CultLib/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs

101 lines
4.7 KiB
C#
Raw Normal View History

CultNet selection cut1, commit 2f: the Q-J fix (canonical decimal numbers) 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>
2026-09-22 04:18:22 +02:00
#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")]
CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix - R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20 mutants - dropping `wantDocument &&` in ToEdge (a header-projection edge would carry document payload bytes anyway) and always emitting Edges instead of `selection.HasHop ? ... : null` (a hop-less selection's page would carry an empty array instead of no Edges member) - plus a matched-vs-page-length test for R-G. Fixing ToEdge's mutant coverage surfaced a real bug the new tests caught: it serialized an edge payload through CultDocumentMessagePackSerialization, which requires the payload's type to be a registered CultDocument and throws for a plain declared value type (float, in the fixture); it now serializes directly through MessagePackSerializer. - R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's $ matches immediately before a single trailing newline even without RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z and would have accepted "5\n" as canonical without this. Caching's TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits is updated for 1e-7's actual exact decimal expansion (the R-D commit's own effect): the old assertion pinned "0.0000001", the shortest round-trip form a pre-R-D render would have produced. - R-K: every serverThread.Join() in NetworkingTests.cs that sat outside a try/finally now sits inside one, so a failed assertion cannot leave the poll thread running past the point where `using` disposes the socket it polls (an ObjectDisposedException on that raw background Thread was a host crash, not a test failure). The bodyDemand.HasConsumers assertions wait on the condition instead of assuming SubscribeAsync's completion and PublishDemand happened in an order a network round trip does not guarantee. - Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias built a v0-shaped CultNetDatabaseSubscribeMessage to invoke CreateMatchedRecord by reflection; that method now takes the shared SubscriptionRequest (R-A), so the test builds one through the same reflection path instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
// R-D/Q-J (Soul's whole-cut pass, confirmed): .NET's $ matches immediately before a single
// trailing newline even without RegexOptions.Multiline, so a naive `$`-anchored pattern accepts
// "5\n" as canonical - the door must refuse it exactly as it refuses any other non-canonical
// spelling, byte for byte the same as Rust and the JSON schema's ECMA-262 $.
[TestCase("5\n")]
[TestCase("5\r\n")]
CultNet selection cut1, commit 2f: the Q-J fix (canonical decimal numbers) 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>
2026-09-22 04:18:22 +02:00
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));
}
}
}