From 9ed4a72a530728df5de49596e31fb8816b2982d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:14:35 +0200 Subject: [PATCH] thing-contract: TextInputNode's slot 2 is input_kind in every runtime; the nil vector Rename kind to input_kind at key 2 (the wire is the slot key; no golden byte moves). nil_positions.rs walks every array element of every value golden, splices nil over it and records whether the Rust decode door accepts it, into goldens/nil-positions.tsv. The tsv is blessed by the next commit; this one builds, and its nil test fails until then. golden_bytes, blessing and goldens_dir move to tests/common so both test files share one path. Co-Authored-By: Claude Opus 5.5 --- crates/thing-contract/src/node.rs | 4 +- crates/thing-contract/tests/common/mod.rs | 21 +++ crates/thing-contract/tests/goldens.rs | 23 +--- crates/thing-contract/tests/goldens/README.md | 90 ++++++------ crates/thing-contract/tests/nil_positions.rs | 129 ++++++++++++++++++ 5 files changed, 203 insertions(+), 64 deletions(-) create mode 100644 crates/thing-contract/tests/nil_positions.rs diff --git a/crates/thing-contract/src/node.rs b/crates/thing-contract/src/node.rs index 603950b..622e171 100644 --- a/crates/thing-contract/src/node.rs +++ b/crates/thing-contract/src/node.rs @@ -159,8 +159,10 @@ pub struct TextInputNode { pub common: NodeCommon, #[cultcache(key = 1)] pub multiline: bool, + /// Named `input_kind` in every runtime (Rust `input_kind`, C# `InputKind`, TypeScript `inputKind`): a field + /// named `kind` collides with the union tag in TypeScript. The wire is the slot key, 2. #[cultcache(key = 2)] - pub kind: InputKind, + pub input_kind: InputKind, #[cultcache(key = 3)] pub placeholder: TextSource, #[cultcache(key = 4)] diff --git a/crates/thing-contract/tests/common/mod.rs b/crates/thing-contract/tests/common/mod.rs index ee6f71b..5696035 100644 --- a/crates/thing-contract/tests/common/mod.rs +++ b/crates/thing-contract/tests/common/mod.rs @@ -3,8 +3,29 @@ #![allow(dead_code)] +use std::path::PathBuf; + use thing_contract::*; +pub fn goldens_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") +} + +pub fn blessing() -> bool { + std::env::var("THING_BLESS").as_deref() == Ok("1") +} + +/// Bless the file or compare against it. Returns the committed bytes. +pub fn golden_bytes(file: &str, produced: &[u8]) -> Vec { + let path = goldens_dir().join(file); + if blessing() { + std::fs::write(&path, produced).unwrap_or_else(|error| panic!("{file}: {error}")); + } + let committed = std::fs::read(&path).unwrap_or_else(|error| panic!("{file}: {error}")); + assert_eq!(committed, produced, "{file}: the committed golden differs from what the crate writes"); + committed +} + pub fn seq(value: i32) -> Seq { Seq::new(value).unwrap() } diff --git a/crates/thing-contract/tests/goldens.rs b/crates/thing-contract/tests/goldens.rs index 71a5c23..e5c940d 100644 --- a/crates/thing-contract/tests/goldens.rs +++ b/crates/thing-contract/tests/goldens.rs @@ -16,25 +16,6 @@ const PINNED_CLOCK: &str = "2026-01-01T00:00:00Z"; // A value that must never appear in a refusal. const CANARY: &str = "CANARY-7f3a91c2"; -fn goldens_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/goldens") -} - -fn blessing() -> bool { - std::env::var("THING_BLESS").as_deref() == Ok("1") -} - -/// Bless the file or compare against it. Returns the committed bytes. -fn golden_bytes(file: &str, produced: &[u8]) -> Vec { - let path = goldens_dir().join(file); - if blessing() { - std::fs::write(&path, produced).unwrap_or_else(|error| panic!("{file}: {error}")); - } - let committed = std::fs::read(&path).unwrap_or_else(|error| panic!("{file}: {error}")); - assert_eq!(committed, produced, "{file}: the committed golden differs from what the crate writes"); - committed -} - fn golden(name: &str, value: &T) -> String { let produced = rmp_serde::to_vec(value).unwrap(); let committed = golden_bytes(&format!("{name}.bin"), &produced); @@ -150,7 +131,7 @@ fn full_surface() -> SurfaceDocument { Node::TextInput(TextInputNode { common: common("name-input"), multiline: true, - kind: InputKind::Number, + input_kind: InputKind::Number, placeholder: lit_text("name"), initial: TextSource::Param(param("initial")), submit: command("rename"), @@ -158,7 +139,7 @@ fn full_surface() -> SurfaceDocument { Node::TextInput(TextInputNode { common: common("title-input"), multiline: false, - kind: InputKind::Text, + input_kind: InputKind::Text, placeholder: lit_text("title"), initial: TextSource::Param(param("initial")), submit: command("retitle"), diff --git a/crates/thing-contract/tests/goldens/README.md b/crates/thing-contract/tests/goldens/README.md index 0e7f7d9..277067c 100644 --- a/crates/thing-contract/tests/goldens/README.md +++ b/crates/thing-contract/tests/goldens/README.md @@ -1,42 +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. +# 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/crates/thing-contract/tests/nil_positions.rs b/crates/thing-contract/tests/nil_positions.rs new file mode 100644 index 0000000..27ee98d --- /dev/null +++ b/crates/thing-contract/tests/nil_positions.rs @@ -0,0 +1,129 @@ +//! The nil vector: for every element of every array of every value golden, the golden with that +//! element replaced by nil, and whether the Rust decode door accepts it. A slot is required +//! exactly when its Rust type is not optional, so the rows are the required-slot set; the C# and +//! TypeScript packages hold no list of their own and are held to every row. +//! `THING_BLESS=1` rewrites `goldens/nil-positions.tsv`; any other run compares. + +mod common; + +use std::fmt::Debug; + +use common::*; +use serde::Serialize; +use serde::de::DeserializeOwned; +use thing_contract::*; + +/// One MessagePack value's byte span; `kids` is set for arrays only (maps are never walked). +struct Span { + start: usize, + end: usize, + kids: Option>, +} + +fn be(bytes: &[u8], at: usize, width: usize) -> usize { + bytes[at..at + width].iter().fold(0, |acc, byte| acc << 8 | *byte as usize) +} + +fn collection(bytes: &[u8], start: usize, mut at: usize, count: usize, is_array: bool) -> Span { + let mut kids = Vec::new(); + for _ in 0..count { + let kid = item(bytes, at); + at = kid.end; + kids.push(kid); + } + Span { start, end: at, kids: is_array.then_some(kids) } +} + +fn item(bytes: &[u8], at: usize) -> Span { + let tag = bytes[at]; + let flat = |len: usize| Span { start: at, end: at + len, kids: None }; + match tag { + 0x00..=0x7f | 0xe0..=0xff | 0xc0 | 0xc2 | 0xc3 => flat(1), + 0x80..=0x8f => collection(bytes, at, at + 1, (tag & 0x0f) as usize * 2, false), + 0x90..=0x9f => collection(bytes, at, at + 1, (tag & 0x0f) as usize, true), + 0xa0..=0xbf => flat(1 + (tag & 0x1f) as usize), + 0xc4 | 0xd9 => flat(2 + be(bytes, at + 1, 1)), + 0xc5 | 0xda => flat(3 + be(bytes, at + 1, 2)), + 0xc6 | 0xdb => flat(5 + be(bytes, at + 1, 4)), + 0xca => flat(5), + 0xcb => flat(9), + 0xcc | 0xd0 => flat(2), + 0xcd | 0xd1 => flat(3), + 0xce | 0xd2 => flat(5), + 0xcf | 0xd3 => flat(9), + 0xdc => collection(bytes, at, at + 3, be(bytes, at + 1, 2), true), + 0xdd => collection(bytes, at, at + 5, be(bytes, at + 1, 4), true), + 0xde => collection(bytes, at, at + 3, be(bytes, at + 1, 2) * 2, false), + 0xdf => collection(bytes, at, at + 5, be(bytes, at + 1, 4) * 2, false), + 0xd4 => flat(3), + 0xd5 => flat(4), + 0xd6 => flat(6), + 0xd7 => flat(10), + 0xd8 => flat(18), + 0xc7 => flat(3 + be(bytes, at + 1, 1)), + 0xc8 => flat(4 + be(bytes, at + 1, 2)), + 0xc9 => flat(6 + be(bytes, at + 1, 4)), + _ => panic!("unsupported tag {tag:#x} at {at}"), + } +} + +/// Every element inside an array, at every depth, in walk order, with its dotted index path. +fn elements(span: &Span, prefix: &str, out: &mut Vec<(String, usize, usize)>) { + for (index, kid) in span.kids.iter().flatten().enumerate() { + let path = if prefix.is_empty() { index.to_string() } else { format!("{prefix}.{index}") }; + out.push((path.clone(), kid.start, kid.end)); + elements(kid, &path, out); + } +} + +/// The rows of one golden: nil spliced over each element, decoded as `T`. An accepted variant +/// must re-encode to the spliced bytes. +fn rows(file: &str, out: &mut String) -> (usize, usize) { + let bytes = std::fs::read(goldens_dir().join(format!("{file}.bin"))).unwrap(); + let mut found = Vec::new(); + elements(&item(&bytes, 0), "", &mut found); + let (mut accepted, mut refused) = (0, 0); + for (path, start, end) in found { + let mut spliced = bytes[..start].to_vec(); + spliced.push(0xc0); + spliced.extend_from_slice(&bytes[end..]); + let verdict = match rmp_serde::from_slice::(&spliced) { + Ok(value) => { + assert_eq!(rmp_serde::to_vec(&value).unwrap(), spliced, "{file} {path}: an accepted nil must re-encode to the spliced bytes"); + accepted += 1; + "ACCEPT" + } + Err(_) => { + refused += 1; + "REFUSE" + } + }; + out.push_str(&format!("{file}\t{path}\t{start}\t{end}\t{verdict}\n")); + } + (accepted, refused) +} + +#[test] +fn nil_positions_match() { + let mut table = String::new(); + let mut totals = Vec::new(); + totals.push(("surface-all-arms", rows::("surface-all-arms", &mut table))); + totals.push(("composition-all-params", rows::("composition-all-params", &mut table))); + totals.push(("invocation-all-inputs", rows::("invocation-all-inputs", &mut table))); + for file in [ + "result-pending", + "result-completed", + "result-completed-open-surface", + "result-completed-open-external", + "result-denied", + "result-denied-no-node", + "result-failed", + ] { + totals.push((file, rows::(file, &mut table))); + } + totals.push(("asset-catalog", rows::("asset-catalog", &mut table))); + for (file, (accepted, refused)) in &totals { + println!("NILTOTAL {file} accept={accepted} refuse={refused}"); + } + golden_bytes("nil-positions.tsv", table.as_bytes()); +}