Fix normalizer sidecar log and coverage test (#4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-04-07 14:17:22 +09:00
parent 7920de15b3
commit 05c7a3f388
6 changed files with 251 additions and 31 deletions

View File

@@ -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`가 디렉터리면 `<dir>/normalization.log`, 아니면 해당 경로에 작성.
- 포맷: `{RuleId}\tcount={Count}` 라인을 RuleId 사전순 정렬, 마지막 `total=<sum>` 라인.
- `src/Recordingtest.Normalizer/Rules.cs`
- `mask_volatile_settings` 규칙 추가. catalog에 등재된 휘발성 boolean/scalar 필드 (GridSnap, IsSidePanelVisible, GridColor.* 등)의 값을 `<VOLATILE>`로 마스킹.
- `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<string,string>` 필드→규칙 매핑 도입.
- 매핑된 규칙이 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

View File

@@ -6,6 +6,9 @@ namespace Recordingtest.Normalizer;
public static class Normalizer public static class Normalizer
{ {
public static NormalizeResult Normalize(string input, string profileName) 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 profile = Profile.Load(profileName);
var log = new List<RuleApplication>(); var log = new List<RuleApplication>();
@@ -99,6 +102,20 @@ public static class Normalizer
} }
break; 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": case "sort_json_keys":
{ {
if (isJson && jsonNode is not null) if (isJson && jsonNode is not null)
@@ -128,6 +145,36 @@ public static class Normalizer
output = current; 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;
} }
} }

View File

@@ -163,6 +163,66 @@ public static class Rules
return false; return false;
} }
/// <summary>
/// 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.
/// </summary>
public static readonly HashSet<string> 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("<VOLATILE>");
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);
}
}
}
}
/// <summary> /// <summary>
/// Returns a new JsonNode with object keys sorted recursively. Counts the number of objects sorted. /// Returns a new JsonNode with object keys sorted recursively. Counts the number of objects sorted.
/// </summary> /// </summary>

View File

@@ -4,4 +4,5 @@ rules:
- mask_guids - mask_guids
- normalize_paths - normalize_paths
- round_floats - round_floats
- mask_volatile_settings
- sort_json_keys - sort_json_keys

View File

@@ -6,21 +6,43 @@ namespace Recordingtest.Normalizer.Tests;
/// <summary> /// <summary>
/// Verifies that every "SuspectedNondeterministicFields" entry in /// Verifies that every "SuspectedNondeterministicFields" entry in
/// docs/sut-catalog/json-configs.json is covered by a default-profile rule. /// docs/sut-catalog/json-configs.json is covered by a semantically appropriate
/// /// rule that is actually present in the default profile.
/// 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.
/// </summary> /// </summary>
public class CoverageTests 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<string, string> 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() private static string FindCatalog()
{ {
var dir = AppContext.BaseDirectory; 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); Assert.NotEmpty(allFields);
var profile = Profile.Load("default"); var profile = Profile.Load("default");
Assert.Contains("normalize_paths", profile.Rules); var profileRules = new HashSet<string>(profile.Rules, StringComparer.Ordinal);
Assert.Contains("sort_json_keys", profile.Rules);
var uncovered = new List<string>(); var unmapped = new List<string>();
var notInProfile = new List<string>();
foreach (var field in allFields) foreach (var field in allFields)
{ {
bool covered = if (!FieldRuleMap.TryGetValue(field, out var rule))
IsPathField(field) // -> normalize_paths {
|| true; // -> sort_json_keys covers any scalar by canonicalising order unmapped.Add(field);
if (!covered) uncovered.Add(field); continue;
}
if (!profileRules.Contains(rule))
{
notInProfile.Add($"{field} -> {rule}");
}
} }
Assert.Empty(uncovered); Assert.True(unmapped.Count == 0,
} "Suspected fields without an explicit semantic rule mapping: " + string.Join(", ", unmapped));
Assert.True(notInProfile.Count == 0,
private static bool IsPathField(string name) "Mapped rules missing from default profile: " + string.Join(", ", notInProfile));
{
return name.EndsWith("Path", StringComparison.Ordinal)
|| name.EndsWith("FileName", StringComparison.Ordinal)
|| name.EndsWith("FilePath", StringComparison.Ordinal);
} }
} }

View File

@@ -67,12 +67,62 @@ public class RuleTests
{ {
var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}"; var input = "{\"ts\":\"2026-04-07T00:00:00Z\",\"x\":1.23456789}";
var r = Normalizer.Normalize(input, "default"); 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(); var ids = r.Log.Select(l => l.RuleId).ToList();
Assert.Contains("strip_timestamps", ids); Assert.Contains("strip_timestamps", ids);
Assert.Contains("mask_guids", ids); Assert.Contains("mask_guids", ids);
Assert.Contains("normalize_paths", ids); Assert.Contains("normalize_paths", ids);
Assert.Contains("round_floats", ids); Assert.Contains("round_floats", ids);
Assert.Contains("sort_json_keys", 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);
}
} }
} }