diff --git a/crates/thing-contract/tests/goldens/README.md b/crates/thing-contract/tests/goldens/README.md index 277067c..4d66e7d 100644 --- a/crates/thing-contract/tests/goldens/README.md +++ b/crates/thing-contract/tests/goldens/README.md @@ -1,48 +1,48 @@ -# thing-contract goldens - -Written only by `crates/thing-contract/tests/goldens.rs`, with `THING_BLESS=1` set on that test. -Every other run compares byte for byte. The TypeScript and C# packages and core decode these files -and are held to the answers below; none of them writes one. - -Each `.bin` is the payload of one record: the positional slot array of its kind, as MessagePack. -A union value is `[armKey, [armSlots...]]`; a unit arm is `[key, []]`. An optional member is nil -when absent. Arm keys are fixed for v1. - -## Values (decode to the constructed value, re-encode to the same bytes) - -| File | Kind | Decodes to | -|---|---|---| -| `surface-all-arms.bin` | `gamecult.thing.surface.v1` | One surface with all 13 node arms (Box 0, Text 1, Image 2, Button 3, Toggle 4, Slider 5, TextInput 6 as both input kinds, Text 0 and Number 1, Select 7, Repeat 8, Overlay 9, Plot 10, Embed 11, Use 12), a Box chain four levels deep, every text, number and bool source arm, every Format arm, both state sources, both member steps, every dimension and colour arm, and two declared commands | -| `composition-all-params.bin` | `gamecult.thing.composition.v1` | A composition with one parameter of each of the 7 kinds, whose body uses a composition with one argument of each of the 7 argument arms | -| `invocation-all-inputs.bin` | `gamecult.thing.command_invocation.v1` | An invocation with a payload record of bin bytes and one captured input of each of the 4 input arms | -| `result-pending.bin` | `gamecult.thing.command_result.v1` | Outcome Pending, seq 1 | -| `result-completed.bin` | same | Outcome Completed with source version 8, drafts cleared, no effect | -| `result-completed-open-surface.bin` | same | Completed with no source version and the OpenSurface effect | -| `result-completed-open-external.bin` | same | Completed with the OpenExternal effect and a resume command | -| `result-denied.bin` | same | Denied naming a node | -| `result-denied-no-node.bin` | same | Denied with a nil node | -| `result-failed.bin` | same | Failed | -| `asset-catalog.bin` | `gamecult.thing.asset_catalog.v1` | Two assets | -| `surface.cc` | single-file store | One record of each of the five kinds, written under the pinned clock `2026-01-01T00:00:00Z`; reading it and writing it again gives the same bytes | - -## Refusals and tolerance - -| File | Payload | Expected | -|---|---|---| -| `result-thing1-flat.bin` | a MessagePack map with named members, the shape a Thing 1 provider wrote for a command result | refused when stored as a command result; the error names the kind and the record key and never a value | -| `node-unknown-arm.bin` | a surface whose root node has arm key 13, which Node does not declare | refused; the error names the kind and the record key, and decoded directly it names the union Node and the key 13, and never a value | -| `outcome-unknown-arm.bin` | a result whose outcome has arm key 4, which Outcome does not declare | refused in the same way, naming the union Outcome and the key 4 | -| `result-extra-slot.bin` | a Pending result with a fourth slot past the last one declared | read: it decodes to the Pending result with seq 1 (CultLib's extra-trailing-slots ruling: read, never written back) | -| `result-seq-zero.bin` | a result whose seq slot is 0 | refused; the error names the kind and the record key, decoded directly it names Seq, and never a value | -| `surface-version-negative.bin` | a surface whose version slot is -1 | refused in the same way, naming Version | -| `surface-label-dotted.bin` | a surface whose label is `b.c` | refused in the same way, naming Segment: a label holds no dot, so a record key splits at its last dot | -| `asset-extent-negative.bin` | an asset catalog whose first asset has width -5 | refused in the same way, naming Count | -| `rgba-channel-out-of-range.bin` | the Rgba slot array `[256, 0, 0, 255]` | refused when decoded as an Rgba: a channel is a byte, 0 to 255 | - -Domain refusals are the contract's rule, which the TypeScript and C# packages mirror: each is held to refuse these files, without the leaf set a Rust `u8` or a checked newtype has. - -## The nil vector - -`nil-positions.tsv` is written only by `crates/thing-contract/tests/nil_positions.rs` (`THING_BLESS=1`). For every element inside an array, at every depth, of each value golden above except `surface.cc` (the eleven `.bin` files from `surface-all-arms` to `asset-catalog`), the test replaces that element with nil (`0xC0`) and decodes the whole payload with the Rust decode door, `rmp_serde::from_slice` into the golden's kind. A row is `golden`, `path` (dotted array indices from the root, e.g. `3.0.1`), the element's byte `start` and `end` in the golden, and `ACCEPT` or `REFUSE`. An accepted variant re-encodes to the spliced bytes. Maps are not walked. - -A slot is required exactly when its Rust type is not optional, so `REFUSE` rows are the required slots and `ACCEPT` rows the optional ones. The Rust decode door decides every row; the TypeScript and C# packages keep no list of required slots and are held to every row. +# thing-contract goldens + +Written only by `crates/thing-contract/tests/goldens.rs`, with `THING_BLESS=1` set on that test. +Every other run compares byte for byte. The TypeScript and C# packages and core decode these files +and are held to the answers below; none of them writes one. + +Each `.bin` is the payload of one record: the positional slot array of its kind, as MessagePack. +A union value is `[armKey, [armSlots...]]`; a unit arm is `[key, []]`. An optional member is nil +when absent. Arm keys are fixed for v1. + +## Values (decode to the constructed value, re-encode to the same bytes) + +| File | Kind | Decodes to | +|---|---|---| +| `surface-all-arms.bin` | `gamecult.thing.surface.v1` | One surface with all 13 node arms (Box 0, Text 1, Image 2, Button 3, Toggle 4, Slider 5, TextInput 6 as both input kinds, Text 0 and Number 1, Select 7, Repeat 8, Overlay 9, Plot 10, Embed 11, Use 12), a Box chain four levels deep, every text, number and bool source arm, every Format arm, both state sources, both member steps, every dimension and colour arm, and two declared commands | +| `composition-all-params.bin` | `gamecult.thing.composition.v1` | A composition with one parameter of each of the 7 kinds, whose body uses a composition with one argument of each of the 7 argument arms | +| `invocation-all-inputs.bin` | `gamecult.thing.command_invocation.v1` | An invocation with a payload record of bin bytes and one captured input of each of the 4 input arms | +| `result-pending.bin` | `gamecult.thing.command_result.v1` | Outcome Pending, seq 1 | +| `result-completed.bin` | same | Outcome Completed with source version 8, drafts cleared, no effect | +| `result-completed-open-surface.bin` | same | Completed with no source version and the OpenSurface effect | +| `result-completed-open-external.bin` | same | Completed with the OpenExternal effect and a resume command | +| `result-denied.bin` | same | Denied naming a node | +| `result-denied-no-node.bin` | same | Denied with a nil node | +| `result-failed.bin` | same | Failed | +| `asset-catalog.bin` | `gamecult.thing.asset_catalog.v1` | Two assets | +| `surface.cc` | single-file store | One record of each of the five kinds, written under the pinned clock `2026-01-01T00:00:00Z`; reading it and writing it again gives the same bytes | + +## Refusals and tolerance + +| File | Payload | Expected | +|---|---|---| +| `result-thing1-flat.bin` | a MessagePack map with named members, the shape a Thing 1 provider wrote for a command result | refused when stored as a command result; the error names the kind and the record key and never a value | +| `node-unknown-arm.bin` | a surface whose root node has arm key 13, which Node does not declare | refused; the error names the kind and the record key, and decoded directly it names the union Node and the key 13, and never a value | +| `outcome-unknown-arm.bin` | a result whose outcome has arm key 4, which Outcome does not declare | refused in the same way, naming the union Outcome and the key 4 | +| `result-extra-slot.bin` | a Pending result with a fourth slot past the last one declared | read: it decodes to the Pending result with seq 1 (CultLib's extra-trailing-slots ruling: read, never written back) | +| `result-seq-zero.bin` | a result whose seq slot is 0 | refused; the error names the kind and the record key, decoded directly it names Seq, and never a value | +| `surface-version-negative.bin` | a surface whose version slot is -1 | refused in the same way, naming Version | +| `surface-label-dotted.bin` | a surface whose label is `b.c` | refused in the same way, naming Segment: a label holds no dot, so a record key splits at its last dot | +| `asset-extent-negative.bin` | an asset catalog whose first asset has width -5 | refused in the same way, naming Count | +| `rgba-channel-out-of-range.bin` | the Rgba slot array `[256, 0, 0, 255]` | refused when decoded as an Rgba: a channel is a byte, 0 to 255 | + +Domain refusals are the contract's rule, which the TypeScript and C# packages mirror: each is held to refuse these files, without the leaf set a Rust `u8` or a checked newtype has. + +## The nil vector + +`nil-positions.tsv` is written only by `crates/thing-contract/tests/nil_positions.rs` (`THING_BLESS=1`). For every element inside an array, at every depth, of each value golden above except `surface.cc` (the eleven `.bin` files from `surface-all-arms` to `asset-catalog`), the test replaces that element with nil (`0xC0`) and decodes the whole payload with the Rust decode door, `rmp_serde::from_slice` into the golden's kind. A row is `golden`, `path` (dotted array indices from the root, e.g. `3.0.1`), the element's byte `start` and `end` in the golden, and `ACCEPT` or `REFUSE`. An accepted variant re-encodes to the spliced bytes. Maps are not walked. + +A slot is required exactly when its Rust type is not optional, so `REFUSE` rows are the required slots and `ACCEPT` rows the optional ones. The Rust decode door decides every row; the TypeScript and C# packages keep no list of required slots and are held to every row. diff --git a/tests/GameCult.Thing.Contract.Tests/NameSetTests.cs b/tests/GameCult.Thing.Contract.Tests/NameSetTests.cs index f987b44..428ddc6 100644 --- a/tests/GameCult.Thing.Contract.Tests/NameSetTests.cs +++ b/tests/GameCult.Thing.Contract.Tests/NameSetTests.cs @@ -35,6 +35,19 @@ public sealed class NameSetTests Assert.That(actual, Has.Length.GreaterThan(150)); } + private static int Replace(byte[] bytes, byte[] from, byte[] to) + { + var count = 0; + for (var i = 0; i + from.Length <= bytes.Length; i++) + { + if (!bytes.AsSpan(i, from.Length).SequenceEqual(from)) continue; + to.CopyTo(bytes, i); + count++; + } + + return count; + } + [Test] public void AStoreWhoseRecordedTypeNameChangedIsRefusedOnReopen() { @@ -50,24 +63,22 @@ public sealed class NameSetTests using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true })) Assert.That(cache.GetAll().Single().Assets, Has.Count.EqualTo(2), "the unpatched store reopens"); + // A store written by a different type shape carries a different schema id and different type names. The + // reopen compares names only when the ids differ, so the patch moves both: the id's last digit and Int64. + var id = Wire.Registry.GetRequired(typeof(AssetCatalog)).ToCatalogEntry().SchemaId; + var movedId = id[..^1] + (id[^1] == '0' ? '1' : '0'); var bytes = File.ReadAllBytes(scratch); - var from = "System.Int64"u8.ToArray(); - var to = "System.Int32"u8.ToArray(); - var patched = 0; - for (var i = 0; i + from.Length <= bytes.Length; i++) - { - if (!bytes.AsSpan(i, from.Length).SequenceEqual(from)) continue; - to.CopyTo(bytes, i); - patched++; - } - + var patched = Replace(bytes, System.Text.Encoding.UTF8.GetBytes(id), System.Text.Encoding.UTF8.GetBytes(movedId)) + + Replace(bytes, "System.Int64"u8.ToArray(), "System.Int32"u8.ToArray()); Assert.That(patched, Is.GreaterThan(0), "the store records the type name"); File.WriteAllBytes(scratch, bytes); var error = Assert.Catch(() => { using var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Wire.Registry, ReadOnly = true }); + _ = cache.GetAll().ToList(); })!; TestContext.Progress.WriteLine($"NAMEPATCH patched={patched} refused-by: {Wire.Flatten(error)}"); + Assert.That(Wire.Flatten(error), Does.Contain("System.Int64").And.Contain("System.Int32"), "the refusal is the type-name comparison"); } finally { diff --git a/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs b/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs index 6f81e02..23a8636 100644 --- a/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs +++ b/tests/GameCult.Thing.Contract.Tests/PresenceTests.cs @@ -51,7 +51,7 @@ public sealed class PresenceTests Assert.That(failures, Is.Empty); TestContext.Progress.WriteLine($"PRESENCE required={required} optional={optional}"); - Assert.That(required, Is.GreaterThan(200), "the scan reached the required slots"); + Assert.That(required, Is.GreaterThan(150), "the scan reached the required slots"); Assert.That(optional, Is.GreaterThan(15), "the scan reached the optional slots"); }