diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index 8db6ec68..39d87d53 100644 --- a/crates/oa-desktop/src/app/process_canvas.rs +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -143,6 +143,18 @@ impl App { Err(e) => self.status = e, } } + CanvasEvent::AppendExchange(_) => { + let mut state = std::mem::take(&mut self.behavior.topology); + let result = state.append_exchange(self.project(), context.owner); + self.behavior.topology = state; + match result { + Ok(()) => { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into(); + } + Err(e) => self.status = e, + } + } CanvasEvent::Connect { source, target, .. } => { let mut state = std::mem::take(&mut self.behavior.topology); let result = state.connect(self.project(), context.owner, source, target); @@ -222,6 +234,114 @@ mod tests { } } #[test] + fn direct_process_append_empty_first_second_exchange_has_exact_source_history() { + for layer in [Layer::Oa, Layer::Sa] { + let (f, next) = direct_tests::empty_exchange_fixture(layer); + let mut app = App::with_project(f.p.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(Page::Behavior)); + let empty = app.document.snapshot(); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin { + context, + tool: Tool::Exchange, + control: ControlKind::Choice, + insertion: Insertion::default(), + })); + assert!(app.behavior.has_pending()); + form( + &mut app, + Event::Target(Choice { + id: f.exchange, + label: "Assessment".into(), + }), + ); + form(&mut app, Event::Name("First contribution".into())); + form(&mut app, Event::Submit); + assert!(!app.behavior.has_pending(), "{}", app.status); + let first_id = app.selected; + let first = app.document.snapshot(); + assert_eq!( + app.project().elements[&f.owner].steps().unwrap(), + &[first_id] + ); + assert_eq!(app.project().relations, empty.decoded.project.relations); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange( + context, + ))); + assert!( + app.behavior.has_pending(), + "reachable single-exchange continuation: {}", + app.status + ); + assert!(Arc::ptr_eq(&first, &app.document.snapshot())); + form( + &mut app, + Event::Target(Choice { + id: next, + label: "Response".into(), + }), + ); + form(&mut app, Event::Name("Second contribution".into())); + form(&mut app, Event::Submit); + assert!(!app.behavior.has_pending(), "{}", app.status); + let second_id = app.selected; + let second = app.document.snapshot(); + assert_ne!(first_id, second_id); + assert_eq!( + app.project().elements[&f.owner].steps().unwrap(), + &[first_id, second_id] + ); + let links = app + .project() + .relations + .values() + .filter(|r| r.kind() == RelationKind::SequenceLink) + .collect::>(); + assert_eq!(links.len(), 1); + assert_eq!( + (links[0].source(), links[0].target()), + (first_id, second_id) + ); + assert_eq!( + app.project().elements.len(), + empty.decoded.project.elements.len() + 2 + ); + for (id, relation) in &empty.decoded.project.relations { + assert_eq!(app.project().relations[id], *relation); + } + for work in f.work { + assert_eq!( + app.project().elements[&work], + empty.decoded.project.elements[&work] + ); + } + let reopened = Document::from_bundle( + second.bundle.clone(), + oa_core::source_project::ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(reopened.project(), app.project()); + assert_eq!(reopened.bundle(), &second.bundle); + for expected in [&first, &empty] { + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &expected.bundle); + assert_eq!(app.project(), &expected.decoded.project); + } + assert!( + !app.document.can_undo(), + "each Create must add exactly one history entry" + ); + for expected in [&first, &second] { + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &expected.bundle); + assert_eq!(app.project(), &expected.decoded.project); + } + assert!(!app.document.can_redo()); + } + } + #[test] fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() { let (mut app, _) = setup(); let snapshot = app.document.snapshot(); diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index b60ccd9c..2fd2d8ed 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -30,6 +30,7 @@ pub enum CanvasEvent { Scrolled(Context, scenario::ScrollArea, iced::Vector), Choose(Context, CanvasTool), Select(Context, Id), + AppendExchange(Context), Begin { context: Context, tool: Tool, @@ -67,6 +68,7 @@ impl CanvasEvent { | Self::Expand(c, ..) | Self::Open(c, ..) | Self::Back(c) + | Self::AppendExchange(c) | Self::Zoom(c, ..) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } @@ -204,6 +206,15 @@ impl State { } Ok(()) } + pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> { + let last = legacy_order(p, owner) + .last() + .copied() + .ok_or("Choose an ordered exchange path to append to.")?; + self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?; + self.draft.as_mut().expect("started form").fixed = true; + Ok(()) + } pub(crate) fn connect( &mut self, p: &Project, diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index 8d2129c1..cbf9a5c8 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -848,3 +848,123 @@ fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() { assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0])); assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0); } + +pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) { + let mut f = fixture(layer); + let mut commands = f.nodes.into_iter().map(Command::Delete).collect::>(); + commands.push(Command::Connect( + f.p.relations[&f.exchange].kind(), + "Response".into(), + f.work[1], + f.work[2], + )); + f.p = execute_batch(&f.p, &commands).unwrap(); + let next = + f.p.relations + .values() + .find(|r| r.name == "Response") + .unwrap() + .id; + assert!(f.p.elements[&f.owner].steps().unwrap().is_empty()); + (f, next) +} + +#[test] +fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() { + for layer in [Layer::Oa, Layer::Sa] { + let (mut f, next) = empty_exchange_fixture(layer); + let kind = f.p.relations[&f.exchange].kind(); + f.p = execute_batch( + &f.p, + &[Command::Connect( + kind, + "Recovery".into(), + f.work[2], + f.work[0], + )], + ) + .unwrap(); + let final_exchange = + f.p.relations + .values() + .find(|r| r.name == "Recovery") + .unwrap() + .id; + for exchange in [f.exchange, next] { + let e = occurrence( + &f.p, + f.owner, + "Ordered contribution", + OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: MessageKind::default(), + reply_to: None, + values: vec![], + }, + ); + f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap(); + } + let before = f.p.elements[&f.owner].steps().unwrap().clone(); + assert_eq!(legacy_order(&f.p, f.owner), before); + assert!(sequence_links(&f.p, f.owner).is_empty()); + let mut state = State::default(); + state.append_exchange(&f.p, f.owner).unwrap(); + let draft = state.draft.as_ref().unwrap(); + assert!(draft.fixed); + assert_eq!(draft.before, before.last().copied()); + assert_eq!(draft.after, None); + assert!(!compatible_source(&f.p, draft, f.exchange)); + assert!(compatible_source(&f.p, draft, final_exchange)); + state + .update( + &f.p, + Event::Target(Choice { + id: f.exchange, + label: "Incompatible".into(), + }), + ) + .unwrap(); + assert!(state.prepare_pending(&f.p).is_err()); + assert!(state.draft.is_some()); + state + .update( + &f.p, + Event::Target(Choice { + id: final_exchange, + label: "Recovery".into(), + }), + ) + .unwrap(); + let command = state.prepare_pending(&f.p).unwrap(); + let Command::Batch(commands) = &command else { + panic!("one ordinary batch") + }; + assert_eq!( + commands.len(), + 4, + "materialize old path + occurrence + append link + order" + ); + let serialized = serde_json::to_value(command).unwrap(); + let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap(); + let after = result.elements[&f.owner].steps().unwrap(); + assert_eq!(&after[..2], &before); + assert_eq!(after.len(), 3); + let links = sequence_links(&result, f.owner); + assert_eq!(links.len(), 2); + for pair in after.windows(2) { + assert_eq!( + links + .iter() + .filter(|r| r.source() == pair[0] && r.target() == pair[1]) + .count(), + 1 + ); + } + assert!(uses_explicit_topology(&result, f.owner)); + for (id, relation) in &f.p.relations { + assert_eq!(result.relations[id], *relation); + } + } +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 0df64063..e5cad97d 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -155,7 +155,7 @@ pub(super) fn view<'a>( .spacing(6), ); } - out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else{match state.tool{ + out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{ CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column", CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels", CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}})); @@ -226,8 +226,14 @@ pub(super) fn view<'a>( ); } else { out = out.push(label( - "Ordered exchange path · convert explicitly before drawing topology.", + "Ordered exchange path · append the next exchange or convert to explicit links.", )); + if !pending { + out = out.push( + button("Append exchange…") + .on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))), + ).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step.")); + } let commands = preserved_order(p, owner, None); if !commands.is_empty() { out = out.push( @@ -235,7 +241,7 @@ pub(super) fn view<'a>( .on_press(Message::Command(unique_batch(p, commands))), ); } else { - out=out.push(label("A single exchange has no path link to convert. Involve work, a control or a reusable process to begin explicit topology.")) + out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process.")) .push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))), button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))), button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6)); @@ -309,6 +315,14 @@ pub(super) fn view<'a>( p.named(link.target()), guard ))); + } else if d.fixed + && d.tool == Tool::Exchange + && let Some(before) = d.before + { + form = form.push(label(format!( + "Append after {} → new exchange · Create adds an explicit sequence link", + p.named(before) + ))); } else if d.fixed && d.tool != Tool::Connect { form = form.push(label( d.insertion