feat: engine-state normalizer profile + sort_array_elements rule (#10)

- Rules.SortArrayElements: sort JSON arrays lexicographically (post-mask_guids order-independence)
- Normalizer output: UnsafeRelaxedJsonEscaping to preserve <GUID>/<TS>/<VOLATILE> tokens
- profiles/engine-state.yaml: normalize_paths + mask_guids + sort_array_elements + round_floats(2dp) + sort_json_keys
- RunnerOptions.SidecarProfile: default "engine-state", overridable via --sidecar-profile
- TestRunner.CaptureAndDiffSidecar: uses SidecarProfile instead of main Profile
- 4 new normalizer tests (136 total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-09 16:28:57 +09:00
parent 800ea9c175
commit 190cc6e596
8 changed files with 144 additions and 4 deletions

View File

@@ -54,7 +54,9 @@
## In progress ## In progress
_(없음)_ | 날짜 | 항목 | 담당 |
|------|------|------|
| 2026-04-09 | P1-4+5: Runner sidecar 라이브 검증 + normalizer engine-state 프로파일 | Claude Sonnet 4.6 |
## Follow-ups ## Follow-ups

View File

@@ -1,3 +1,4 @@
using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -134,6 +135,20 @@ public static class Normalizer
} }
break; 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: default:
throw new InvalidOperationException($"Unknown rule: {rule}"); throw new InvalidOperationException($"Unknown rule: {rule}");
} }
@@ -142,7 +157,11 @@ public static class Normalizer
string output; string output;
if (isJson && jsonNode is not null) if (isJson && jsonNode is not null)
{ {
output = jsonNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); output = jsonNode.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
} }
else else
{ {

View File

@@ -326,4 +326,51 @@ public static class Rules
} }
return null; return null;
} }
/// <summary>
/// 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.
/// </summary>
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<JsonNode?>(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;
}
} }

View File

@@ -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 → <GUID> (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

View File

@@ -18,11 +18,13 @@ public static class Program
case "--no-launch": options.NoLaunch = true; break; case "--no-launch": options.NoLaunch = true; break;
case "--sidecar-url": sidecarUrl = args[++i]; break; case "--sidecar-url": sidecarUrl = args[++i]; break;
case "--no-sidecar": noSidecar = true; break; case "--no-sidecar": noSidecar = true; break;
case "--sidecar-profile": options.SidecarProfile = args[++i]; break;
case "-h": case "-h":
case "--help": case "--help":
Console.WriteLine("Usage: Recordingtest.Runner --scenarios <dir> --baselines <dir> --out <dir>"); Console.WriteLine("Usage: Recordingtest.Runner --scenarios <dir> --baselines <dir> --out <dir>");
Console.WriteLine(" [--profile <name>] [--no-launch]"); Console.WriteLine(" [--profile <name>] [--no-launch]");
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]"); Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
Console.WriteLine(" [--sidecar-profile engine-state]");
return 0; return 0;
} }
} }

View File

@@ -6,5 +6,7 @@ public sealed class RunnerOptions
public string BaselinesDir { get; set; } = string.Empty; public string BaselinesDir { get; set; } = string.Empty;
public string OutDir { get; set; } = string.Empty; public string OutDir { get; set; } = string.Empty;
public string Profile { get; set; } = "default"; public string Profile { get; set; } = "default";
/// <summary>Profile name for engine-state sidecar normalization. Defaults to "engine-state".</summary>
public string SidecarProfile { get; set; } = "engine-state";
public bool NoLaunch { get; set; } public bool NoLaunch { get; set; }
} }

View File

@@ -197,8 +197,8 @@ public sealed class TestRunner
{ {
var receivedRaw = File.ReadAllText(receivedPath); var receivedRaw = File.ReadAllText(receivedPath);
var approvedRaw = File.ReadAllText(baselinePath); var approvedRaw = File.ReadAllText(baselinePath);
var receivedNorm = normalizer.Normalize(receivedRaw, options.Profile, null); var receivedNorm = normalizer.Normalize(receivedRaw, options.SidecarProfile, null);
var approvedNorm = normalizer.Normalize(approvedRaw, options.Profile, null); var approvedNorm = normalizer.Normalize(approvedRaw, options.SidecarProfile, null);
var receivedNormPath = Path.Combine(artifactDir, "engine-state.received.normalized"); var receivedNormPath = Path.Combine(artifactDir, "engine-state.received.normalized");
var approvedNormPath = Path.Combine(artifactDir, "engine-state.approved.normalized"); var approvedNormPath = Path.Combine(artifactDir, "engine-state.approved.normalized");

View File

@@ -208,4 +208,56 @@ public class RuleTests
if (Directory.Exists(dir)) Directory.Delete(dir, true); 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<string>());
Assert.Equal("banana", arr[1]!.GetValue<string>());
Assert.Equal("cherry", arr[2]!.GetValue<string>());
}
[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<string>());
Assert.Equal("b", ids[1]!.GetValue<string>());
Assert.Equal("c", ids[2]!.GetValue<string>());
}
[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("<GUID>", result);
Assert.DoesNotContain(guid, result);
// fov should be rounded to 2 decimal places
Assert.Contains("45.12", result);
}
} }