178 lines
6.3 KiB
Rust
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:?}"),
|
|
}
|
|
}
|