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 <noreply@anthropic.com>
This commit is contained in:
parent
3e46195b3f
commit
9ed4a72a53
5 changed files with 203 additions and 64 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<T: DatabaseEntry + PartialEq + Debug>(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"),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
129
crates/thing-contract/tests/nil_positions.rs
Normal file
129
crates/thing-contract/tests/nil_positions.rs
Normal file
|
|
@ -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<Vec<Span>>,
|
||||
}
|
||||
|
||||
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<T: DeserializeOwned + Serialize + Debug>(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::<T>(&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::<SurfaceDocument>("surface-all-arms", &mut table)));
|
||||
totals.push(("composition-all-params", rows::<CompositionDocument>("composition-all-params", &mut table)));
|
||||
totals.push(("invocation-all-inputs", rows::<CommandInvocation>("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::<CommandResult>(file, &mut table)));
|
||||
}
|
||||
totals.push(("asset-catalog", rows::<AssetCatalog>("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());
|
||||
}
|
||||
Loading…
Reference in a new issue