Exercise real source-budget refusal in Scenario group removal

This commit is contained in:
2026-09-24 19:34:15 -07:00
parent f0e8746950
commit 2aaea8a3a0
@@ -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::<usize>()
};
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);