diff --git a/scripts/mutate-cultnet-selection-caching.entries.ps1 b/scripts/mutate-cultnet-selection-caching.entries.ps1 index 96b37703..53d75601 100644 --- a/scripts/mutate-cultnet-selection-caching.entries.ps1 +++ b/scripts/mutate-cultnet-selection-caching.entries.ps1 @@ -71,11 +71,17 @@ New = '' }, @{ - Id = 'CACHE-QJ-ExponentExpansion-Revert' - Rule = 'Q-J: a double/float that .NET would render in exponent notation is rewritten to positional digits before canonicalizing.' + # R-D (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): a double/float + # renders as its exact decimal expansion (RenderExactDouble/RenderExactFloat via BigInteger), + # never .NET's shortest round-trip form - the two runtimes' shortest forms round a tie the + # opposite way at some values (394/200k f32, 48/200k f64 in the parity vectors). This anchor + # replaces the pre-R-D CACHE-QJ-ExponentExpansion-Revert, whose ExpandScientificNotation callee + # R-D deleted outright. + Id = 'CACHE-RD-ExactExpansion-Revert' + Rule = 'R-D: a double renders its exact decimal expansion via RenderExactDouble/BigInteger, not .NET''s shortest round-trip ToString().' Mutant = 'revert' Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits' - Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))),' + Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : RenderExactDouble(d),' New = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(d.ToString(CultureInfo.InvariantCulture)),' }, @{ @@ -83,7 +89,19 @@ Rule = 'Q-J: a NaN or infinite member matches no comparison - TryGetIndexNumber must answer false, not a garbage rendering of the value.' Mutant = 'revert' Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberReturnsFalseForNaNAndInfinity' - Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))),' - New = 'double d => CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))),' + Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : RenderExactDouble(d),' + New = 'double d => RenderExactDouble(d),' + }, + @{ + # R-D loosening: RenderExactDecimal's fraction-digit placement is off by one (a plausible + # fencepost slip in the BigInteger-to-string split), so every rendered fraction is 10x too + # large - still "exact-looking" digits, still no exponent, but the wrong value. Caught by the + # same test's pinned exact string for 1e-7's nearest double. + Id = 'CACHE-RD-FractionSplit-Loosening' + Rule = 'R-D: RenderExactDecimal splits the digit string at exactly fractionDigits from the right - not fractionDigits-1, which would shift the whole fractional rendering by a power of ten.' + Mutant = 'loosening' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits' + Old = 'var splitAt = digits.Length - fractionDigits;' + New = 'var splitAt = digits.Length - fractionDigits + 1;' } ) diff --git a/scripts/mutate-cultnet-selection-mesh.entries.ps1 b/scripts/mutate-cultnet-selection-mesh.entries.ps1 index 765b23ee..23363195 100644 --- a/scripts/mutate-cultnet-selection-mesh.entries.ps1 +++ b/scripts/mutate-cultnet-selection-mesh.entries.ps1 @@ -59,14 +59,33 @@ }, @{ Id = 'MESH-FOREIGN-Revert' - Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload''s embedded schema (read by the registry''s shared TryReadSchemaVersion) alias-matches the caller''s own descriptor.' + Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload''s embedded schema (read by the registry''s shared resolver, now CultNetDocumentRegistry.PayloadMatchesSchema after the R-L rename of TryReadSchemaVersion back to internal) alias-matches the caller''s own descriptor.' Mutant = 'revert' File = 'src/GameCult.Mesh/CultMesh.cs' Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments' - Old = 'else if (byPayload == null && - CultNetDocumentRegistry.TryReadSchemaVersion(candidate.Payload) is { } payloadSchemaVersion && - CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor)) + Old = 'else if (byPayload == null && CultNetDocumentRegistry.PayloadMatchesSchema(candidate.Payload, descriptor)) byPayload = candidate;' New = '' + }, + @{ + # R-F (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): the door is + # inside select in both runtimes, and Mesh's EnsureV0Compatible runs the declaration-independent + # half of it (CultNetSelectionValidation.ValidateShape) before its own v0 transport refusals, so + # Keys=[]/[""] is refused there too instead of silently lowering to "every key". + Id = 'MESH-RF-Door-Revert' + Rule = 'R-F: EnsureV0Compatible runs the door (ValidateShape) before its own v0 term refusals, so Keys=[] is refused there, not only inside CultNetSelectionEvaluator.Select.' + Mutant = 'revert' + File = 'src/GameCult.Mesh/CultMeshSnapshots.cs' + Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshSnapshotOptionsTests.FetchSnapshotAsync_RefusesAnEmptyKeysListBeforeTouchingTheClient' + Old = 'if (selection == null) + return; + // The door runs first (docs/cultnet-selection-cut.md, R-F): Mesh has no descriptor list to + // check field/role reachability against, but the declaration-independent half of the door - + // an empty or blank-only schemas/keys list, an unrecognised projection - applies here just + // as it does inside CultNetSelectionEvaluator.Select, so Keys=[]/[""] is refused rather than + // silently lowered to "every key". + CultNetSelectionValidation.ValidateShape(selection);' + New = 'if (selection == null) + return;' } ) diff --git a/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs new file mode 100644 index 00000000..549f4a1b --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs @@ -0,0 +1,50 @@ +using System; +using GameCult.Networking; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +// R-F (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): the door is inside +// select in both runtimes, and CultMesh - which has no descriptor list to check field/role +// reachability against - still runs the declaration-independent half of it +// (CultNetSelectionValidation.ValidateShape) before its own v0 transport refusals, through +// CultMeshSnapshots.EnsureV0Compatible. Keys=[]/[""] is refused there, not silently lowered to +// "every key" the way v0's own cleaning would. +public sealed class CultMeshSnapshotOptionsTests +{ + [Test] + public void FetchSnapshotAsync_RefusesAnEmptyKeysListBeforeTouchingTheClient() + { + var options = new CultMeshSnapshotRequestOptions + { + Selection = new CultNetSelection { Keys = Array.Empty() } + }; + + var ex = Assert.ThrowsAsync(() => + CultMesh.FetchSnapshotAsync( + createClient: () => throw new InvalidOperationException( + "EnsureV0Compatible must refuse before a client is ever created."), + endpoint: "mesh-door-test-endpoint", + options: options)); + + Assert.That(ex!.Field, Is.EqualTo("keys")); + } + + [Test] + public void FetchSnapshotAsync_RefusesABlankOnlyKeysEntryBeforeTouchingTheClient() + { + var options = new CultMeshSnapshotRequestOptions + { + Selection = new CultNetSelection { Keys = new[] { " " } } + }; + + var ex = Assert.ThrowsAsync(() => + CultMesh.FetchSnapshotAsync( + createClient: () => throw new InvalidOperationException( + "EnsureV0Compatible must refuse before a client is ever created."), + endpoint: "mesh-door-test-endpoint", + options: options)); + + Assert.That(ex!.Field, Is.EqualTo("keys")); + } +}