diff --git a/crates/oa-desktop/src/app/scenario_removal/tests.rs b/crates/oa-desktop/src/app/scenario_removal/tests.rs index b9312298..bd24a961 100644 --- a/crates/oa-desktop/src/app/scenario_removal/tests.rs +++ b/crates/oa-desktop/src/app/scenario_removal/tests.rs @@ -195,6 +195,236 @@ fn scenario_group_removal_is_one_exact_model_source_transaction_in_both_represen } } +#[test] +fn scenario_group_removal_source_write_failure_preserves_snapshot_selection_camera_and_redo() { + use oa_core::source_project::{self, ProjectLimits}; + + let mut project = Project::empty(); + let owner = project + .add_element(Kind::Scenario, Layer::Oa, "Conversation", None) + .unwrap(); + let lanes = ["a", "b"].map(|alias| { + let id = project + .add_element(Kind::Lifeline, Layer::Oa, alias, Some(owner)) + .unwrap(); + project.elements.get_mut(&id).unwrap().alias = alias.into(); + id + }); + let mut messages = Vec::new(); + for index in 0..256 { + let mut message = project + .make_element( + Kind::Occurrence, + Layer::Oa, + &format!("Message {index}"), + Some(owner), + ) + .unwrap(); + let SharedBody::Occurrence { body, .. } = message.shared_mut().unwrap() else { + panic!("message occurrence"); + }; + *body = OccurrenceBody::Exchange { + exchange: None, + sender: Some(lanes[0]), + receiver: Some(lanes[1]), + message_kind: oa_core::MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }; + messages.push(message.id); + project.elements.insert(message.id, message); + } + *project + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() = messages.clone(); + let diagram = Id::new_v4(); + project.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: "removal_view".into(), + name: "Removal view".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())), + }, + ); + oa_core::validate_candidate(&project).unwrap(); + let bundle = source_project::format_bundle(&project); + let bytes = |bundle: &source_project::SourceBundle| { + bundle.files.values().map(String::len).sum::() + }; + let targets = lanes.map(Target::ScenarioObject); + let plan = group_removal::prepare(&project, owner, &targets).unwrap(); + assert_eq!(plan.removed, lanes.into_iter().collect()); + let mut expected = project.clone(); + for lane in lanes { + expected.elements.remove(&lane); + } + for id in &messages { + let SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + sender, receiver, .. + }, + .. + } = expected.elements.get_mut(id).unwrap().shared_mut().unwrap() + else { + panic!("surviving message"); + }; + *sender = None; + *receiver = None; + } + oa_core::validate_candidate(&expected).unwrap(); + let mut unrestricted = Document::from_bundle(bundle.clone(), Default::default()).unwrap(); + unrestricted.execute(plan.command.clone()).unwrap(); + assert_eq!(unrestricted.project(), &expected); + // Removal can grow losslessly edited source: each authored @a/@b becomes + // explicit none in a surviving message. Prove net growth after deletion, + // rather than assuming a budget failure or changing private Document limits. + assert!(bytes(unrestricted.bundle()) > bytes(&bundle)); + let mut limits = ProjectLimits::default(); + limits.model.max_source_bytes = bytes(&bundle); + let mut bounded = Document::from_bundle(bundle.clone(), limits).unwrap(); + let error = bounded.execute(plan.command).unwrap_err(); + assert!( + error + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.diagnostic.code == "source_limit") + ); + let refusal = format!("Nothing removed: {error}"); + + for saved in [false, true] { + for with_redo in [false, true] { + let mut app = App::with_project(project.clone()); + app.document = Document::from_bundle(bundle.clone(), limits).unwrap(); + let redo = if with_redo { + // A genuine earlier rename fits the same budget. Undo leaves + // its exact model/source snapshot available before this review. + app.document + .execute(Command::Rename(owner, "Short".into())) + .unwrap(); + let future = app.document.snapshot(); + assert!(app.document.undo()); + Some(future) + } else { + None + }; + app.sync(); + let _ = app.update(Message::Select(owner)); + let _ = app.update(if saved { + Message::Diagram(Choice { + id: diagram, + label: "Saved".into(), + }) + } else { + Message::Page(Page::Behavior) + }); + for (id, choice) in [ + (lanes[1], multiselect::Choice::Replace), + (lanes[0], multiselect::Choice::Add), + ] { + let event = selection::Event { + context: app.scenario_context().unwrap(), + snapshot: app.multiselect_snapshot().unwrap(), + intent: selection::Intent::Choose(Target::ScenarioObject(id), choice), + }; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new( + event, + )))); + } + app.window_size = iced::Size::new(1600., 1000.); + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + iced::Size::new(900., 600.), + ))); + assert!(app.scenario_removal_enabled()); + let baseline = app.document.snapshot(); + let revision = app.document.revision(); + let selected = app.multiselect_snapshot().unwrap(); + assert_eq!( + selected.targets(), + &[targets[1].clone(), targets[0].clone()] + ); + let context = app.scenario_context().unwrap(); + let camera = app.behavior.scenario.camera_boundary(); + let navigation = ( + app.page, + app.active_diagram, + app.selected, + app.selected_node, + app.behavior.active_owner, + ); + let editor = app.source_editor.text(); + assert!(!app.document.dirty() && !app.document.can_undo()); + assert_eq!(app.document.can_redo(), with_redo); + let token = open(&mut app); + let review = app.delete_target.as_ref().unwrap(); + assert!(app.scenario_removal_review_valid(review)); + assert_eq!(review.scenario.as_ref().unwrap().plan.removed, plan.removed); + let epoch = app.canvas_epoch; + let _ = app.update(Message::ConfirmDelete(token)); + assert_eq!(app.status, refusal); + assert!(!app.delete_pending && app.delete_target.is_none()); + assert_ne!(app.canvas_epoch, epoch, "failed confirmation retires input"); + assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot())); + assert_eq!(app.project(), &baseline.decoded.project); + assert_eq!(app.document.bundle(), &baseline.bundle); + assert_eq!(app.document.revision(), revision); + assert!(!app.document.dirty() && !app.document.can_undo()); + assert_eq!(app.document.can_redo(), with_redo); + let retained = app.multiselect_snapshot().unwrap(); + assert_eq!(retained.targets(), selected.targets()); + assert_eq!(retained.primary(), selected.primary()); + assert_eq!(retained.generation(), selected.generation()); + let after = app.scenario_context().unwrap(); + assert_eq!(after.document, context.document); + assert_eq!(after.revision, context.revision); + assert_eq!(after.representation, context.representation); + assert_eq!(after.visit, context.visit); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!( + ( + app.page, + app.active_diagram, + app.selected, + app.selected_node, + app.behavior.active_owner, + ), + navigation + ); + assert_eq!(app.source_editor.text(), editor); + assert_eq!(app.source_workspace.bundle(), baseline.bundle); + assert!(!app.source_dirty() && !app.draft_dirty()); + let epoch = app.canvas_epoch; + let _ = app.update(Message::ConfirmDelete(token)); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!(app.multiselect_snapshot().unwrap(), retained); + assert_eq!(app.status, refusal); + assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot())); + if let Some(future) = redo { + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &future.decoded.project); + assert_eq!(app.document.bundle(), &future.bundle); + assert!(!app.document.can_redo()); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &baseline.decoded.project); + assert_eq!(app.document.bundle(), &baseline.bundle); + assert!(!app.document.can_undo() && !app.document.dirty()); + } + } + } +} + #[test] fn scenario_group_removal_old_modal_packets_do_not_touch_the_new_review() { let mut f = setup(true);