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:
@@ -6,21 +6,43 @@ namespace Recordingtest.Normalizer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
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<string>(profile.Rules, StringComparer.Ordinal);
|
||||
|
||||
var uncovered = new List<string>();
|
||||
var unmapped = new List<string>();
|
||||
var notInProfile = new List<string>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user