diff --git a/crates/thing-contract/tests/common/mod.rs b/crates/thing-contract/tests/common/mod.rs new file mode 100644 index 0000000..f111ff1 --- /dev/null +++ b/crates/thing-contract/tests/common/mod.rs @@ -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) -> 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::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()), + } +} diff --git a/crates/thing-contract/tests/goldens.rs b/crates/thing-contract/tests/goldens.rs new file mode 100644 index 0000000..cf6928c --- /dev/null +++ b/crates/thing-contract/tests/goldens.rs @@ -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 { + 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); + 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 { + 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(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::(envelope).unwrap(); +} + +fn envelope(key: &str, payload: Vec) -> 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 = 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::(&surface.record_key()).unwrap(), Some(surface)); + let composition = full_composition(); + assert_eq!(cache.get::(&composition.record_key()).unwrap(), Some(composition)); + assert_eq!(cache.get::(&invocation.record_key()).unwrap(), Some(invocation.clone())); + let result = CommandResult::pending(&invocation, 1, "working"); + assert_eq!(cache.get::(&result.record_key()).unwrap(), Some(result)); + let catalog = asset_catalog(); + assert_eq!(cache.get::(&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)> { + // 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::(envelope::(key, bytes)).unwrap_err() + } else { + cache.put_envelope::(envelope::(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::(envelope::("k", committed("node-unknown-arm"))) + .unwrap_err(); + assert!(format!("{node:#}").contains("13"), "{node:#}"); + let outcome = cache + .put_envelope::(envelope::("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 = (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::(&deep.record_key()).unwrap(), Some(deep)); + assert_eq!(cache.get::(&wide.record_key()).unwrap(), Some(wide)); +} diff --git a/crates/thing-contract/tests/result.rs b/crates/thing-contract/tests/result.rs new file mode 100644 index 0000000..2d439a8 --- /dev/null +++ b/crates/thing-contract/tests/result.rs @@ -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"); +}