ontoref-code/crates/ontoref-daemon/tests/save_reconciler.rs
2026-07-10 01:44:59 +01:00

178 lines
6.3 KiB
Rust

//! Integration coverage for the save-on-NCL reconciler (O3.5).
//!
//! Exercises the full simulated edit → diff → dispatch path: an
//! initial StateConfig is constructed in-memory, a "user edit" mutates
//! it, the reconciler diffs the two values, and the runtime dispatches
//! the resulting ops. Uncatalogued edits surface as
//! [`ReconcileError::UncatalogedDiff`] so the watcher can rollback.
use ed25519_dalek::SigningKey;
use ontoref_ontology::types::{
Dimension, DimensionState, Horizon, StateConfig, TensionLevel, Transition,
};
use ontoref_ops::reconcile::{apply_diffs, diff_state, ReconcileError};
use ontoref_ops::{OpContext, OpError};
fn signing_key() -> SigningKey {
SigningKey::from_bytes(&[55u8; 32])
}
fn sample_state(current: &str) -> StateConfig {
StateConfig {
dimensions: vec![Dimension {
id: "adoption".to_owned(),
name: "Adoption".to_owned(),
description: "Sample dimension for save-reconciler O3.5 coverage.".to_owned(),
current_state: current.to_owned(),
desired_state: "c".to_owned(),
horizon: Horizon::Weeks,
states: vec![
DimensionState {
id: "a".to_owned(),
name: "A".to_owned(),
description: String::new(),
tension: TensionLevel::Low,
},
DimensionState {
id: "b".to_owned(),
name: "B".to_owned(),
description: String::new(),
tension: TensionLevel::Medium,
},
DimensionState {
id: "c".to_owned(),
name: "C".to_owned(),
description: String::new(),
tension: TensionLevel::Low,
},
],
transitions: vec![
Transition {
from: "a".to_owned(),
to: "b".to_owned(),
condition: "ready".to_owned(),
catalyst: "trigger".to_owned(),
blocker: "none".to_owned(),
horizon: Horizon::Weeks,
},
Transition {
from: "b".to_owned(),
to: "c".to_owned(),
condition: "ready".to_owned(),
catalyst: "trigger".to_owned(),
blocker: "none".to_owned(),
horizon: Horizon::Weeks,
},
],
coupled_with: vec![],
}],
}
}
#[test]
fn diff_yields_one_move_fsm_state_per_current_state_change() {
let old = sample_state("a");
let new = sample_state("b");
let diffs = diff_state(&old, &new);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].op_id, "move_fsm_state");
assert_eq!(diffs[0].inputs["dimension"], "adoption");
assert_eq!(diffs[0].inputs["new_state"], "b");
}
#[test]
fn apply_diffs_dispatches_via_runtime() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".ontoref/ontology")).expect("mkdir");
let old = sample_state("a");
let new = sample_state("b");
let diffs = diff_state(&old, &new);
let mut ctx = OpContext::new_in_memory("developer", "", signing_key())
.with_state(old)
.with_project_root(tmp.path().to_path_buf());
let results = apply_diffs(&diffs, &mut ctx).expect("apply ok");
assert_eq!(results.len(), 1);
let result = &results[0];
assert_eq!(result.witness.op_id, "move_fsm_state");
result.witness.verify().expect("witness verifies");
assert_eq!(
ctx.state.as_ref().unwrap().dimensions[0].current_state,
"b"
);
}
#[test]
fn apply_diffs_rolls_back_on_uncatalogued_edit() {
// Adding a new dimension is not catalogued in Phase 1 — the
// reconciler refuses the whole batch BEFORE any dispatch fires.
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".ontoref/ontology")).expect("mkdir");
let mut new = sample_state("a");
new.dimensions.push(Dimension {
id: "new-dim".to_owned(),
name: "New".to_owned(),
description: String::new(),
current_state: "x".to_owned(),
desired_state: "y".to_owned(),
horizon: Horizon::Weeks,
states: vec![],
transitions: vec![],
coupled_with: vec![],
});
let diffs = diff_state(&sample_state("a"), &new);
let mut ctx = OpContext::new_in_memory("developer", "", signing_key())
.with_state(sample_state("a"))
.with_project_root(tmp.path().to_path_buf());
let err = apply_diffs(&diffs, &mut ctx).expect_err("uncatalogued edit must reject");
match err {
ReconcileError::UncatalogedDiff(msg) => {
assert!(msg.contains("new-dim"), "diff summary must reference the new dim: {msg}");
}
other => panic!("expected UncatalogedDiff, got {other:?}"),
}
// State left untouched — first-pass refusal preserves the
// pre-edit substrate.
assert_eq!(
ctx.state.as_ref().unwrap().dimensions[0].current_state,
"a"
);
}
#[test]
fn apply_diffs_propagates_validator_rejection() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(".ontoref/ontology")).expect("mkdir");
// A "save" that transitions to a state with no declared transition
// — the diff IS catalogued (current_state changed) but the
// pre-validator will reject.
let mut new = sample_state("a");
new.dimensions[0].current_state = "nonexistent".to_owned();
let diffs = diff_state(&sample_state("a"), &new);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].op_id, "move_fsm_state");
let mut ctx = OpContext::new_in_memory("developer", "", signing_key())
.with_state(sample_state("a"))
.with_project_root(tmp.path().to_path_buf());
let err = apply_diffs(&diffs, &mut ctx).expect_err("validator must reject");
match err {
ReconcileError::Dispatch { op_id, source } => {
assert_eq!(op_id, "move_fsm_state");
assert!(
matches!(source, OpError::ValidatorRejected { .. }),
"expected validator rejection, got {source:?}"
);
}
other => panic!("expected Dispatch error, got {other:?}"),
}
}