Add direct scenario gestures and atomic ordered message insertion
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user