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:
@@ -54,7 +54,9 @@
|
||||
|
||||
## In progress
|
||||
|
||||
_(없음)_
|
||||
| 날짜 | 항목 | 담당 |
|
||||
|------|------|------|
|
||||
| 2026-04-09 | P1-4+5: Runner sidecar 라이브 검증 + normalizer engine-state 프로파일 | Claude Sonnet 4.6 |
|
||||
|
||||
## Follow-ups
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -326,4 +326,51 @@ public static class Rules
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
16
src/Recordingtest.Normalizer/profiles/engine-state.yaml
Normal file
16
src/Recordingtest.Normalizer/profiles/engine-state.yaml
Normal 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
|
||||
@@ -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 <dir> --baselines <dir> --out <dir>");
|
||||
Console.WriteLine(" [--profile <name>] [--no-launch]");
|
||||
Console.WriteLine(" [--sidecar-url http://localhost:38080] [--no-sidecar]");
|
||||
Console.WriteLine(" [--sidecar-profile engine-state]");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user