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:
41
docs/history/2026-04-07_이슈4-normalizer-iteration2.md
Normal file
41
docs/history/2026-04-07_이슈4-normalizer-iteration2.md
Normal 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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user