diff --git a/PROGRESS.md b/PROGRESS.md
index 9514f67..4fcaba9 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -54,7 +54,9 @@
## In progress
-_(없음)_
+| 날짜 | 항목 | 담당 |
+|------|------|------|
+| 2026-04-09 | P1-4+5: Runner sidecar 라이브 검증 + normalizer engine-state 프로파일 | Claude Sonnet 4.6 |
## Follow-ups
diff --git a/src/Recordingtest.Normalizer/Normalizer.cs b/src/Recordingtest.Normalizer/Normalizer.cs
index a256478..d6b4135 100644
--- a/src/Recordingtest.Normalizer/Normalizer.cs
+++ b/src/Recordingtest.Normalizer/Normalizer.cs
@@ -1,3 +1,4 @@
+using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -134,6 +135,20 @@ public static class Normalizer
}
break;
}
+ case "sort_array_elements":
+ {
+ if (isJson && jsonNode is not null)
+ {
+ var (n, c) = Rules.SortArrayElements(jsonNode);
+ jsonNode = n;
+ log.Add(new RuleApplication(rule, c));
+ }
+ else
+ {
+ log.Add(new RuleApplication(rule, 0));
+ }
+ break;
+ }
default:
throw new InvalidOperationException($"Unknown rule: {rule}");
}
@@ -142,7 +157,11 @@ public static class Normalizer
string output;
if (isJson && jsonNode is not null)
{
- output = jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ output = jsonNode.ToJsonString(new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ });
}
else
{
diff --git a/src/Recordingtest.Normalizer/Rules.cs b/src/Recordingtest.Normalizer/Rules.cs
index 0a230a9..3db73ac 100644
--- a/src/Recordingtest.Normalizer/Rules.cs
+++ b/src/Recordingtest.Normalizer/Rules.cs
@@ -326,4 +326,51 @@ public static class Rules
}
return null;
}
+
+ ///
+ /// Sorts JSON array elements lexicographically (by their JSON serialization).
+ /// Recurses into objects and nested arrays. Counts the number of arrays sorted.
+ /// Use after mask_guids so that GUID-based IDs are order-independent.
+ ///
+ public static (JsonNode? node, int count) SortArrayElements(JsonNode? node)
+ {
+ int count = 0;
+ var result = SortArraysInternal(node, ref count);
+ return (result, count);
+ }
+
+ private static JsonNode? SortArraysInternal(JsonNode? node, ref int count)
+ {
+ if (node is JsonObject obj)
+ {
+ var newObj = new JsonObject();
+ foreach (var kv in obj)
+ {
+ newObj[kv.Key] = SortArraysInternal(kv.Value, ref count);
+ }
+ return newObj;
+ }
+ if (node is JsonArray arr)
+ {
+ count++;
+ // Recursively process children first
+ var processed = new List(arr.Count);
+ foreach (var item in arr)
+ processed.Add(SortArraysInternal(item, ref count));
+ // Sort by JSON serialization
+ processed.Sort((a, b) =>
+ StringComparer.Ordinal.Compare(
+ a?.ToJsonString() ?? "null",
+ b?.ToJsonString() ?? "null"));
+ var newArr = new JsonArray();
+ foreach (var item in processed)
+ newArr.Add(item);
+ return newArr;
+ }
+ if (node is JsonValue v)
+ {
+ return JsonNode.Parse(v.ToJsonString());
+ }
+ return null;
+ }
}
diff --git a/src/Recordingtest.Normalizer/profiles/engine-state.yaml b/src/Recordingtest.Normalizer/profiles/engine-state.yaml
new file mode 100644
index 0000000..9e43065
--- /dev/null
+++ b/src/Recordingtest.Normalizer/profiles/engine-state.yaml
@@ -0,0 +1,16 @@
+name: engine-state
+# Sidecar JSON profile: {"scene":{...}, "camera":{...}, "selection":{...}}
+#
+# Rules applied in order:
+# 1. normalize_paths — mask document_path (user/temp dirs)
+# 2. mask_guids — selected_ids GUIDs → (order becomes irrelevant)
+# 3. sort_array_elements — sort selected_ids after masking
+# 4. round_floats — camera eye/target/up coords with 2 decimal places
+# 5. sort_json_keys — deterministic key order for clean diff
+float_decimals: 2
+rules:
+ - normalize_paths
+ - mask_guids
+ - sort_array_elements
+ - round_floats
+ - sort_json_keys
diff --git a/src/Recordingtest.Runner/Program.cs b/src/Recordingtest.Runner/Program.cs
index d02c8f1..527b3fa 100644
--- a/src/Recordingtest.Runner/Program.cs
+++ b/src/Recordingtest.Runner/Program.cs
@@ -18,11 +18,13 @@ public static class Program
case "--no-launch": options.NoLaunch = true; break;
case "--sidecar-url": sidecarUrl = args[++i]; break;
case "--no-sidecar": noSidecar = true; break;
+ case "--sidecar-profile": options.SidecarProfile = args[++i]; break;
case "-h":
case "--help":
Console.WriteLine("Usage: Recordingtest.Runner --scenarios --baselines --out ");
Console.WriteLine(" [--profile ] [--no-launch]");
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
+ Console.WriteLine(" [--sidecar-profile engine-state]");
return 0;
}
}
diff --git a/src/Recordingtest.Runner/RunnerOptions.cs b/src/Recordingtest.Runner/RunnerOptions.cs
index a2d5a23..868faf4 100644
--- a/src/Recordingtest.Runner/RunnerOptions.cs
+++ b/src/Recordingtest.Runner/RunnerOptions.cs
@@ -6,5 +6,7 @@ public sealed class RunnerOptions
public string BaselinesDir { get; set; } = string.Empty;
public string OutDir { get; set; } = string.Empty;
public string Profile { get; set; } = "default";
+ /// Profile name for engine-state sidecar normalization. Defaults to "engine-state".
+ public string SidecarProfile { get; set; } = "engine-state";
public bool NoLaunch { get; set; }
}
diff --git a/src/Recordingtest.Runner/TestRunner.cs b/src/Recordingtest.Runner/TestRunner.cs
index a1369d8..2a10538 100644
--- a/src/Recordingtest.Runner/TestRunner.cs
+++ b/src/Recordingtest.Runner/TestRunner.cs
@@ -197,8 +197,8 @@ public sealed class TestRunner
{
var receivedRaw = File.ReadAllText(receivedPath);
var approvedRaw = File.ReadAllText(baselinePath);
- var receivedNorm = normalizer.Normalize(receivedRaw, options.Profile, null);
- var approvedNorm = normalizer.Normalize(approvedRaw, options.Profile, null);
+ var receivedNorm = normalizer.Normalize(receivedRaw, options.SidecarProfile, null);
+ var approvedNorm = normalizer.Normalize(approvedRaw, options.SidecarProfile, null);
var receivedNormPath = Path.Combine(artifactDir, "engine-state.received.normalized");
var approvedNormPath = Path.Combine(artifactDir, "engine-state.approved.normalized");
diff --git a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs
index 1cd982f..7b17d78 100644
--- a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs
+++ b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs
@@ -208,4 +208,56 @@ public class RuleTests
if (Directory.Exists(dir)) Directory.Delete(dir, true);
}
}
+
+ // ---- sort_array_elements tests -----------------------------------------
+
+ [Fact]
+ public void SortArrayElements_SortsTopLevelArray()
+ {
+ var node = JsonNode.Parse("[\"banana\",\"apple\",\"cherry\"]");
+ var (n, c) = Rules.SortArrayElements(node);
+ Assert.Equal(1, c);
+ var arr = n!.AsArray();
+ Assert.Equal("apple", arr[0]!.GetValue());
+ Assert.Equal("banana", arr[1]!.GetValue());
+ Assert.Equal("cherry", arr[2]!.GetValue());
+ }
+
+ [Fact]
+ public void SortArrayElements_NestedArray_SortedIndependently()
+ {
+ var node = JsonNode.Parse("{\"ids\":[\"c\",\"a\",\"b\"]}");
+ var (n, c) = Rules.SortArrayElements(node);
+ Assert.Equal(1, c);
+ var ids = n!["ids"]!.AsArray();
+ Assert.Equal("a", ids[0]!.GetValue());
+ Assert.Equal("b", ids[1]!.GetValue());
+ Assert.Equal("c", ids[2]!.GetValue());
+ }
+
+ [Fact]
+ public void SortArrayElements_AfterGuidMask_OrderIndependent()
+ {
+ // Simulate: selected_ids with two different GUIDs in different orders
+ const string json1 = "{\"selection\":{\"selected_ids\":[\"aaa\",\"bbb\"]}}";
+ const string json2 = "{\"selection\":{\"selected_ids\":[\"bbb\",\"aaa\"]}}";
+ var (n1, _) = Rules.SortArrayElements(JsonNode.Parse(json1));
+ var (n2, _) = Rules.SortArrayElements(JsonNode.Parse(json2));
+ Assert.Equal(
+ n1!["selection"]!["selected_ids"]!.ToJsonString(),
+ n2!["selection"]!["selected_ids"]!.ToJsonString());
+ }
+
+ [Fact]
+ public void Normalize_EngineStateProfile_AppliesAllRules()
+ {
+ // engine-state profile: normalize_paths, mask_guids, sort_array_elements, round_floats, sort_json_keys
+ var guid = "12345678-1234-1234-1234-123456789012";
+ var json = $"{{\"selection\":{{\"selected_ids\":[\"{guid}\"]}},\"camera\":{{\"fov\":45.123456789}}}}";
+ var result = Normalizer.Normalize(json, "engine-state").Output;
+ Assert.Contains("", result);
+ Assert.DoesNotContain(guid, result);
+ // fov should be rounded to 2 decimal places
+ Assert.Contains("45.12", result);
+ }
}