CultLib/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs
Meta Vens 915262fee2 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

101 lines
4.7 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")]
// 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")]
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));
}
}
}