//! 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:?}"), } }