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); + } }