use std::fmt::Write;
use chappi_core::{CognitiveState, Patch, PatchOp, apply_patch, state_hash, validate_patch};
use chappi_syntax::{Atom, Form, FormKind, parse_document};
fn decode_state(source: &str) -> CognitiveState {
CognitiveState::try_from(parse_document(source).expect("fixture syntax should parse"))
.expect("fixture semantics should decode")
}
fn decode_patch(source: &str) -> Patch {
Patch::try_from(parse_document(source).expect("fixture syntax should parse"))
.expect("fixture patch should decode")
}
fn hash_hex(state: &CognitiveState) -> String {
let mut output = String::with_capacity(64);
for byte in state_hash(state) {
write!(&mut output, "{byte:02x}").expect("writing into a String cannot fail");
}
output
}
#[test]
fn decodes_patch_operations_with_spans_and_preserves_retire_reason() {
let patch = decode_patch(
r#"(chappi-patch 1 "0000000000000000000000000000000000000000000000000000000000000000")
(+ (claim new self working 50 likely (new)))
(+ (rel about new self))
(~ 3 (claim old self working 80 settled (revised)))
(- gone 2 (because obsolete))
(> (focus work 0 self evoked none (fg new) (bg) (residue) (trigger task)))
"#,
);
assert_eq!(patch.version, 1);
assert_eq!(patch.ops.len(), 5);
assert!(matches!(&patch.ops[0], PatchOp::Add(op) if op.span.start_line == 2));
assert!(matches!(&patch.ops[1], PatchOp::Add(op) if op.span.start_line == 3));
assert!(
matches!(&patch.ops[2], PatchOp::Revise(op) if op.expected_revision == 3 && op.node.id == "old" && op.span.start_line == 4)
);
let PatchOp::Retire(retire) = &patch.ops[3] else {
panic!("fourth operation should be retire");
};
assert_eq!(retire.node_id, "gone");
assert_eq!(retire.expected_revision, 2);
assert_eq!(retire.span.start_line, 5);
let FormKind::List(reason) = &retire.reason.kind else {
panic!("retire reason should remain an open form");
};
assert_eq!(reason.len(), 2);
assert!(
matches!(&patch.ops[4], PatchOp::AppendFocus(op) if op.frame.stream_id == "work" && op.span.start_line == 6)
);
}
#[test]
fn replays_add_revise_retire_and_focus_into_the_same_snapshot_as_direct_construction() {
let base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 60 plausible (draft))
(question temporary self transient 20 open (discard me))
"#,
);
let patch = decode_patch(&format!(
r#"(chappi-patch 1 "{}")
(~ 0 (claim c self working 80 settled (final)))
(- temporary 0 (because answered-elsewhere))
(+ (goal g self working 75 active (ship)))
(+ (rel causes c g))
(> (focus work 0 self evoked none (fg g) (bg c) (residue self) (trigger assigned)))
"#,
hash_hex(&base)
));
let replayed = apply_patch(&base, &patch).expect("valid patch should apply");
let mut direct = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 80 settled (final))
(goal g self working 75 active (ship))
(rel causes c g)
(focus work 0 self evoked none (fg g) (bg c) (residue self) (trigger assigned))
"#,
);
direct.nodes[1].revision = 1;
assert_eq!(replayed, direct);
assert_eq!(
base.nodes[1].revision, 0,
"input snapshot must be immutable"
);
assert!(base.nodes.iter().any(|node| node.id == "temporary"));
}
#[test]
fn reports_all_patch_conflicts_transactionally_without_changing_the_input() {
let base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 60 plausible (draft))
"#,
);
let patch = decode_patch(
r#"(chappi-patch 1 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
(+ (claim c self working 50 likely (duplicate)))
(~ 9 (claim c self working 80 settled (stale)))
"#,
);
let original = base.clone();
let diagnostics = apply_patch(&base, &patch).expect_err("conflicting patch must fail");
let codes = diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"stale-patch-base"));
assert!(codes.contains(&"duplicate-node-id"));
assert!(codes.contains(&"revision-conflict"));
assert_eq!(base, original);
}
#[test]
fn reports_a_programmatic_snapshot_patch_version_mismatch_explicitly() {
let mut base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
"#,
);
base.version = 2;
let patch = decode_patch(&format!("(chappi-patch 1 \"{}\")\n", hash_hex(&base)));
let diagnostics =
apply_patch(&base, &patch).expect_err("different schema versions must not apply");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "patch-version-mismatch");
assert_eq!(diagnostics[0].semantic_path, ["header", "version"]);
assert_eq!(
diagnostics[0].message,
"patch version 1 does not match snapshot version 2"
);
}
#[test]
fn refuses_to_retire_nodes_still_referenced_by_graph_or_focus() {
let base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 60 plausible (draft))
(goal g self working 70 active (goal))
(rel causes c g)
(focus work 0 self evoked none (fg c) (bg g) (residue self) (trigger assigned))
"#,
);
let patch = decode_patch(&format!(
"(chappi-patch 1 \"{}\")\n(- c 0 (because old))\n",
hash_hex(&base)
));
let diagnostics = apply_patch(&base, &patch).expect_err("referenced node must remain active");
assert!(
diagnostics
.iter()
.any(|item| item.code == "node-still-referenced")
);
assert!(base.nodes.iter().any(|node| node.id == "c"));
}
#[test]
fn validates_the_final_clone_and_never_returns_a_partially_applied_state() {
let base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 60 plausible (draft))
"#,
);
let patch = decode_patch(&format!(
r#"(chappi-patch 1 "{}")
(+ (goal g self working 75 active (ship)))
(~ 0 (claim c missing working 80 settled (broken owner)))
"#,
hash_hex(&base)
));
let diagnostics = apply_patch(&base, &patch).expect_err("invalid final graph must fail");
assert!(
diagnostics
.iter()
.any(|item| item.code == "undefined-owner")
);
assert!(!base.nodes.iter().any(|node| node.id == "g"));
assert_eq!(base.nodes[1].owner, "self");
}
#[test]
fn rejects_patch_forms_that_use_the_wrong_operation_payload_kind() {
let diagnostics = Patch::try_from(
parse_document(
r#"(chappi-patch 1 "0000000000000000000000000000000000000000000000000000000000000000")
(+ (focus work 0 self evoked none (fg) (bg) (residue) (trigger bad)))
(~ 0 (rel about c self))
(- c 0)
"#,
)
.expect("fixture syntax should parse"),
)
.expect_err("invalid patch operations must not decode");
assert_eq!(diagnostics.len(), 3);
assert_eq!(diagnostics[0].code, "invalid-add-payload");
assert_eq!(diagnostics[1].code, "invalid-revise-payload");
assert_eq!(diagnostics[2].code, "invalid-retire-arity");
}
#[test]
fn append_focus_requires_the_exact_next_frame() {
let base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 60 plausible (draft))
(focus work 0 self evoked none (fg c) (bg) (residue) (trigger start))
"#,
);
let patch = decode_patch(&format!(
r#"(chappi-patch 1 "{}")
(> (focus work 2 self returned 0 (fg c) (bg) (residue) (trigger skipped)))
(> (focus new 0 self associated none (fg c) (bg) (residue) (trigger wrong-start)))
"#,
hash_hex(&base)
));
let diagnostics = apply_patch(&base, &patch).expect_err("invalid focus append must fail");
assert_eq!(
diagnostics
.iter()
.filter(|item| item.code == "invalid-focus-append")
.count(),
2
);
assert_eq!(base.focus_streams[0].frames.len(), 1);
}
#[test]
fn reports_revision_overflow_instead_of_panicking() {
let mut base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(claim c self working 60 plausible (draft))
"#,
);
base.nodes[1].revision = u64::MAX;
let patch = decode_patch(&format!(
r#"(chappi-patch 1 "{}")
(~ 18446744073709551615 (claim c self working 80 settled (final)))
"#,
hash_hex(&base)
));
let diagnostics = apply_patch(&base, &patch).expect_err("revision overflow must be rejected");
assert!(
diagnostics
.iter()
.any(|item| item.code == "revision-overflow")
);
assert_eq!(base.nodes[1].revision, u64::MAX);
}
#[test]
fn rejects_readding_an_id_retired_earlier_in_the_same_append_only_patch() {
let base = decode_state(
r#"(chappi 1)
(entity self self persistent 100 person (self))
(question temporary self transient 20 open (discard me))
"#,
);
let patch = decode_patch(&format!(
r#"(chappi-patch 1 "{}")
(- temporary 0 (because obsolete))
(+ (claim temporary self working 50 likely (resurrected)))
"#,
hash_hex(&base)
));
let diagnostics = apply_patch(&base, &patch).expect_err("logical IDs cannot be reused");
assert!(
diagnostics
.iter()
.any(|item| item.code == "duplicate-node-id")
);
assert!(base.nodes.iter().any(|node| node.id == "temporary"));
}
#[test]
fn rejects_a_programmatic_add_that_skips_initial_revision_zero() {
let base = decode_state("(chappi 1)\n(entity self self persistent 100 person (self))\n");
let mut patch = decode_patch(&format!(
"(chappi-patch 1 \"{}\")\n(+ (claim c self working 50 likely (claim)))\n",
hash_hex(&base)
));
let PatchOp::Add(add) = &mut patch.ops[0] else {
panic!("fixture should decode as add");
};
let chappi_core::AddValue::Node(node) = &mut add.value else {
panic!("fixture should add a node");
};
node.revision = 7;
let diagnostics =
apply_patch(&base, &patch).expect_err("new nodes must begin at revision zero");
assert!(
diagnostics
.iter()
.any(|item| item.code == "invalid-add-revision")
);
assert!(!base.nodes.iter().any(|node| node.id == "c"));
}
#[test]
fn final_validation_rejects_programmatic_nodes_with_invalid_salience() {
let base = decode_state("(chappi 1)\n(entity self self persistent 100 person (self))\n");
let mut patch = decode_patch(&format!(
"(chappi-patch 1 \"{}\")\n(+ (claim c self working 50 likely (claim)))\n",
hash_hex(&base)
));
let PatchOp::Add(add) = &mut patch.ops[0] else {
panic!("fixture should decode as add");
};
let chappi_core::AddValue::Node(node) = &mut add.value else {
panic!("fixture should add a node");
};
node.salience = 255;
let diagnostics = apply_patch(&base, &patch).expect_err("salience above 100 is invalid");
assert!(
diagnostics
.iter()
.any(|item| item.code == "invalid-salience")
);
assert!(!base.nodes.iter().any(|node| node.id == "c"));
}
#[test]
fn validates_every_patch_operation_in_the_canonical_round_trip_domain() {
let mut patch = decode_patch(
"(chappi-patch 1 \"0000000000000000000000000000000000000000000000000000000000000000\")\n\
(+ (claim added self working 50 likely (nested 01 1.50 \"e\u{301}\")))\n\
(+ (rel about added self))\n\
(~ 0 (claim revised self working 50 likely (nested 01)))\n\
(- retired 0 (because 01))\n\
(> (focus work 0 self evoked none (fg added) (bg self) (residue) (trigger 01)))\n",
);
patch.version = 2;
let PatchOp::Add(add_node) = &mut patch.ops[0] else {
panic!("first operation should add a node");
};
let chappi_core::AddValue::Node(node) = &mut add_node.value else {
panic!("first operation should add a node");
};
node.id = "123".into();
node.owner = "e\u{301}".into();
let PatchOp::Add(add_relation) = &mut patch.ops[1] else {
panic!("second operation should add a relation");
};
let chappi_core::AddValue::Relation(relation) = &mut add_relation.value else {
panic!("second operation should add a relation");
};
relation.source = "\u{7f}".into();
let PatchOp::Revise(revise) = &mut patch.ops[2] else {
panic!("third operation should revise a node");
};
revise.node.id = "456".into();
let PatchOp::Retire(retire) = &mut patch.ops[3] else {
panic!("fourth operation should retire a node");
};
retire.node_id = "e\u{301}".into();
let PatchOp::AppendFocus(append) = &mut patch.ops[4] else {
panic!("fifth operation should append focus");
};
append.frame.stream_id = "789".into();
append.frame.owner = "\u{7f}".into();
append.frame.foreground = vec!["e\u{301}".into()];
let diagnostics = validate_patch(&patch);
let codes = diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"invalid-patch-version"));
assert!(codes.contains(&"noncanonical-symbol"));
assert!(codes.contains(&"noncanonical-integer"));
assert!(codes.contains(&"noncanonical-decimal"));
assert!(codes.contains(&"noncanonical-text"));
for (code, path) in [
("noncanonical-symbol", &["ops", "0", "add", "id"][..]),
("noncanonical-symbol", &["ops", "0", "add", "owner"]),
("noncanonical-integer", &["ops", "0", "add", "payload", "1"]),
("noncanonical-decimal", &["ops", "0", "add", "payload", "2"]),
("noncanonical-text", &["ops", "0", "add", "payload", "3"]),
("noncanonical-symbol", &["ops", "1", "add", "source"]),
("noncanonical-symbol", &["ops", "2", "revise", "id"]),
(
"noncanonical-integer",
&["ops", "2", "revise", "payload", "1"],
),
("noncanonical-symbol", &["ops", "3", "retire", "node_id"]),
(
"noncanonical-integer",
&["ops", "3", "retire", "reason", "1"],
),
(
"noncanonical-symbol",
&["ops", "4", "append_focus", "stream_id"],
),
(
"noncanonical-symbol",
&["ops", "4", "append_focus", "owner"],
),
(
"noncanonical-symbol",
&["ops", "4", "append_focus", "fg", "0"],
),
(
"noncanonical-integer",
&["ops", "4", "append_focus", "trigger", "1"],
),
] {
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.code == code
&& diagnostic
.semantic_path
.iter()
.map(String::as_str)
.eq(path.iter().copied())
}),
"missing {code} at {}",
path.join(".")
);
}
}
#[test]
fn apply_rejects_a_noncanonical_programmatic_patch_transactionally() {
let base = decode_state(
"(chappi 1)\n(entity self self persistent 100 person (self))\n(question retired self transient 20 open (old))\n",
);
let mut patch = decode_patch(&format!(
"(chappi-patch 1 \"{}\")\n(- retired 0 (because canonical))\n",
hash_hex(&base)
));
let PatchOp::Retire(retire) = &mut patch.ops[0] else {
panic!("fixture should retire a node");
};
let FormKind::List(reason) = &mut retire.reason.kind else {
panic!("retire reason should be a list");
};
reason.push(Form {
kind: FormKind::Atom(Atom::Integer("01".into())),
span: retire.reason.span,
});
let original = base.clone();
let diagnostics =
apply_patch(&base, &patch).expect_err("noncanonical patch must not be applied");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "noncanonical-integer")
);
assert_eq!(base, original);
}