normalizer: configurable float epsilon + JSON-path volatile mask scoping

Follow-ups to #4 normalizer PoC v2:
- Profile.float_decimals (default 6) flows into Rules.RoundFloatsInNode.
- mask_volatile_settings switches from name-only HashSet to a JSONPath-lite
  allowlist ($.a.b.c) so same-named fields in unrelated subtrees stay intact.
- default.yaml migrated; 6 new tests including a regression trap for the
  unrelated-subtree case. 16/16 normalizer tests, 77/77 solution tests.

Refs #2
This commit is contained in:
minsung
2026-04-07 20:42:27 +09:00
parent 0f0324efb5
commit eeee3c2a03
6 changed files with 238 additions and 26 deletions

View File

@@ -109,6 +109,89 @@ public class RuleTests
}
}
[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<double>());
}
[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<double>());
}
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<double>());
}
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("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
Assert.Equal(1, n!["Other"]!.GetValue<int>());
}
[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("<VOLATILE>", n!["GridSnap"]!.GetValue<string>());
// Unrelated subtree must remain its original boolean value.
Assert.False(n!["Foo"]!["GridSnap"]!.GetValue<bool>());
}
[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("<VOLATILE>", n!["GridColor"]!["R"]!.GetValue<string>());
Assert.Equal(34, n!["GridColor"]!["G"]!.GetValue<int>());
}
[Fact]
public void Normalize_SidecarPath_AcceptsDirectory()
{