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:
parent
852c34b60a
commit
896a6a1c83
3 changed files with 852 additions and 0 deletions
130
crates/thing-contract/tests/common/mod.rs
Normal file
130
crates/thing-contract/tests/common/mod.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
641
crates/thing-contract/tests/goldens.rs
Normal file
641
crates/thing-contract/tests/goldens.rs
Normal 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));
|
||||
}
|
||||
81
crates/thing-contract/tests/result.rs
Normal file
81
crates/thing-contract/tests/result.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue