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 and ", 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("", 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("", 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()); } [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()); } [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()); } 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()); } 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("", n!["GridSnap"]!.GetValue()); Assert.Equal(1, n!["Other"]!.GetValue()); } [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("", n!["GridSnap"]!.GetValue()); // Unrelated subtree must remain its original boolean value. Assert.False(n!["Foo"]!["GridSnap"]!.GetValue()); } [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("", n!["GridColor"]!["R"]!.GetValue()); Assert.Equal(34, n!["GridColor"]!["G"]!.GetValue()); } [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()); 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); } }