Add direct scenario gestures and atomic ordered message insertion

This commit is contained in:
2026-09-06 04:33:13 +03:00
parent 93e3137342
commit d1d8f0bbed
3 changed files with 1788 additions and 108 deletions
+242 -1
View File
@@ -12,6 +12,152 @@ use oa_core::{
use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
#[cfg(test)]
mod direct_scenario_tests {
use super::*;
use crate::behavior::{
self,
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
};
fn setup() -> (App, scenario::direct_tests::Fixture) {
let f = scenario::direct_tests::fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
}
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
CanvasEvent::Begin {
context: app.scenario_context().unwrap(),
slot: Slot {
parent: f.owner,
before: None,
},
sender: f.sender,
receiver: f.receiver,
style: MessageStyle::Asynchronous,
}
}
#[test]
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
let (mut app, f) = setup();
let before = app.document.snapshot();
for dimension in 0..4 {
let mut event = begin(&app, &f);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match dimension {
0 => context.document = Id::new_v4(),
1 => context.scenario = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
}
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
let _ = app.update(Message::Field(
"/name".into(),
"Pending scenario name".into(),
));
assert!(app.draft_dirty());
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
let created = app.selected;
assert_ne!(created, f.owner);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[f.call, f.reply, created]
);
let before_reorder = app.document.bundle().clone();
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
context, f.sender,
)));
assert_eq!(
app.scenario_context(),
Some(context),
"same-owner selection retains gesture context"
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: created,
slot: Slot {
parent: f.owner,
before: Some(f.call),
},
}));
assert_eq!(app.selected, created);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[created, f.call, f.reply]
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before_reorder);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original);
assert_eq!(app.project(), &f.project);
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 4000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Name("Long message".repeat(1024)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered message".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_eq!(app.project().named(app.selected), "Recovered message");
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
}
}
#[cfg(test)]
mod behavior_lifecycle_tests;
mod state_diagram;
@@ -147,6 +293,7 @@ pub enum Message {
BehaviorOwner(Choice),
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -579,9 +726,10 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let scenario_selection = self.behavior.scenario.created_selection(&command);
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = selected {
if let Some(id) = scenario_selection.or(selected) {
self.selected = id;
}
self.sync();
@@ -936,8 +1084,101 @@ impl App {
task
}
}
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
.then_some(crate::behavior::scenario::Context {
document: self.canvas_document,
scenario,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if self.scenario_context() != Some(context) {
return Task::none();
}
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id)
== Some(context.scenario)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.scenario.choose_tool(context.scenario, tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
"Apply or cancel pending edits before changing the scenario."
.into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
slot,
sender,
receiver,
style,
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_message(
self.project(),
context,
slot,
sender,
receiver,
style,
);
self.behavior.scenario = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
}
Err(error) => self.status = error,
}
}
CanvasEvent::Reorder {
occurrence, slot, ..
} => {
match scenario::reorder(
self.project(),
context.scenario,
occurrence,
slot,
) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.selected_node = None;
self.refresh_draft();
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
}
}
Err(error) => self.status = error,
}
}
_ => unreachable!(),
}
}
}
}
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()