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
|
## In progress
|
||||||
|
|
||||||
_(없음)_
|
| 날짜 | 항목 | 담당 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-04-09 | P1-4+5: Runner sidecar 라이브 검증 + normalizer engine-state 프로파일 | Claude Sonnet 4.6 |
|
||||||
|
|
||||||
## Follow-ups
|
## Follow-ups
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 "--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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user