diff --git a/docs/history/2026-04-07_이슈4-normalizer-iteration2.md b/docs/history/2026-04-07_이슈4-normalizer-iteration2.md new file mode 100644 index 0000000..185edc9 --- /dev/null +++ b/docs/history/2026-04-07_이슈4-normalizer-iteration2.md @@ -0,0 +1,41 @@ +# 2026-04-07 이슈 #4 — Normalizer Iteration 2 (Generator) + +## 요약 +Evaluator가 fail 처리한 두 항목(DoD #6 sidecar 로그, DoD #7 suspected-field 커버리지)을 수정. + +## 변경 사항 +- `src/Recordingtest.Normalizer/Normalizer.cs` + - `Normalize(string input, string profileName, string? sidecarPath = null)` 오버로드 추가. + - `sidecarPath`가 디렉터리면 `/normalization.log`, 아니면 해당 경로에 작성. + - 포맷: `{RuleId}\tcount={Count}` 라인을 RuleId 사전순 정렬, 마지막 `total=` 라인. +- `src/Recordingtest.Normalizer/Rules.cs` + - `mask_volatile_settings` 규칙 추가. catalog에 등재된 휘발성 boolean/scalar 필드 (GridSnap, IsSidePanelVisible, GridColor.* 등)의 값을 ``로 마스킹. +- `src/Recordingtest.Normalizer/profiles/default.yaml` + - `mask_volatile_settings` 규칙을 sort_json_keys 직전에 추가. +- `tests/Recordingtest.Normalizer.Tests/RuleTests.cs` + - sidecar 파일/디렉터리 두 케이스 테스트 추가. + - default 적용 규칙 수 5 → 6 갱신, `mask_volatile_settings` 포함 검증. +- `tests/Recordingtest.Normalizer.Tests/CoverageTests.cs` + - `|| true` 단락 제거. 명시적 `Dictionary` 필드→규칙 매핑 도입. + - 매핑된 규칙이 default 프로파일에 실제로 존재하는지 검증. + - 매핑 없는 필드는 explicit 메시지로 fail. + +## 매핑 (suspectedNondeterministicFields → 규칙) +- `AutoSaveFilePath`, `AutoSave_RecentFileName` → `normalize_paths` +- `CanOverrideWireColorWithFace`, `IsSidePanelVisible`, `OverrideFaceColor`, `Solar_IsLocalTime`, `VisibleGrid`, `GridSnap`, `MidpointOsnap`, `GridSpacing` → `mask_volatile_settings` +- `GridColor.{ALPHA,BLUE,GREEN,RED}`, `MajorGridColor.{ALPHA,BLUE,GREEN,RED}` → `mask_volatile_settings` + +## 결과 +- `dotnet build recordingtest.sln`: 경고 0, 오류 0 +- `dotnet test tests/Recordingtest.Normalizer.Tests`: 10/10 pass (기존 8 + sidecar 2) +- 강화된 coverage 테스트가 명시적 매핑으로 실제 통과함 (단락 없음). +- sidecar 파일 작성 verified (파일 존재 + 라인 포맷 + 정렬 + total 합계). + +## 소요 시간 +약 15분 + +## Context 사용량 +약 35k tokens + +## 관련 이슈 +#4 diff --git a/src/Recordingtest.Normalizer/Normalizer.cs b/src/Recordingtest.Normalizer/Normalizer.cs index 63b8338..3e387d0 100644 --- a/src/Recordingtest.Normalizer/Normalizer.cs +++ b/src/Recordingtest.Normalizer/Normalizer.cs @@ -6,6 +6,9 @@ namespace Recordingtest.Normalizer; public static class Normalizer { public static NormalizeResult Normalize(string input, string profileName) + => Normalize(input, profileName, null); + + public static NormalizeResult Normalize(string input, string profileName, string? sidecarPath) { var profile = Profile.Load(profileName); var log = new List(); @@ -99,6 +102,20 @@ public static class Normalizer } break; } + case "mask_volatile_settings": + { + if (isJson && jsonNode is not null) + { + var (n, c) = Rules.MaskVolatileSettings(jsonNode); + jsonNode = n; + log.Add(new RuleApplication(rule, c)); + } + else + { + log.Add(new RuleApplication(rule, 0)); + } + break; + } case "sort_json_keys": { if (isJson && jsonNode is not null) @@ -128,6 +145,36 @@ public static class Normalizer output = current; } - return new NormalizeResult(output, log); + var result = new NormalizeResult(output, log); + + if (sidecarPath is not null) + { + string filePath; + if (Directory.Exists(sidecarPath)) + { + filePath = Path.Combine(sidecarPath, "normalization.log"); + } + else + { + var parent = Path.GetDirectoryName(sidecarPath); + if (!string.IsNullOrEmpty(parent) && !Directory.Exists(parent)) + { + Directory.CreateDirectory(parent); + } + filePath = sidecarPath; + } + + var sb = new System.Text.StringBuilder(); + int total = 0; + foreach (var entry in log.OrderBy(l => l.RuleId, StringComparer.Ordinal)) + { + sb.Append(entry.RuleId).Append("\tcount=").Append(entry.Count).Append('\n'); + total += entry.Count; + } + sb.Append("total=").Append(total).Append('\n'); + File.WriteAllText(filePath, sb.ToString()); + } + + return result; } } diff --git a/src/Recordingtest.Normalizer/Rules.cs b/src/Recordingtest.Normalizer/Rules.cs index ed78112..ab1d484 100644 --- a/src/Recordingtest.Normalizer/Rules.cs +++ b/src/Recordingtest.Normalizer/Rules.cs @@ -163,6 +163,66 @@ public static class Rules return false; } + /// + /// Allowlist of field names whose values are known to be volatile boolean/scalar + /// settings (per docs/sut-catalog/json-configs.json). The values are replaced with + /// a deterministic placeholder so golden-file comparisons stay stable while still + /// preserving the field's presence and key order. + /// + public static readonly HashSet VolatileSettingFieldNames = new(StringComparer.Ordinal) + { + "CanOverrideWireColorWithFace", + "IsSidePanelVisible", + "OverrideFaceColor", + "Solar_IsLocalTime", + "VisibleGrid", + "GridSnap", + "MidpointOsnap", + "GridSpacing", + "GridColor.ALPHA", + "GridColor.BLUE", + "GridColor.GREEN", + "GridColor.RED", + "MajorGridColor.ALPHA", + "MajorGridColor.BLUE", + "MajorGridColor.GREEN", + "MajorGridColor.RED", + }; + + public static (JsonNode? node, int count) MaskVolatileSettings(JsonNode? node) + { + int count = 0; + if (node is null) return (null, 0); + Walk(node); + return (node, count); + + void Walk(JsonNode n) + { + if (n is JsonObject obj) + { + foreach (var kv in obj.ToList()) + { + if (VolatileSettingFieldNames.Contains(kv.Key)) + { + obj[kv.Key] = JsonValue.Create(""); + count++; + } + else if (kv.Value is JsonObject || kv.Value is JsonArray) + { + Walk(kv.Value); + } + } + } + else if (n is JsonArray arr) + { + foreach (var item in arr) + { + if (item is JsonObject || item is JsonArray) Walk(item); + } + } + } + } + /// /// Returns a new JsonNode with object keys sorted recursively. Counts the number of objects sorted. /// diff --git a/src/Recordingtest.Normalizer/profiles/default.yaml b/src/Recordingtest.Normalizer/profiles/default.yaml index 85e3e3e..dafced0 100644 --- a/src/Recordingtest.Normalizer/profiles/default.yaml +++ b/src/Recordingtest.Normalizer/profiles/default.yaml @@ -4,4 +4,5 @@ rules: - mask_guids - normalize_paths - round_floats + - mask_volatile_settings - sort_json_keys diff --git a/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs b/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs index c2ad204..f0f2233 100644 --- a/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs +++ b/tests/Recordingtest.Normalizer.Tests/CoverageTests.cs @@ -6,21 +6,43 @@ namespace Recordingtest.Normalizer.Tests; /// /// Verifies that every "SuspectedNondeterministicFields" entry in -/// docs/sut-catalog/json-configs.json is covered by a default-profile rule. -/// -/// Mapping rationale: -/// - Field names ending in "Path" / "FileName" / "FilePath" -> normalize_paths -/// (their VALUES are absolute filesystem paths) -/// - All other suspected fields are simple scalar settings whose order in the -/// serialized JSON varies between runs. These are covered by sort_json_keys, -/// which produces a canonical key ordering so the resulting bytes are -/// deterministic regardless of which suspected scalar fields exist. -/// - The default profile additionally provides strip_timestamps, mask_guids, -/// and round_floats for the value-level non-determinism not catalogued in -/// json-configs.json yet. +/// docs/sut-catalog/json-configs.json is covered by a semantically appropriate +/// rule that is actually present in the default profile. /// public class CoverageTests { + // Explicit field -> rule mapping. Each entry must be a rule that semantically + // covers the kind of value the field holds. + // *Path / *FileName / *RecentFile* -> normalize_paths + // Known volatile boolean / color / scalar settings -> mask_volatile_settings + // No catch-all to sort_json_keys for arbitrary scalars. + private static readonly Dictionary FieldRuleMap = new(StringComparer.Ordinal) + { + // path-bearing + ["AutoSaveFilePath"] = "normalize_paths", + ["AutoSave_RecentFileName"] = "normalize_paths", + + // volatile boolean / scalar UI settings + ["CanOverrideWireColorWithFace"] = "mask_volatile_settings", + ["IsSidePanelVisible"] = "mask_volatile_settings", + ["OverrideFaceColor"] = "mask_volatile_settings", + ["Solar_IsLocalTime"] = "mask_volatile_settings", + ["VisibleGrid"] = "mask_volatile_settings", + ["GridSnap"] = "mask_volatile_settings", + ["MidpointOsnap"] = "mask_volatile_settings", + ["GridSpacing"] = "mask_volatile_settings", + + // volatile color channels + ["GridColor.ALPHA"] = "mask_volatile_settings", + ["GridColor.BLUE"] = "mask_volatile_settings", + ["GridColor.GREEN"] = "mask_volatile_settings", + ["GridColor.RED"] = "mask_volatile_settings", + ["MajorGridColor.ALPHA"] = "mask_volatile_settings", + ["MajorGridColor.BLUE"] = "mask_volatile_settings", + ["MajorGridColor.GREEN"] = "mask_volatile_settings", + ["MajorGridColor.RED"] = "mask_volatile_settings", + }; + private static string FindCatalog() { var dir = AppContext.BaseDirectory; @@ -51,30 +73,29 @@ public class CoverageTests } } - // Sanity: catalog must have produced at least one field, otherwise the - // assertion below is vacuous. Assert.NotEmpty(allFields); var profile = Profile.Load("default"); - Assert.Contains("normalize_paths", profile.Rules); - Assert.Contains("sort_json_keys", profile.Rules); + var profileRules = new HashSet(profile.Rules, StringComparer.Ordinal); - var uncovered = new List(); + var unmapped = new List(); + var notInProfile = new List(); foreach (var field in allFields) { - bool covered = - IsPathField(field) // -> normalize_paths - || true; // -> sort_json_keys covers any scalar by canonicalising order - if (!covered) uncovered.Add(field); + if (!FieldRuleMap.TryGetValue(field, out var rule)) + { + unmapped.Add(field); + continue; + } + if (!profileRules.Contains(rule)) + { + notInProfile.Add($"{field} -> {rule}"); + } } - Assert.Empty(uncovered); - } - - private static bool IsPathField(string name) - { - return name.EndsWith("Path", StringComparison.Ordinal) - || name.EndsWith("FileName", StringComparison.Ordinal) - || name.EndsWith("FilePath", StringComparison.Ordinal); + Assert.True(unmapped.Count == 0, + "Suspected fields without an explicit semantic rule mapping: " + string.Join(", ", unmapped)); + Assert.True(notInProfile.Count == 0, + "Mapped rules missing from default profile: " + string.Join(", ", notInProfile)); } } diff --git a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs index 839dd44..3cda6bc 100644 --- a/tests/Recordingtest.Normalizer.Tests/RuleTests.cs +++ b/tests/Recordingtest.Normalizer.Tests/RuleTests.cs @@ -67,12 +67,62 @@ public class RuleTests { var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}"; var r = Normalizer.Normalize(input, "default"); - Assert.Equal(5, r.Log.Count); + 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 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); + } } }