Corrects commit 1's Number field: docs/cultnet-selection-cut.md section 2 "Numbers" (added 2026-09-22, superseding the map's own stale float64 text) specifies canonical decimal strings per the operator's Q-J ruling, and commits 0/1 shipped double instead - the Rust Hands caught the conflict before writing a line and stopped; this is the correction the map's Progress block calls for before Rust starts. - CultNetFieldPredicate.Number: double? -> string?. New CultNetCanonicalNumber (CultNetSelection.cs) is the wire's canonical grammar and comparator: IsCanonical validates ^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$ and excludes "-0" (the regex alone matches it); Compare orders by sign, then the integer part's length, then its digits, then the fraction padded on the right to equal length - a pure string function, no double anywhere on the path. ValidateField refuses a non-canonical number at the door (selection_invalid) rather than normalising it. - CultNetSelectionEvaluator.CompareNumber now takes two canonical strings and switches on CultNetCanonicalNumber.Compare's sign instead of comparing doubles directly. - CultCache.cs: CultDocumentDescriptor.TryGetIndexNumber and PersistedMember.NumberOf render a member's exact canonical decimal instead of a double. An integer renders exact (no 2^53 limit); decimal strips trailing fractional zeros; float/double use .NET's shortest round-trip ToString(), rewritten out of exponent notation to positional digits (ExpandScientificNotation) before the shared CanonicalizeDecimalDigits strips zeros and collapses a negative-zero result to "0"; NaN and infinity render null (TryGetIndexNumber returns false, matching "matches no comparison"). - contracts/cultnet/cultnet.selection.schema.json: "number" is now a string with the canonical pattern (a defence-in-depth (?!-0$) lookahead beyond what the door enforces in code), not a JSON number. - Tests: CultNetCanonicalNumberTests (new) pins IsCanonical/Compare directly, including the door's five named non-canonical spellings, magnitude vs. lexicographic ordering, sign, integer-part length, and a long past 2^53 against its float64 rounding. CultNetSelectionEvaluatorTests gains ValidationRefusesNonCanonicalNumberSpellings (+1, 1.0, 01, 1e3, -0) and its existing Number literals become canonical strings. CultDocumentSelectionSurfaceTests gains a dedicated FixtureLeafNumbers fixture and four tests: the 2^53+1 long rendering exactly, a decimal's trailing zeros stripped, a double's exponent notation rewritten to positional digits (1e21, 1e-7), and NaN/Infinity answering false. Mutation entries (scripts/mutate-dotnet.ps1): four new Caching entries (integer-through-float64, decimal trailing zeros, exponent expansion, NaN/Infinity) and five new Networking entries (the door's regex, the door's explicit "-0" exclusion, the comparator's integer-length check, a lexicographic-whole-string mutant, and the comparator's sign check) - all KILLED. The two pre-existing S16 (le/ge boundary) entries had to be re-anchored: their Old text quoted CompareNumber's previous double-based `left <= right`/`left >= right` spelling, which no longer exists now that CompareNumber switches on Compare's int result: `cmp <= 0`/`cmp >= 0`. Verification: dotnet test - GameCult.Networking.Tests 192/192 (was 183 before these additions), GameCult.Caching.Tests 192/192 (was 188), GameCult.Mesh.Tests 252/253 (one pre-existing unrelated skip, unaffected by this commit - no Mesh source touched). Caching mutation run: 10/10 KILLED (6 pre-existing + 4 new), M0 and final both green. Networking mutation run: 15/15 KILLED (10 pre-existing + 5 new, across CultNetSelectionEvaluator.cs, CultNetDatabase.cs and CultNetSelection.cs), M0 and final both green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
4.3 KiB
C#
95 lines
4.3 KiB
C#
#nullable enable
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Networking.Tests
|
|
{
|
|
// Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the wire's canonical
|
|
// decimal form - the door's grammar and the comparator that reads it. Pure string-in functions,
|
|
// independent of the cache and the evaluator; CultDocumentSelectionSurfaceTests (GameCult.Caching.Tests)
|
|
// pins the row side's rendering of the same form.
|
|
public sealed class CultNetCanonicalNumberTests
|
|
{
|
|
[TestCase("0")]
|
|
[TestCase("1")]
|
|
[TestCase("-1")]
|
|
[TestCase("10")]
|
|
[TestCase("0.5")]
|
|
[TestCase("-0.5")]
|
|
[TestCase("9007199254740993")]
|
|
[TestCase("123.456")]
|
|
public void IsCanonicalAcceptsCanonicalSpellings(string value) =>
|
|
Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.True);
|
|
|
|
// The door's named mutants: a leading '+', a trailing fractional zero, a leading integer zero,
|
|
// exponent notation, and negative zero are each refused, never normalised.
|
|
[TestCase("+1")]
|
|
[TestCase("1.0")]
|
|
[TestCase("01")]
|
|
[TestCase("1e3")]
|
|
[TestCase("1E3")]
|
|
[TestCase("-0")]
|
|
[TestCase("")]
|
|
[TestCase(" 1")]
|
|
[TestCase("1.")]
|
|
[TestCase("1..5")]
|
|
public void IsCanonicalRefusesNonCanonicalSpellings(string value) =>
|
|
Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.False);
|
|
|
|
[Test]
|
|
public void IsCanonicalRefusesNull() =>
|
|
Assert.That(CultNetCanonicalNumber.IsCanonical(null), Is.False);
|
|
|
|
// The comparator: sign first, then the integer part's length, then its digits, then the
|
|
// fraction padded on the right - never a lexicographic compare of the whole string, which
|
|
// would rank "10" below "9".
|
|
[Test]
|
|
public void CompareOrdersByMagnitudeNotLexicographically()
|
|
{
|
|
Assert.That(CultNetCanonicalNumber.Compare("10", "9"), Is.GreaterThan(0));
|
|
Assert.That(CultNetCanonicalNumber.Compare("9", "10"), Is.LessThan(0));
|
|
}
|
|
|
|
[Test]
|
|
public void CompareOrdersNegativeBelowPositive()
|
|
{
|
|
Assert.That(CultNetCanonicalNumber.Compare("-1", "1"), Is.LessThan(0));
|
|
Assert.That(CultNetCanonicalNumber.Compare("1", "-1"), Is.GreaterThan(0));
|
|
Assert.That(CultNetCanonicalNumber.Compare("-10", "-2"), Is.LessThan(0));
|
|
}
|
|
|
|
// Same leading digit, different length: "100" > "99" even though '1' < '9' as a bare character
|
|
// comparison - this is what a comparator that skips the integer part's length gets wrong.
|
|
[Test]
|
|
public void CompareOrdersByIntegerPartLengthBeforeDigits()
|
|
{
|
|
Assert.That(CultNetCanonicalNumber.Compare("100", "99"), Is.GreaterThan(0));
|
|
Assert.That(CultNetCanonicalNumber.Compare("99", "100"), Is.LessThan(0));
|
|
}
|
|
|
|
// "1.5" vs "1.45": padded to "50" vs "45" so 1.5 > 1.45. Comparing the raw, unpadded fraction
|
|
// strings would get this wrong ("5" > "45" as characters is true by luck, "45" > "5" as strings
|
|
// sorted by length first is not the rule either) - padding to equal length is the rule.
|
|
[Test]
|
|
public void CompareOrdersFractionsByPaddedDigitsNotRawLength()
|
|
{
|
|
Assert.That(CultNetCanonicalNumber.Compare("1.5", "1.45"), Is.GreaterThan(0));
|
|
Assert.That(CultNetCanonicalNumber.Compare("1.45", "1.5"), Is.LessThan(0));
|
|
}
|
|
|
|
[Test]
|
|
public void CompareTreatsEqualCanonicalStringsAsEqual()
|
|
{
|
|
Assert.That(CultNetCanonicalNumber.Compare("5", "5"), Is.EqualTo(0));
|
|
Assert.That(CultNetCanonicalNumber.Compare("-0.5", "-0.5"), Is.EqualTo(0));
|
|
}
|
|
|
|
// The row-rendering mutant named in section 2 "Numbers": a long past 2^53 (where double loses
|
|
// integer precision) must still compare correctly once rendered. This pins the comparator's
|
|
// side of that guarantee; CultDocumentSelectionSurfaceTests pins the cache's rendering side.
|
|
[Test]
|
|
public void CompareDistinguishesALongPast2Pow53FromItsFloat64Rounding()
|
|
{
|
|
Assert.That(CultNetCanonicalNumber.Compare("9007199254740993", "9007199254740992"), Is.GreaterThan(0));
|
|
}
|
|
}
|
|
}
|