Files
minsung 190cc6e596 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>
2026-04-09 16:28:57 +09:00

264 lines
9.7 KiB
C#

using System.Text.Json.Nodes;
using Xunit;
using Recordingtest.Normalizer;
namespace Recordingtest.Normalizer.Tests;
public class RuleTests
{
[Fact]
public void StripTimestamps_ReplacesIso8601()
{
var input = "saved at 2026-04-07T12:34:56.789Z and 2025-01-02 03:04:05";
var (o, c) = Rules.StripTimestamps(input);
Assert.Equal(2, c);
Assert.Equal("saved at <TS> and <TS>", o);
}
[Fact]
public void MaskGuids_ReplacesUuids()
{
var input = "id=550e8400-e29b-41d4-a716-446655440000 done";
var (o, c) = Rules.MaskGuids(input);
Assert.Equal(1, c);
Assert.Contains("<GUID>", o);
}
[Fact]
public void NormalizePaths_ReplacesRepoAndUser()
{
Environment.SetEnvironmentVariable("RECORDINGTEST_REPO", @"D:\proj\recordingtest");
var input = @"file: D:\proj\recordingtest\foo\bar.txt";
var (o, c) = Rules.NormalizePaths(input);
Assert.True(c >= 1);
Assert.Contains("<REPO>", o);
Environment.SetEnvironmentVariable("RECORDINGTEST_REPO", null);
}
[Fact]
public void RoundFloats_RoundsToSixDecimals()
{
var node = JsonNode.Parse("{\"x\": 3.1415926535897932, \"n\": 1}");
var (n, c) = Rules.RoundFloatsInNode(node);
Assert.Equal(1, c);
Assert.Equal(3.141593, n!["x"]!.GetValue<double>());
}
[Fact]
public void SortJsonKeys_RecursivelySortsObjects()
{
var node = JsonNode.Parse("{\"b\":1,\"a\":{\"y\":2,\"x\":1}}");
var (sorted, _) = Rules.SortJsonKeys(node);
var s = sorted!.ToJsonString();
Assert.Equal("{\"a\":{\"x\":1,\"y\":2},\"b\":1}", s);
}
[Fact]
public void Normalize_IsIdempotent()
{
var input = "{\"b\":2.0000001,\"a\":\"2026-04-07T00:00:00Z\",\"id\":\"550e8400-e29b-41d4-a716-446655440000\"}";
var first = Normalizer.Normalize(input, "default");
var second = Normalizer.Normalize(first.Output, "default");
Assert.Equal(first.Output, second.Output);
}
[Fact]
public void Normalize_AppliesAllDefaultRules()
{
var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}";
var r = Normalizer.Normalize(input, "default");
Assert.Equal(6, r.Log.Count);
var ids = r.Log.Select(l => l.RuleId).ToList();
Assert.Contains("strip_timestamps", ids);
Assert.Contains("mask_guids", ids);
Assert.Contains("normalize_paths", ids);
Assert.Contains("round_floats", ids);
Assert.Contains("sort_json_keys", ids);
Assert.Contains("mask_volatile_settings", ids);
}
[Fact]
public void Normalize_WritesSidecarLogFile()
{
var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"id\":\"550e8400-e29b-41d4-a716-446655440000\",\"GridSnap\":true,\"x\":1.234567}";
var tmp = Path.Combine(Path.GetTempPath(), "norm-sidecar-" + Guid.NewGuid().ToString("N") + ".log");
try
{
var r = Normalizer.Normalize(input, "default", tmp);
Assert.True(File.Exists(tmp), "sidecar file should exist at " + tmp);
var text = File.ReadAllText(tmp);
// every rule from result.Log should appear with matching count
int total = 0;
foreach (var entry in r.Log)
{
Assert.Contains($"{entry.RuleId}\tcount={entry.Count}", text);
total += entry.Count;
}
Assert.Contains($"total={total}", text);
// sorted by RuleId
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var ruleLines = lines.Where(l => !l.StartsWith("total=")).ToList();
var sorted = ruleLines.OrderBy(l => l, StringComparer.Ordinal).ToList();
Assert.Equal(sorted, ruleLines);
}
finally
{
if (File.Exists(tmp)) File.Delete(tmp);
}
}
[Fact]
public void RoundFloats_DefaultProfile_Rounds6Decimals()
{
var input = "{\"x\": 3.1415926535897932}";
var r = Normalizer.Normalize(input, "default");
var node = JsonNode.Parse(r.Output)!;
Assert.Equal(3.141593, node["x"]!.GetValue<double>());
}
[Fact]
public void RoundFloats_ProfileWithDecimals3_RoundsTo3()
{
// Write a temp profile next to the loaded profiles dir.
var baseDir = AppContext.BaseDirectory;
var dest = Path.Combine(baseDir, "profiles", "test_decimals3.yaml");
File.WriteAllText(dest,
"name: test_decimals3\nfloat_decimals: 3\nrules:\n - round_floats\n");
try
{
var input = "{\"x\": 3.1415926535}";
var r = Normalizer.Normalize(input, "test_decimals3");
var node = JsonNode.Parse(r.Output)!;
Assert.Equal(3.142, node["x"]!.GetValue<double>());
}
finally
{
if (File.Exists(dest)) File.Delete(dest);
}
}
[Fact]
public void Profile_OmittedFloatDecimals_DefaultsTo6()
{
var baseDir = AppContext.BaseDirectory;
var dest = Path.Combine(baseDir, "profiles", "test_no_decimals.yaml");
File.WriteAllText(dest, "name: test_no_decimals\nrules:\n - round_floats\n");
try
{
var profile = Profile.Load("test_no_decimals");
Assert.Null(profile.FloatDecimals);
var input = "{\"x\": 3.1415926535897932}";
var r = Normalizer.Normalize(input, "test_no_decimals");
var node = JsonNode.Parse(r.Output)!;
Assert.Equal(3.141593, node["x"]!.GetValue<double>());
}
finally
{
if (File.Exists(dest)) File.Delete(dest);
}
}
[Fact]
public void MaskVolatileSettings_RootField_Masks()
{
var node = JsonNode.Parse("{\"GridSnap\":true,\"Other\":1}");
var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridSnap" });
Assert.Equal(1, c);
Assert.Equal("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
Assert.Equal(1, n!["Other"]!.GetValue<int>());
}
[Fact]
public void MaskVolatileSettings_SameNameInUnrelatedSubtree_NotMasked()
{
// Root has GridSnap (should mask), and an unrelated subtree Foo.GridSnap (should NOT mask).
var node = JsonNode.Parse("{\"GridSnap\":true,\"Foo\":{\"GridSnap\":false}}");
var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridSnap" });
Assert.Equal(1, c);
Assert.Equal("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
// Unrelated subtree must remain its original boolean value.
Assert.False(n!["Foo"]!["GridSnap"]!.GetValue<bool>());
}
[Fact]
public void MaskVolatileSettings_NestedPath_MasksCorrectly()
{
var node = JsonNode.Parse("{\"GridColor\":{\"R\":12,\"G\":34}}");
var (n, c) = Rules.MaskVolatileSettings(node, new[] { "$.GridColor.R" });
Assert.Equal(1, c);
Assert.Equal("<VOLATILE>", n!["GridColor"]!["R"]!.GetValue<string>());
Assert.Equal(34, n!["GridColor"]!["G"]!.GetValue<int>());
}
[Fact]
public void Normalize_SidecarPath_AcceptsDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), "norm-sidecar-dir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
Normalizer.Normalize("{\"a\":1}", "default", dir);
var expected = Path.Combine(dir, "normalization.log");
Assert.True(File.Exists(expected));
}
finally
{
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);
}
}