thing-contract tests: goldens harness (bless or compare), refusal cases, deep tree, result constructors (goldens not yet blessed; the goldens test fails at this commit)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 04:03:19 +02:00
commit 896a6a1c83
3 changed files with 852 additions and 0 deletions

View file

@ -0,0 +1,130 @@
//! Constructors for the values the goldens and the other tests share. Every value is built
//! through the crate's public types; no bytes are spelled here.
#![allow(dead_code)]
use thing_contract::*;
pub fn nid(id: &str) -> NodeId {
NodeId::new(id)
}
pub fn lit_text(value: &str) -> TextSource {
TextSource::Literal(TextLiteral { value: value.into() })
}
pub fn lit_bool(value: bool) -> BoolSource {
BoolSource::Literal(BoolLiteral { value })
}
pub fn lit_number(value: f64) -> NumberSource {
NumberSource::Literal(NumberLiteral { value })
}
pub fn param(name: &str) -> ParamName {
ParamName { name: name.into() }
}
pub fn path(names: &[&str]) -> MemberPath {
MemberPath { steps: names.iter().map(|name| MemberStep::Name(MemberName { name: (*name).into() })).collect() }
}
pub fn record_bind(member: MemberPath, format: Option<Format>) -> Bind {
Bind {
source: StateSource::Record(StateRef {
provider: Some(ProviderId::new("provider.a")),
schema: SchemaId::new("gamecult.thing.test.v1"),
key: RecordKey::new("row-1"),
}),
member,
format,
}
}
pub fn item_bind(member: MemberPath) -> Bind {
Bind { source: StateSource::Item, member, format: None }
}
pub fn command(id: &str) -> CommandRef {
CommandRef::Id(CommandIdRef { id: CommandId::new(id) })
}
pub fn common(id: &str) -> NodeCommon {
NodeCommon { id: nid(id), visible: lit_bool(true), a11y_label: None }
}
pub fn px(value: f64) -> Dimension {
Dimension::Px(Scalar { value })
}
pub fn size2(width: Dimension, height: Dimension) -> Size2 {
Size2 { width, height }
}
pub fn edges(all: Dimension) -> Edges {
Edges { top: all.clone(), right: all.clone(), bottom: all.clone(), left: all }
}
pub fn style() -> BoxStyle {
BoxStyle {
display: Display::Flex,
direction: Direction::Column,
wrap: Wrap::NoWrap,
gap: size2(px(4.0), px(8.0)),
padding: edges(px(2.0)),
margin: edges(Dimension::Auto),
size: size2(Dimension::Percent(Scalar { value: 100.0 }), Dimension::Content),
min_size: size2(px(0.0), px(0.0)),
max_size: size2(Dimension::Auto, Dimension::Auto),
position: Position::Relative,
inset: edges(Dimension::Auto),
overflow: Overflow::Visible,
align_items: ItemAlign::Stretch,
align_self: ItemAlign::Auto,
align_content: ContentAlign::Start,
justify_content: ContentAlign::SpaceBetween,
grow: 1.0,
shrink: 0.0,
basis: Dimension::Auto,
grid_columns: vec![],
grid_rows: vec![],
}
}
pub fn plain_paint() -> Paint {
Paint { background: None, border: None, radius: 0.0 }
}
pub fn boxed(id: &str, children: Vec<Node>) -> Node {
Node::Box(BoxNode { common: common(id), style: style(), paint: plain_paint(), role: Role::Group, children })
}
pub fn text(id: &str, content: &str) -> Node {
Node::Text(TextNode {
common: common(id),
content: lit_text(content),
style_token: "body".into(),
wrap: true,
max_lines: None,
selectable: false,
})
}
pub fn invocation() -> CommandInvocation {
CommandInvocation {
invocation_id: "3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b".into(),
surface_key: "thing:surface:provider.a.main".into(),
surface_version: 7,
command: CommandId::new("go"),
payload: RecordPayload { schema: SchemaId::new("gamecult.thing.test.go.v1"), bytes: vec![0x91, 0x01, 0xff, 0x00] },
captured: vec![
CapturedInput { node: nid("name-input"), value: InputValue::Text(TextValue { value: "Ada".into() }) },
CapturedInput { node: nid("level"), value: InputValue::Number(NumberValue { value: 0.75 }) },
CapturedInput { node: nid("flag"), value: InputValue::Bool(BoolValue { value: true }) },
CapturedInput { node: nid("pick"), value: InputValue::Choice(ChoiceValue { value: "a".into() }) },
],
client: "renderer.web".into(),
issued_at: "2026-01-01T00:00:00Z".into(),
delegation: Some("delegate-1".into()),
}
}

View file

@ -0,0 +1,641 @@
//! The goldens: payload bytes written by this crate and nobody else, then compared byte for
//! byte. The TypeScript and C# packages and core are held to these files; none of them writes
//! one. `THING_BLESS=1` rewrites them; any other run compares.
mod common;
use std::collections::BTreeSet;
use std::fmt::Debug;
use std::path::PathBuf;
use common::*;
use cultcache_rs::{CultCache, CultCacheEnvelope, CultCacheRegistry, DatabaseEntry, SingleFileMessagePackBackingStore};
use thing_contract::*;
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);
let decoded: T = rmp_serde::from_slice(&committed).unwrap_or_else(|error| panic!("{name}: {error}"));
assert_eq!(&decoded, value, "{name}: the golden does not decode to the constructed value");
assert_eq!(rmp_serde::to_vec(&decoded).unwrap(), committed, "{name}: re-encoding changed the bytes");
name.to_string()
}
fn bind_all_formats() -> Vec<Bind> {
let number = Format::Number(NumberFormat { precision: 2, percent: true, unit: Some("ms".into()) });
let affix = Format::Affix(AffixFormat { prefix: "$".into(), suffix: "/s".into() });
let indexed = MemberPath {
steps: vec![
MemberStep::Name(MemberName { name: "items".into() }),
MemberStep::Index(MemberIndex { index: 3 }),
],
};
vec![
record_bind(indexed, Some(number)),
record_bind(path(&["elapsed"]), Some(Format::Duration)),
record_bind(path(&["at"]), Some(Format::Timestamp)),
record_bind(path(&["price"]), Some(affix)),
record_bind(path(&["plain"]), None),
item_bind(path(&["name"])),
]
}
/// A surface using all 13 node arms, nested more than four deep, with every source arm.
fn full_surface() -> SurfaceDocument {
let binds = bind_all_formats();
let bound_text = TextSource::Bound(binds[3].clone());
let bound_bool = BoolSource::Bound(binds[4].clone());
let bound_number = NumberSource::Bound(binds[0].clone());
let mut painted = BoxNode {
common: NodeCommon {
id: nid("painted"),
visible: BoolSource::Param(param("show")),
a11y_label: Some(TextSource::Param(param("label"))),
},
style: style(),
paint: Paint {
background: Some(ColorRef::Token(ThemeToken { name: "surface.raised".into() })),
border: Some(Border { color: ColorRef::Rgba(Rgba { r: 1, g: 2, b: 3, a: 255 }), width: 1.5 }),
radius: 4.0,
},
role: Role::Region,
children: vec![],
};
painted.style.display = Display::Grid;
painted.style.grid_columns = vec![Dimension::Fr(Scalar { value: 1.0 }), px(120.0)];
painted.style.grid_rows = vec![Dimension::Content];
painted.style.position = Position::Absolute;
painted.style.overflow = Overflow::Scroll;
painted.style.direction = Direction::RowReverse;
painted.style.wrap = Wrap::WrapReverse;
painted.style.align_items = ItemAlign::Baseline;
painted.style.align_self = ItemAlign::Center;
painted.style.align_content = ContentAlign::SpaceAround;
painted.style.justify_content = ContentAlign::SpaceEvenly;
painted.children = vec![text("deep-text", "deep")];
let repeat = Node::Repeat(RepeatNode {
common: common("rows"),
collection: CollectionSource::Records(RecordsSource {
provider: None,
schema: SchemaId::new("gamecult.thing.test.v1"),
key_prefix: "row-".into(),
}),
key_member: path(&["id"]),
template: std::boxed::Box::new(Node::Text(TextNode {
common: common("row-name"),
content: TextSource::Bound(item_bind(path(&["name"]))),
style_token: "body".into(),
wrap: false,
max_lines: Some(2),
selectable: true,
})),
});
let leaves = vec![
Node::Text(TextNode {
common: common("t-bound"),
content: bound_text,
style_token: "label".into(),
wrap: true,
max_lines: None,
selectable: false,
}),
Node::Image(ImageNode {
common: common("img"),
catalog: "icons".into(),
asset: "gear".into(),
fit: ImageFit::Cover,
alt: lit_text("a gear"),
}),
Node::Button(ButtonNode {
common: common("go"),
content: vec![text("go-label", "Go")],
on_activate: command("go"),
enabled: bound_bool.clone(),
}),
Node::Toggle(ToggleNode { common: common("flag"), checked: bound_bool, on_change: CommandRef::Param(param("toggle")) }),
Node::Slider(SliderNode {
common: common("level"),
min: 0.0,
max: 1.0,
step: 0.25,
value: bound_number,
commit: command("set-level"),
}),
Node::TextInput(TextInputNode {
common: common("name-input"),
multiline: true,
kind: InputKind::Number,
placeholder: lit_text("name"),
initial: TextSource::Param(param("initial")),
submit: command("rename"),
}),
Node::Select(SelectNode {
common: common("pick"),
options: OptionList::Literal(LiteralOptions {
options: vec![SelectOption { value: "a".into(), label: "Alpha".into() }],
}),
value: binds[4].clone(),
on_change: command("pick"),
}),
Node::Select(SelectNode {
common: common("pick-bound"),
options: OptionList::Bound(BoundOptions {
collection: CollectionSource::Member(binds[0].clone()),
value_member: path(&["id"]),
label_member: path(&["name"]),
}),
value: binds[5].clone(),
on_change: command("pick"),
}),
repeat,
Node::Overlay(OverlayNode {
common: common("menu"),
anchor: Anchor::Node(AnchorNode { node: nid("go") }),
modal: false,
dismiss: command("close"),
children: vec![text("menu-item", "one")],
}),
Node::Overlay(OverlayNode {
common: common("dialog"),
anchor: Anchor::Viewport,
modal: true,
dismiss: command("close"),
children: vec![],
}),
Node::Plot(PlotNode {
common: common("trend"),
series: binds[1].clone(),
mark: PlotMark::Area,
x_min: Some(0.0),
x_max: None,
y_min: None,
y_max: Some(100.0),
}),
Node::Embed(EmbedNode {
common: common("viewport"),
slot_kind: "world.scene".into(),
slot_id: "main".into(),
width: 640.0,
height: 480.0,
}),
Node::Use(UseNode {
common: common("kv"),
composition: CompositionRef { publisher: "thing.std".into(), name: "key-value".into(), version: 1 },
args: vec![Arg { name: "key".into(), value: ArgValue::Text(TextArg { value: "k".into() }) }],
}),
];
let mut deep = boxed("d4", vec![Node::Box(painted)]);
for id in ["d3", "d2", "d1"] {
deep = boxed(id, vec![deep]);
}
let mut children = vec![deep];
children.extend(leaves);
for plot_choice in [
Node::Plot(PlotNode {
common: common("trend-line"),
series: binds[2].clone(),
mark: PlotMark::Line,
x_min: None,
x_max: None,
y_min: None,
y_max: None,
}),
Node::Plot(PlotNode {
common: common("trend-bar"),
series: binds[2].clone(),
mark: PlotMark::Bar,
x_min: None,
x_max: None,
y_min: None,
y_max: None,
}),
] {
children.push(plot_choice);
}
for (index, fit) in [ImageFit::Contain, ImageFit::Fill, ImageFit::Unscaled].into_iter().enumerate() {
children.push(Node::Image(ImageNode {
common: common(&format!("img-fit-{index}")),
catalog: "icons".into(),
asset: "gear".into(),
fit,
alt: lit_text(""),
}));
}
for (index, role) in [Role::List, Role::ListItem, Role::Heading, Role::Dialog, Role::Toolbar].into_iter().enumerate() {
let mut role_style = style();
if index == 0 {
role_style.direction = Direction::Row;
role_style.align_content = ContentAlign::Auto;
role_style.justify_content = ContentAlign::Stretch;
}
children.push(Node::Box(BoxNode {
common: common(&format!("role-{index}")),
style: role_style,
paint: plain_paint(),
role,
children: vec![],
}));
}
let mut block = BoxNode {
common: common("block"),
style: style(),
paint: plain_paint(),
role: Role::Group,
children: vec![],
};
block.style.display = Display::Block;
block.style.direction = Direction::ColumnReverse;
block.style.wrap = Wrap::Wrap;
block.style.overflow = Overflow::Clip;
block.style.align_content = ContentAlign::Center;
block.style.justify_content = ContentAlign::End;
block.style.align_items = ItemAlign::End;
block.style.align_self = ItemAlign::Start;
block.style.basis = Dimension::Content;
children.push(Node::Box(block));
children.push(Node::Text(TextNode {
common: common("t-param"),
content: TextSource::Param(param("title")),
style_token: "title".into(),
wrap: true,
max_lines: None,
selectable: false,
}));
children.push(Node::Toggle(ToggleNode {
common: NodeCommon { id: nid("literal-number"), visible: BoolSource::Param(param("v")), a11y_label: None },
checked: lit_bool(false),
on_change: command("toggle"),
}));
children.push(Node::Slider(SliderNode {
common: common("slider-literal"),
min: -1.0,
max: 1.0,
step: 0.5,
value: lit_number(0.5),
commit: command("set-level"),
}));
children.push(Node::Slider(SliderNode {
common: common("slider-param"),
min: 0.0,
max: 10.0,
step: 1.0,
value: NumberSource::Param(param("n")),
commit: command("set-level"),
}));
children.push(Node::Repeat(RepeatNode {
common: common("rows-param"),
collection: CollectionSource::Param(param("rows")),
key_member: path(&["id"]),
template: std::boxed::Box::new(text("row-param", "r")),
}));
SurfaceDocument {
provider_id: ProviderId::new("provider.a"),
label: "main".into(),
version: 7,
title: "All arms".into(),
root: boxed("root", children),
commands: vec![
CommandDecl {
id: CommandId::new("go"),
payload_schema: SchemaId::new("gamecult.thing.test.go.v1"),
captures: vec![nid("name-input"), nid("level")],
confirm: Some("Go now?".into()),
},
CommandDecl {
id: CommandId::new("close"),
payload_schema: SchemaId::new("gamecult.thing.test.close.v1"),
captures: vec![],
confirm: None,
},
],
}
}
fn full_composition() -> CompositionDocument {
let args = vec![
Arg { name: "text".into(), value: ArgValue::Text(TextArg { value: "t".into() }) },
Arg { name: "number".into(), value: ArgValue::Number(NumberArg { value: 2.5 }) },
Arg { name: "bool".into(), value: ArgValue::Bool(BoolArg { value: true }) },
Arg { name: "bind".into(), value: ArgValue::Bind(record_bind(path(&["x"]), None)) },
Arg {
name: "collection".into(),
value: ArgValue::Collection(CollectionArg { source: CollectionSource::Param(param("rows")) }),
},
Arg { name: "command".into(), value: ArgValue::Command(CommandArg { command: command("go") }) },
Arg {
name: "slot".into(),
value: ArgValue::Slot(SlotArg { nodes: vec![text("slot-text", "inside"), boxed("slot-box", vec![])] }),
},
];
let inner_use = Node::Use(UseNode {
common: common("inner"),
composition: CompositionRef { publisher: "thing.std".into(), name: "card".into(), version: 2 },
args,
});
CompositionDocument {
publisher: "provider.a".into(),
name: "panel".into(),
version: 3,
params: [
("title", ParamKind::Text),
("count", ParamKind::Number),
("on", ParamKind::Bool),
("source", ParamKind::Bind),
("rows", ParamKind::Collection),
("act", ParamKind::Command),
("body", ParamKind::Slot),
]
.into_iter()
.map(|(name, kind)| ParamDecl { name: name.into(), kind })
.collect(),
body: boxed("panel-root", vec![text("panel-title", "t"), inner_use]),
}
}
fn results(invocation: &CommandInvocation) -> Vec<(&'static str, CommandResult)> {
vec![
("result-pending", CommandResult::pending(invocation, 1, "working")),
("result-completed", CommandResult::completed(invocation, 2, Some(8), true, None)),
(
"result-completed-open-surface",
CommandResult::completed(
invocation,
3,
None,
false,
Some(Effect::OpenSurface(OpenSurfaceEffect { key: "thing:surface:provider.a.next".into() })),
),
),
(
"result-completed-open-external",
CommandResult::completed(
invocation,
4,
Some(9),
true,
Some(Effect::OpenExternal(OpenExternalEffect {
url: "https://example.test/auth".into(),
resume: command("resume"),
})),
),
),
("result-denied", CommandResult::denied(invocation, 5, "not yours", Some(nid("go")))),
("result-denied-no-node", CommandResult::denied(invocation, 6, "not allowed", None)),
("result-failed", CommandResult::failed(invocation, 7, "broke")),
]
}
fn asset_catalog() -> AssetCatalog {
AssetCatalog {
provider_id: ProviderId::new("provider.a"),
catalog_id: "icons".into(),
version: 2,
assets: vec![
AssetEntry { id: "gear".into(), media_type: "image/png".into(), uri: "assets/gear.png".into(), width: 32, height: 32 },
AssetEntry { id: "bolt".into(), media_type: "image/svg+xml".into(), uri: "assets/bolt.svg".into(), width: 0, height: 0 },
],
}
}
/// A store with one record of each kind, written by cultcache-rs under a pinned clock.
fn write_store(path: &std::path::Path) {
let _ = std::fs::remove_file(path);
let mut cache = CultCache::new();
ThingContract.register_entries(&mut cache).unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
let surface = full_surface();
let composition = full_composition();
let invocation = invocation();
let result = CommandResult::pending(&invocation, 1, "working");
let catalog = asset_catalog();
put(&mut cache, &surface.record_key(), &surface);
put(&mut cache, &composition.record_key(), &composition);
put(&mut cache, &invocation.record_key(), &invocation);
put(&mut cache, &result.record_key(), &result);
put(&mut cache, &catalog.record_key(), &catalog);
}
fn put<T: DatabaseEntry>(cache: &mut CultCache, key: &str, value: &T) {
let (mut envelope, _) = cache.prepare_entry(key, value).unwrap();
envelope.stored_at = PINNED_CLOCK.into();
cache.put_envelope::<T>(envelope).unwrap();
}
fn envelope<T: DatabaseEntry>(key: &str, payload: Vec<u8>) -> CultCacheEnvelope {
CultCacheEnvelope {
key: key.into(),
r#type: T::TYPE.into(),
payload,
stored_at: PINNED_CLOCK.into(),
schema_id: Some(T::TYPE.into()),
}
}
fn open_store(path: &std::path::Path) -> CultCache {
let mut cache = CultCache::new();
ThingContract.register_entries(&mut cache).unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache
}
#[test]
fn goldens_match() {
let mut names = BTreeSet::new();
names.insert(golden("surface-all-arms", &full_surface()));
names.insert(golden("composition-all-params", &full_composition()));
let invocation = invocation();
names.insert(golden("invocation-all-inputs", &invocation));
for (name, result) in results(&invocation) {
names.insert(golden(name, &result));
}
names.insert(golden("asset-catalog", &asset_catalog()));
// The refusal and tolerance goldens are written by `refusal_payloads` below; a stale file
// that no case names is an orphan.
let refusals = refusal_payloads();
for (name, bytes) in &refusals {
golden_bytes(&format!("{name}.bin"), bytes);
names.insert((*name).to_string());
}
let on_disk: BTreeSet<String> = std::fs::read_dir(goldens_dir())
.unwrap()
.filter_map(|entry| entry.unwrap().file_name().into_string().ok())
.filter_map(|file| file.strip_suffix(".bin").map(str::to_string))
.collect();
assert_eq!(on_disk, names, "every .bin golden is a named case and every case has its file");
// The store: one record of each kind, written under a pinned clock, byte for byte.
let scratch = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("goldens-surface.cc");
write_store(&scratch);
let committed = golden_bytes("surface.cc", &std::fs::read(&scratch).unwrap());
let reopened = goldens_dir().join("surface.cc");
assert_eq!(std::fs::read(&reopened).unwrap(), committed);
let cache = open_store(&scratch);
let surface = full_surface();
assert_eq!(cache.get::<SurfaceDocument>(&surface.record_key()).unwrap(), Some(surface));
let composition = full_composition();
assert_eq!(cache.get::<CompositionDocument>(&composition.record_key()).unwrap(), Some(composition));
assert_eq!(cache.get::<CommandInvocation>(&invocation.record_key()).unwrap(), Some(invocation.clone()));
let result = CommandResult::pending(&invocation, 1, "working");
assert_eq!(cache.get::<CommandResult>(&result.record_key()).unwrap(), Some(result));
let catalog = asset_catalog();
assert_eq!(cache.get::<AssetCatalog>(&catalog.record_key()).unwrap(), Some(catalog));
}
/// The foreign and malformed payloads. Each is the bytes a consumer might send; none is a
/// value of this crate, so each is spelled from MessagePack tuples and maps.
fn refusal_payloads() -> Vec<(&'static str, Vec<u8>)> {
// The map a Thing 1 provider writes for a command result: named members, a receipt
// inside, no positional slots.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Receipt {
command_id: &'static str,
command: &'static str,
state: &'static str,
message: &'static str,
source_version: u64,
issued_at_utc: &'static str,
}
#[derive(serde::Serialize)]
struct Flat {
receipt: Receipt,
}
let flat = Flat {
receipt: Receipt {
command_id: "inv-1",
command: "go",
state: "completed",
message: CANARY,
source_version: 4,
issued_at_utc: "2026-01-01T00:00:00Z",
},
};
let unit: [u8; 0] = [];
vec![
("result-thing1-flat", rmp_serde::to_vec_named(&flat).unwrap()),
// A surface whose root is an arm key the Node union does not declare.
(
"node-unknown-arm",
rmp_serde::to_vec(&("provider.a", "main", 1i64, CANARY, (13i32, (CANARY,)), unit)).unwrap(),
),
// A result whose outcome is an arm key the Outcome union does not declare.
("outcome-unknown-arm", rmp_serde::to_vec(&("inv-1", 1i32, (4i32, (CANARY,)))).unwrap()),
// A result with a fourth slot past the last declared one.
(
"result-extra-slot",
rmp_serde::to_vec(&("inv-1", 1i32, (0i32, ("working",)), CANARY)).unwrap(),
),
// A result without its outcome.
("result-missing-slot", rmp_serde::to_vec(&("inv-1", 1i32)).unwrap()),
]
}
#[test]
fn refusal_cases() {
let payloads: std::collections::BTreeMap<_, _> = refusal_payloads().into_iter().collect();
let committed = |name: &str| golden_bytes(&format!("{name}.bin"), &payloads[name]);
let cases: [(&str, &str, &str, &str); 3] = [
("result-thing1-flat", "gamecult.thing.command_result", "thing:result:inv-1.1", "CommandResult"),
("node-unknown-arm", "gamecult.thing.surface", "thing:surface:provider.a.main", "Node"),
("outcome-unknown-arm", "gamecult.thing.command_result", "thing:result:inv-1.1", "Outcome"),
];
for (name, kind, key, union) in cases {
let mut cache = CultCache::new();
ThingContract.register_entries(&mut cache).unwrap();
let bytes = committed(name);
let error = if kind == "gamecult.thing.surface" {
cache.put_envelope::<SurfaceDocument>(envelope::<SurfaceDocument>(key, bytes)).unwrap_err()
} else {
cache.put_envelope::<CommandResult>(envelope::<CommandResult>(key, bytes)).unwrap_err()
};
let message = format!("{error:#}");
assert!(message.contains(kind), "{name}: the error names the kind: {message}");
assert!(message.contains(key), "{name}: the error names the record key: {message}");
assert!(message.contains(union) || name == "result-thing1-flat", "{name}: {message}");
assert!(!message.contains(CANARY), "{name}: the error echoed a value: {message}");
}
// The unknown arm key is named; the payload never is.
let mut cache = CultCache::new();
ThingContract.register_entries(&mut cache).unwrap();
let node = cache
.put_envelope::<SurfaceDocument>(envelope::<SurfaceDocument>("k", committed("node-unknown-arm")))
.unwrap_err();
assert!(format!("{node:#}").contains("13"), "{node:#}");
let outcome = cache
.put_envelope::<CommandResult>(envelope::<CommandResult>("k", committed("outcome-unknown-arm")))
.unwrap_err();
assert!(format!("{outcome:#}").contains('4'), "{outcome:#}");
}
#[test]
fn deep_tree() {
// A 64-deep Box chain.
let mut chain = text("leaf", "bottom");
for depth in (0..64).rev() {
chain = boxed(&format!("deep-{depth}"), vec![chain]);
}
let deep = SurfaceDocument {
provider_id: ProviderId::new("provider.a"),
label: "deep".into(),
version: 1,
title: "deep".into(),
root: chain,
commands: vec![],
};
// A surface of 1,000 nodes: the root, 998 texts and one box.
let mut wide_children: Vec<Node> = (0..998).map(|index| text(&format!("t{index}"), "x")).collect();
wide_children.push(boxed("tail", vec![]));
let wide = SurfaceDocument {
provider_id: ProviderId::new("provider.a"),
label: "wide".into(),
version: 1,
title: "wide".into(),
root: boxed("root", wide_children),
commands: vec![],
};
let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("deep-tree.cc");
let _ = std::fs::remove_file(&path);
{
let mut cache = CultCache::new();
ThingContract.register_entries(&mut cache).unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
put(&mut cache, &deep.record_key(), &deep);
put(&mut cache, &wide.record_key(), &wide);
}
let cache = open_store(&path);
assert_eq!(cache.get::<SurfaceDocument>(&deep.record_key()).unwrap(), Some(deep));
assert_eq!(cache.get::<SurfaceDocument>(&wide.record_key()).unwrap(), Some(wide));
}

View file

@ -0,0 +1,81 @@
mod common;
use common::*;
use thing_contract::*;
fn other_invocation() -> CommandInvocation {
let mut other = invocation();
other.invocation_id = "an-invocation-no-constructor-can-guess".into();
other
}
#[test]
fn result_copies_invocation_id() {
let invocation = other_invocation();
let built = [
CommandResult::pending(&invocation, 1, "m"),
CommandResult::completed(&invocation, 2, None, false, None),
CommandResult::denied(&invocation, 3, "r", None),
CommandResult::failed(&invocation, 4, "r"),
];
for (index, result) in built.iter().enumerate() {
assert_eq!(result.invocation_id(), "an-invocation-no-constructor-can-guess");
assert_eq!(result.seq(), index as i32 + 1);
}
}
#[test]
fn each_constructor_builds_its_outcome_arm() {
let invocation = other_invocation();
let node = Some(nid("go"));
assert!(matches!(
CommandResult::pending(&invocation, 1, "wait").outcome(),
Outcome::Pending(PendingOutcome { message }) if message == "wait"
));
let effect = Effect::OpenSurface(OpenSurfaceEffect { key: "k".into() });
assert!(matches!(
CommandResult::completed(&invocation, 1, Some(5), true, Some(effect.clone())).outcome(),
Outcome::Completed(CompletedOutcome { source_version: Some(5), clear_drafts: true, effect: Some(e) }) if *e == effect
));
assert!(matches!(
CommandResult::completed(&invocation, 1, None, false, None).outcome(),
Outcome::Completed(CompletedOutcome { source_version: None, clear_drafts: false, effect: None })
));
assert!(matches!(
CommandResult::denied(&invocation, 1, "no", node.clone()).outcome(),
Outcome::Denied(DeniedOutcome { reason, node: n }) if reason == "no" && *n == node
));
assert!(matches!(
CommandResult::failed(&invocation, 1, "broke").outcome(),
Outcome::Failed(FailedOutcome { reason }) if reason == "broke"
));
}
#[test]
fn record_keys_are_built_from_the_fields() {
let invocation = other_invocation();
assert_eq!(invocation.record_key(), "thing:invocation:an-invocation-no-constructor-can-guess");
assert_eq!(
CommandResult::failed(&invocation, 3, "r").record_key(),
"thing:result:an-invocation-no-constructor-can-guess.3"
);
let surface = SurfaceDocument {
provider_id: ProviderId::new("p"),
label: "main".into(),
version: 1,
title: String::new(),
root: boxed("r", vec![]),
commands: vec![],
};
assert_eq!(surface.record_key(), "thing:surface:p.main");
let composition = CompositionDocument {
publisher: "pub".into(),
name: "card".into(),
version: 2,
params: vec![],
body: boxed("r", vec![]),
};
assert_eq!(composition.record_key(), "thing:composition:pub.card.v2");
let catalog = AssetCatalog { provider_id: ProviderId::new("p"), catalog_id: "icons".into(), version: 1, assets: vec![] };
assert_eq!(catalog.record_key(), "thing:assets:p.icons");
}