use super::*; use crate::document::Document; use oa_core::source_project::{ProjectLimits, SourceBundle}; pub(crate) struct Fixture { pub p: Project, pub owner: Id, pub work: [Id; 3], pub nodes: [Id; 3], pub exchange: Id, pub link: Id, } pub(crate) fn fixture(layer: Layer) -> Fixture { let mut p = Project::empty(); let owner = p .make_element( if layer == Layer::Oa { Kind::Process } else { Kind::FunctionalChain }, layer, "Respond safely", None, ) .unwrap(); let owner_id = owner.id; p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap(); let mut work = Vec::new(); let mut nodes = Vec::new(); for name in ["Assess", "Respond", "Recover"] { let e = p .make_element( if layer == Layer::Oa { Kind::Activity } else { Kind::Function }, layer, name, None, ) .unwrap(); let id = e.id; p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap(); work.push(id); let e = occurrence( &p, owner_id, name, OccurrenceBody::Action { action: Some(id), lifeline: None, }, ); let id = e.id; p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap(); nodes.push(id); } let kind = if layer == Layer::Oa { RelationKind::Interaction } else { RelationKind::FunctionalExchange }; p = execute_batch( &p, &[Command::Connect( kind, "Assessment".into(), work[0], work[1], )], ) .unwrap(); let exchange = p.relations.values().find(|r| r.kind() == kind).unwrap().id; let Command::CreateRelation(mut r) = link(&p, nodes[0], nodes[1], "approved") else { unreachable!() }; r.alias = "assessed_path".into(); r.description = "Reviewed safety rationale".into(); let link_id = r.id; p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap(); Fixture { p, owner: owner_id, work: work.try_into().unwrap(), nodes: nodes.try_into().unwrap(), exchange, link: link_id, } } pub(crate) fn context(owner: Id) -> Context { Context { document: Id::new_v4(), owner, revision: 0, epoch: 0, } } fn command(f: &Fixture, tool: Tool, target: Option, insertion: Insertion) -> Command { let mut state = State::default(); state .begin(&f.p, context(f.owner), tool, ControlKind::Choice, insertion) .unwrap(); if let Some(id) = target { state .update( &f.p, Event::Target(Choice { id, label: "Source".into(), }), ) .unwrap(); } state.prepare_pending(&f.p).unwrap() } fn created(p: &Project, after: &Project, owner: Id) -> Id { after .children(owner) .find(|e| !p.contains(e.id)) .unwrap() .id } #[test] fn direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link() { for layer in [Layer::Oa, Layer::Sa] { let mut f = fixture(layer); let original = f.p.clone(); let add = command(&f, Tool::Work, Some(f.work[0]), Insertion::default()); let result = execute_batch(&f.p, &[add]).unwrap(); let repeated = created(&f.p, &result, f.owner); assert_ne!(repeated, f.nodes[0]); assert_eq!(result.elements[&f.work[0]], f.p.elements[&f.work[0]]); assert!( matches!(result.elements[&repeated].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Action{action:Some(id),..},..})if *id==f.work[0]) ); f.p = result; let plan = command( &f, Tool::Exchange, Some(f.exchange), Insertion { link: Some(f.link), before: None, }, ); let serialized = serde_json::to_string(&plan).unwrap(); let api: Command = serde_json::from_str(&serialized).unwrap(); assert_eq!(api, plan); let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); let before = doc.bundle().clone(); doc.execute(plan).unwrap(); let inserted = created(&f.p, doc.project(), f.owner); let r = &doc.project().relations[&f.link]; let expected = &f.p.relations[&f.link]; assert_eq!(r.id, expected.id); assert_eq!(r.alias, expected.alias); assert_eq!(r.description, expected.description); assert_eq!(r.source(), expected.source()); assert_eq!(r.target(), inserted); assert!(matches!(&r.body,RelationBody::SequenceLink{guard,..}if guard=="approved")); assert!( sequence_links(doc.project(), f.owner) .iter() .any(|r| r.source() == inserted && r.target() == f.nodes[1]) ); let steps = doc.project().elements[&f.owner].steps().unwrap(); assert_eq!(&steps[..3], &[f.nodes[0], inserted, f.nodes[1]]); assert_eq!( doc.project().relations[&f.exchange], original.relations[&f.exchange] ); assert_eq!(doc.project(), &execute_batch(&f.p, &[api]).unwrap()); let after = doc.bundle().clone(); assert!(doc.undo()); assert_eq!(doc.bundle(), &before); assert!(doc.redo()); assert_eq!(doc.bundle(), &after); let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap(); assert_eq!(reopened.project(), doc.project()); } } #[test] fn direct_process_specific_parallel_link_and_incompatible_adjacency() { let mut f = fixture(Layer::Oa); let Command::CreateRelation(mut second) = link(&f.p, f.nodes[0], f.nodes[1], "urgent") else { unreachable!() }; second.alias = "urgent_path".into(); let second_id = second.id; f.p = execute_batch(&f.p, &[Command::CreateRelation(second)]).unwrap(); let graph = Graph::new(&f.p, f.owner, f.owner); for edge in &graph.edges { let clicked = send( &graph, &mut Input::default(), mouse::Event::ButtonPressed(mouse::Button::Left), edge.label.center(), Rectangle::new(Point::ORIGIN, graph.size), ); assert!( matches!(clicked,Some(Message::Select(id))if id==edge.id), "Each guarded parallel path must be individually selectable" ); } let plan = command( &f, Tool::Control, None, Insertion { link: Some(f.link), before: None, }, ); let after = execute_batch(&f.p, &[plan]).unwrap(); assert_eq!(after.relations[&second_id], f.p.relations[&second_id]); let mut state = State::default(); state .begin( &f.p, context(f.owner), Tool::Exchange, ControlKind::Choice, Insertion { link: Some(f.link), before: None, }, ) .unwrap(); let wrong = execute_batch( &f.p, &[Command::Connect( RelationKind::Interaction, "Reverse".into(), f.work[2], f.work[0], )], ) .unwrap(); let wrong_id = wrong .relations .values() .find(|r| r.name == "Reverse") .unwrap() .id; state .update( &wrong, Event::Target(Choice { id: wrong_id, label: "Wrong".into(), }), ) .unwrap(); assert!(state.prepare_pending(&wrong).is_err()); assert!(state.draft.is_some()); assert!(direct::reconnect(&f.p, f.owner, f.link, direct::End::Target, f.nodes[0]).is_err()); } #[test] fn direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics() { let f = fixture(Layer::Oa); for kind in [ ControlKind::Choice, ControlKind::Merge, ControlKind::Fork, ControlKind::Join, ControlKind::Loop, ] { let mut state = State::default(); state .begin( &f.p, context(f.owner), Tool::Control, kind, Insertion::default(), ) .unwrap(); let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap(); let id = created(&f.p, &result, f.owner); assert!( matches!(result.elements[&id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Control{kind:k},..})if *k==kind) ); assert!( validation::validate(&result) .iter() .any(|d| d.element == Some(id) && d.code == "control_cardinality" && d.severity == validation::Severity::Warning) ); } for kind in [ControlKind::Choice, ControlKind::Fork] { let mut state = State::default(); state .update(&f.p, Event::Start(f.owner, Tool::Branch, Some(f.nodes[0]))) .unwrap(); for event in [ Event::Target(Choice { id: f.work[0], label: "A".into(), }), Event::Alternate(Choice { id: f.work[2], label: "B".into(), }), Event::Control(ControlChoice(kind)), Event::Guard("ready".into()), ] { state.update(&f.p, event).unwrap(); } let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap(); assert!( matches!(&result.relations[&f.link].body,RelationBody::SequenceLink{guard,..}if guard=="approved") ); let branch_guards: Vec<_> = sequence_links(&result, f.owner) .into_iter() .filter(|r| !f.p.relations.contains_key(&r.id)) .filter_map(|r| { if let RelationBody::SequenceLink { guard, .. } = &r.body { Some(guard.as_str()) } else { None } }) .collect(); assert_eq!( branch_guards.contains(&"ready"), kind == ControlKind::Choice ); assert_eq!(branch_guards.contains(&"else"), kind == ControlKind::Choice); } } #[test] fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources() { let f = fixture(Layer::Oa); let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); let original = doc.bundle().clone(); doc.execute( direct::reconnect( doc.project(), f.owner, f.link, direct::End::Target, f.nodes[2], ) .unwrap(), ) .unwrap(); assert_eq!(doc.project().relations[&f.link].target(), f.nodes[2]); assert_eq!(doc.project().relations[&f.link].alias, "assessed_path"); let reconnected = doc.bundle().clone(); doc.execute(Command::Delete(f.nodes[0])).unwrap(); assert!(doc.project().contains(f.work[0])); assert!(doc.project().contains(f.exchange)); assert!(doc.project().contains(f.nodes[1])); assert!(!doc.project().contains(f.link)); doc.undo(); assert_eq!(doc.bundle(), &reconnected); doc.undo(); assert_eq!(doc.bundle(), &original); let group_reorder::Plan::Change(command) = direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap() else { panic!("changed order"); }; let reordered = execute_batch(&f.p, &[*command]).unwrap(); assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]); assert_eq!(reordered.relations, f.p.relations); assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err()); } #[test] fn direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected() { let mut f = fixture(Layer::Oa); let source = f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None) .unwrap(); let source_id = source.id; f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap(); for i in 0..8 { let e = occurrence( &f.p, source_id, &format!("Shared step {i}"), OccurrenceBody::Action { action: Some(f.work[0]), lifeline: None, }, ); f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap(); } let shared = f.p.clone(); let mut references = Vec::new(); for _ in 0..2 { let plan = command(&f, Tool::Reuse, Some(source_id), Insertion::default()); let result = execute_batch(&f.p, &[plan]).unwrap(); references.push(created(&f.p, &result, f.owner)); f.p = result; } let graph = Graph::new(&f.p, f.owner, references[0]); let node = graph.nodes.iter().find(|n| n.id == references[0]).unwrap(); assert_eq!(node.preview.len(), 4); assert_eq!(node.preview_total, 8); let mut input = Input::default(); let point = Point::new(node.rect.x + 20., node.rect.y + 140.); let clicked = send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), point, Rectangle::new(Point::ORIGIN, graph.size), ); assert!( matches!(clicked,Some(Message::Select(id))if id==references[0]), "preview click must select the local reference" ); let expanded = Graph::expanded( &f.p, f.owner, references[0], &BTreeSet::from([references[0]]), ); assert_eq!( expanded .nodes .iter() .find(|n| n.id == references[0]) .unwrap() .preview .len(), 8 ); assert_eq!( expanded .nodes .iter() .find(|n| n.id == references[1]) .unwrap() .preview .len(), 4 ); for e in shared.children(source_id) { assert_eq!(f.p.elements[&e.id], *e); } assert!( !targets(&f.p, source_id, Tool::Reuse) .iter() .any(|c| c.id == f.owner) ); let inside = f.p.elements[&source_id].steps().unwrap()[0]; assert!(!validation::can_connect( &f.p, RelationKind::SequenceLink, references[0], inside )); } #[test] fn direct_process_nested_source_reopen_and_api_command_use_one_transaction() { let bundle=SourceBundle{entry:"project.arcadia".into(),files:[ ("project.arcadia".into(),"arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; include \"oa/process.arcadia\"; }\n".into()), ("oa/work.arcadia".into(),"// Shared work\noa_activity assess \"Assess\" {}\n".into()), ("oa/process.arcadia".into(),"// Contextual process\noa_process respond \"Respond\" {}\n".into()), ].into()}; let mut doc = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); let original = doc.bundle().clone(); let owner = doc .project() .elements .values() .find(|e| e.alias == "respond") .unwrap() .id; let work = doc .project() .elements .values() .find(|e| e.alias == "assess") .unwrap() .id; let mut state = State::default(); state .begin( doc.project(), context(owner), Tool::Work, ControlKind::Choice, Insertion::default(), ) .unwrap(); state .update( doc.project(), Event::Target(Choice { id: work, label: "Assess".into(), }), ) .unwrap(); let plan = state.prepare_pending(doc.project()).unwrap(); let expected = execute_batch( doc.project(), &[serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap()], ) .unwrap(); doc.execute(plan).unwrap(); let id = doc.project().elements[&owner].steps().unwrap()[0]; assert_eq!( doc.compiled().index.declarations[&id].path, "oa/process.arcadia" ); assert_eq!( doc.bundle().files["oa/work.arcadia"], original.files["oa/work.arcadia"] ); assert_eq!(doc.project(), &expected); let after = doc.bundle().clone(); doc.undo(); assert_eq!(doc.bundle(), &original); doc.redo(); assert_eq!(doc.bundle(), &after); let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap(); assert_eq!(reopened.project(), &expected); } fn graph(f: &Fixture, tool: CanvasTool) -> Graph<'_> { let mut graph = Graph::new(&f.p, f.owner, f.owner); graph.interaction = Some((context(f.owner), tool, true)); graph } fn send( graph: &Graph<'_>, input: &mut Input, event: mouse::Event, point: Point, bounds: Rectangle, ) -> Option { canvas::Program::update( graph, input, &canvas::Event::Mouse(event), bounds, mouse::Cursor::Available(point), ) .and_then(|a| a.into_inner().0) } fn position(graph: &Graph<'_>, id: Id) -> Point { graph .nodes .iter() .find(|n| n.id == id) .unwrap() .rect .center() } #[test] fn direct_process_production_click_click_drag_insert_and_camera_coordinates() { let f = fixture(Layer::Oa); for drag in [false, true] { let mut graph = graph(&f, CanvasTool::Connect); graph.camera_pan = iced::Vector::new(35., 90.); let bounds = Rectangle::new( Point::new(10., 20.), Size::new(graph.size.width + 70., graph.size.height + 180.), ); let screen = |id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) + graph.camera_pan; let a = screen(f.nodes[0]); let b = screen(f.nodes[2]); assert!(bounds.contains(a) && bounds.contains(b)); let mut input = Input::default(); send( &graph, &mut input, mouse::Event::CursorMoved { position: a }, a, bounds, ); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), a, bounds, ); if !drag { assert!( send( &graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), a, bounds ) .is_none() ); } send( &graph, &mut input, mouse::Event::CursorMoved { position: b }, b, bounds, ); if !drag { send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), b, bounds, ); } assert!( matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Connect{source,target,..}))if source==f.nodes[0]&&target==f.nodes[2]) ); assert!( send( &graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds ) .is_none() ); } let graph = graph(&f, CanvasTool::Add(Tool::Exchange, ControlKind::Choice)); let bounds = Rectangle::new(Point::ORIGIN, graph.size); let e = &graph.edges[0]; let point = Point::new((e.points[0].x + e.points[1].x) / 2., e.points[0].y); let mut input = Input::default(); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), point, bounds, ); assert!( matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),point,bounds),Some(Message::TopologyCanvas(CanvasEvent::Begin{insertion:Insertion{link:Some(id),..},..}))if id==f.link) ); } #[test] fn direct_process_production_stale_focus_scroll_bounds_and_pending_cancel() { let f = fixture(Layer::Oa); for change in 0..8 { let mut graph = graph(&f, CanvasTool::Connect); let bounds = Rectangle::new(Point::ORIGIN, graph.size); let a = position(&graph, f.nodes[0]); let b = position(&graph, f.nodes[2]); let mut input = Input::default(); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), a, bounds, ); send( &graph, &mut input, mouse::Event::CursorMoved { position: b }, b, bounds, ); let mut end_bounds = bounds; match change { 0 => graph.interaction.as_mut().unwrap().0.epoch += 1, 1 => graph.interaction.as_mut().unwrap().2 = false, 2 => end_bounds.y -= 40., 3 => { canvas::Program::update( &graph, &mut input, &canvas::Event::Window(iced::window::Event::Unfocused), bounds, mouse::Cursor::Available(b), ); } 4 => { send( &graph, &mut input, mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, }, b, bounds, ); } 5 => graph.interaction.as_mut().unwrap().0.document = Id::new_v4(), 6 => graph.interaction.as_mut().unwrap().1 = CanvasTool::Select, _ => { canvas::Program::update( &graph, &mut input, &canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), modified_key: iced::keyboard::Key::Named( iced::keyboard::key::Named::Escape, ), physical_key: iced::keyboard::key::Physical::Code( iced::keyboard::key::Code::Escape, ), location: iced::keyboard::Location::Standard, modifiers: iced::keyboard::Modifiers::empty(), text: None, repeat: false, }), bounds, mouse::Cursor::Available(b), ); } } assert!( send( &graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), b, end_bounds ) .is_none(), "change {change}" ); } } #[test] fn direct_process_readable_default_fit_expansion_and_bounded_choices() { let f = fixture(Layer::Oa); let mut state = State::default(); state.enter(Id::new_v4(), f.owner); state.viewport(Size::new(500., 400.)); assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.); state.zoom(None); assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2); state.zoom(Some(0.1)); assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1); state.enter(Id::new_v4(), f.owner); assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.); let mut all = Vec::new(); for page in 0..500usize.div_ceil(24) { let range = view::choice_page(500, page); assert!(range.len() <= 24); all.extend(range); } assert_eq!(all, (0..500).collect::>()); assert_eq!(view::choice_page(0, 90), 0..0); } #[test] fn direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity() { let f = fixture(Layer::Oa); let mut graph = graph(&f, CanvasTool::Select); let bounds = Rectangle::new(Point::ORIGIN, graph.size); graph.selected = f.link; let mut input = Input::default(); let a = *graph .edges .iter() .find(|e| e.id == f.link) .unwrap() .points .last() .unwrap(); let b = position(&graph, f.nodes[2]); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), a, bounds, ); send( &graph, &mut input, mouse::Event::CursorMoved { position: b }, b, bounds, ); assert!( matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reconnect{link,end:direct::End::Target,target,..}))if link==f.link&&target==f.nodes[2]) ); graph.selected = f.nodes[2]; let n = graph.nodes.iter().find(|n| n.id == f.nodes[2]).unwrap(); let a = Point::new(n.rect.x + n.rect.width - 20., n.rect.y + 18.); let b = position(&graph, f.nodes[0]); let mut input = Input::default(); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), a, bounds, ); send( &graph, &mut input, mouse::Event::CursorMoved { position: b }, b, bounds, ); assert!( matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reorder{occurrence,before,..}))if occurrence==f.nodes[2]&&before==f.nodes[0]) ); } #[test] fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() { let f = fixture(Layer::Oa); let mut state = State::default(); state .begin( &f.p, context(f.owner), Tool::Work, ControlKind::Choice, Insertion::default(), ) .unwrap(); state .update( &f.p, Event::Target(Choice { id: f.work[0], label: "Keep".into(), }), ) .unwrap(); let mut choices: Vec<_> = (0..500) .map(|i| Choice { id: Id::new_v4(), label: format!("Activity {i}"), }) .collect(); choices.push(Choice { id: f.work[0], label: "Selected source".into(), }); fn nodes(tree: &iced::advanced::widget::Tree) -> usize { 1 + tree.children.iter().map(nodes).sum::() } for page in 0..21 { state.update(&f.p, Event::Page(0, page)).unwrap(); let d = state.draft.as_ref().unwrap(); let view = view::picker(choices.clone(), d, 0, d.target, "Choose", Event::Target); assert!(nodes(&iced::advanced::widget::Tree::new(view.as_widget())) < 40); } state .update(&f.p, Event::Search(0, "Activity 499".into())) .unwrap(); 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 { operation: None, 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); } } } fn assert_interactive_owner_view(p: &Project, owner: Id) { use iced::advanced::widget::Tree; fn contains(tree: &Tree, tag: iced::advanced::widget::tree::Tag) -> bool { tree.tag == tag || tree.children.iter().any(|child| contains(child, tag)) } let mut app = App::with_project(p.clone()); app.layer = p.elements[&owner].layer(); app.page = crate::app::Page::Behavior; app.selected = owner; app.behavior.active_owner = Some(owner); let graph: Element<'_, Message> = canvas::Canvas::new(Graph::new(p, owner, owner)).into(); let tag = Tree::new(graph.as_widget()).tag; let view = view::view(&app, owner, Column::new()); assert!( contains(&Tree::new(view.as_widget()), tag), "every path uses the interactive graph" ); } #[test] fn direct_process_three_ordered_exchanges_preserve_outer_paths_on_insertion() { for layer in [Layer::Oa, Layer::Sa] { let (mut f, next) = empty_exchange_fixture(layer); f.p = execute_batch( &f.p, &[Command::Connect( f.p.relations[&f.exchange].kind(), "Return for reassessment".into(), f.work[2], f.work[0], )], ) .unwrap(); let last = f.p.relations .values() .find(|r| r.name == "Return for reassessment") .unwrap() .id; for exchange in [f.exchange, next, last] { let node = occurrence( &f.p, f.owner, "Ordered contribution", OccurrenceBody::Exchange { operation: None, exchange: Some(exchange), sender: None, receiver: None, message_kind: MessageKind::default(), reply_to: None, values: vec![], }, ); f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap(); } for index in [0, 2] { let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); let before = doc.bundle().clone(); let old_order = legacy_order(doc.project(), f.owner); let graph = Graph::new(doc.project(), f.owner, f.owner); assert_eq!(graph.ordered_paths.len(), 2); assert!(graph.edges.is_empty()); let mut state = State::default(); state .begin( doc.project(), context(f.owner), Tool::Work, ControlKind::Choice, Insertion { link: None, before: Some(old_order[index]), }, ) .unwrap(); state .update( doc.project(), Event::Target(Choice { id: f.work[index], label: "Shared activity".into(), }), ) .unwrap(); doc.execute(state.prepare_pending(doc.project()).unwrap()) .unwrap(); let added = created(&f.p, doc.project(), f.owner); let mut expected = old_order; expected.insert(index, added); assert_eq!(doc.project().elements[&f.owner].steps().unwrap(), &expected); let links = sequence_links(doc.project(), f.owner); assert_eq!(links.len(), 3); for pair in expected.windows(2) { assert_eq!( links .iter() .filter(|r| r.source() == pair[0] && r.target() == pair[1]) .count(), 1 ); } for (id, relation) in &f.p.relations { assert_eq!(&doc.project().relations[id], relation); } let after = doc.bundle().clone(); assert!(doc.undo()); assert_eq!(doc.bundle(), &before); assert_eq!(doc.project(), &f.p); assert!(doc.redo()); assert_eq!(doc.bundle(), &after); } } } #[test] fn direct_process_incompatible_imported_order_rejects_conversion_without_losing_draft() { for layer in [Layer::Oa, Layer::Sa] { let (mut f, _) = empty_exchange_fixture(layer); // Repeated exchange uses have different occurrences, but this legacy path // has an endpoint gap. Converting its implied adjacency must reject. for _ in 0..2 { let node = occurrence( &f.p, f.owner, "Repeated exchange", OccurrenceBody::Exchange { operation: None, exchange: Some(f.exchange), sender: None, receiver: None, message_kind: MessageKind::default(), reply_to: None, values: vec![], }, ); f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap(); } let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); let before = doc.bundle().clone(); assert_interactive_owner_view(doc.project(), f.owner); assert_eq!( Graph::new(doc.project(), f.owner, f.owner) .ordered_paths .len(), 1 ); let mut state = State::default(); state .begin( doc.project(), context(f.owner), Tool::Control, ControlKind::Choice, Insertion::default(), ) .unwrap(); state .update( doc.project(), Event::Name("Retain this proposed decision".into()), ) .unwrap(); assert!(state.prepare_pending(doc.project()).is_err()); assert_eq!( state.draft.as_ref().unwrap().name, "Retain this proposed decision" ); assert_eq!(doc.bundle(), &before); assert_eq!(doc.project(), &f.p); assert!(!doc.undo(), "rejection must not create history"); state.update(doc.project(), Event::Cancel).unwrap(); assert!(state.draft.is_none()); assert_eq!(doc.bundle(), &before); } } #[test] fn direct_process_empty_first_and_second_exchange_keep_the_same_interactive_renderer() { for layer in [Layer::Oa, Layer::Sa] { let (f, next) = empty_exchange_fixture(layer); let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); for (index, target) in [f.exchange, next].into_iter().enumerate() { assert_interactive_owner_view(doc.project(), f.owner); let before = doc.bundle().clone(); let mut graph = Graph::new(doc.project(), f.owner, f.owner); let c = context(f.owner); graph.interaction = Some(( c, CanvasTool::Add(Tool::Exchange, ControlKind::Choice), true, )); let mut input = Input::default(); let bounds = Rectangle::with_size(graph.size); let point = Point::new(graph.size.width - 10., graph.size.height - 10.); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), point, bounds, ); let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send( &graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds, ) else { panic!("canvas retains direct exchange insertion"); }; let mut state = State::default(); state .begin( doc.project(), c, Tool::Exchange, ControlKind::Choice, insertion, ) .unwrap(); state .update( doc.project(), Event::Target(Choice { id: target, label: "Exchange".into(), }), ) .unwrap(); doc.execute(state.prepare_pending(doc.project()).unwrap()) .unwrap(); assert_eq!( doc.project().elements[&f.owner].steps().unwrap().len(), index + 1 ); assert_eq!(sequence_links(doc.project(), f.owner).len(), index); assert_interactive_owner_view(doc.project(), f.owner); let after = doc.bundle().clone(); assert!(doc.undo()); assert_eq!(doc.bundle(), &before); assert!(doc.redo()); assert_eq!(doc.bundle(), &after); } } } #[test] fn direct_process_imported_order_is_projected_without_mutation_and_first_canvas_edit_is_atomic() { for layer in [Layer::Oa, Layer::Sa] { let (mut f, next) = empty_exchange_fixture(layer); for exchange in [f.exchange, next] { let node = occurrence( &f.p, f.owner, "Imported exchange", OccurrenceBody::Exchange { operation: None, exchange: Some(exchange), sender: None, receiver: None, message_kind: MessageKind::default(), reply_to: None, values: vec![], }, ); f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap(); } let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap(); let before = doc.bundle().clone(); let order = legacy_order(doc.project(), f.owner); assert_eq!(order.len(), 2); assert_interactive_owner_view(doc.project(), f.owner); let mut graph = Graph::new(doc.project(), f.owner, order[0]); assert!( graph.edges.is_empty(), "projection never invents relation identities" ); assert_eq!(graph.ordered_paths.len(), 1); assert!(position(&graph, order[0]).x < position(&graph, order[1]).x); let points = &graph.ordered_paths[0].points; let point = Point::new((points[0].x + points[1].x) / 2., points[0].y); let c = context(f.owner); graph.interaction = Some((c, CanvasTool::Add(Tool::Work, ControlKind::Choice), true)); let mut input = Input::default(); let bounds = Rectangle::with_size(graph.size); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), point, bounds, ); let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send( &graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds, ) else { panic!("ordered arrow supports direct insertion"); }; assert_eq!( insertion, Insertion { link: None, before: Some(order[1]) } ); assert_eq!(doc.bundle(), &before); let mut state = State::default(); state .begin(doc.project(), c, Tool::Work, ControlKind::Choice, insertion) .unwrap(); state .update( doc.project(), Event::Target(Choice { id: f.work[1], label: "Respond".into(), }), ) .unwrap(); doc.execute(state.prepare_pending(doc.project()).unwrap()) .unwrap(); let inserted = created(&f.p, doc.project(), f.owner); assert_eq!( doc.project().elements[&f.owner].steps().unwrap(), &[order[0], inserted, order[1]] ); let links = sequence_links(doc.project(), f.owner); assert_eq!(links.len(), 2); for (a, b) in [(order[0], inserted), (inserted, order[1])] { assert!( links .iter() .any(|link| link.source() == a && link.target() == b) ); } assert!( Graph::new(doc.project(), f.owner, inserted) .ordered_paths .is_empty() ); for id in f.work { assert_eq!(doc.project().elements[&id], f.p.elements[&id]); } assert!(doc.undo()); assert_eq!(doc.bundle(), &before); assert_eq!(doc.project(), &f.p); } } #[test] fn direct_process_palette_preserves_fit_and_manual_camera() { for layer in [Layer::Oa, Layer::Sa] { let f = fixture(layer); let mut state = State::default(); let document = Id::new_v4(); state.enter(document, f.owner); state.viewport(Size::new(500., 300.)); state.camera(super::super::canvas_workspace::Event::Pan( iced::Vector::new(35., 90.), )); state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.)); for camera in [None, Some(0.75), Some(1.5)] { state.zoom(camera); let size = Size::new(690., 250.); let before = state.scale(size, 500.); for tool in [ CanvasTool::Add(Tool::Work, ControlKind::Choice), CanvasTool::Add(Tool::Exchange, ControlKind::Choice), CanvasTool::Add(Tool::Reuse, ControlKind::Choice), CanvasTool::Add(Tool::Control, ControlKind::Choice), CanvasTool::Add(Tool::Control, ControlKind::Fork), CanvasTool::Connect, CanvasTool::Select, ] { state.choose(tool); assert_eq!( state.scale(size, 500.), before, "{layer:?} {camera:?} {tool:?}" ); assert_eq!(state.fit, camera.is_none()); assert_eq!(state.pan, iced::Vector::new(35., 90.)); assert_eq!(state.details_offset(), iced::Vector::new(0., 120.)); state.enter(document, f.owner); assert_eq!(state.scale(size, 500.), before); } } state.enter(Id::new_v4(), f.owner); assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.); assert_eq!(state.pan, iced::Vector::ZERO); assert_eq!(state.details_offset(), iced::Vector::ZERO); } } #[test] fn direct_process_scaled_control_and_link_gestures_use_visible_positions() { for layer in [Layer::Oa, Layer::Sa] { let f = fixture(layer); for scale in [0.15, 0.6898551, 1.5] { let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice)); graph.scale = scale; graph.camera_pan = iced::Vector::new(11., 19.); let bounds = Rectangle::new( Point::new(40., 70.), Size::new( graph.size.width * scale + 22., graph.size.height * scale + 38., ), ); let screen = |world: Point| { Point::new( world.x * scale + bounds.x + 11., world.y * scale + bounds.y + 19., ) }; let edge = &graph.edges[0]; let point = screen(Point::new( (edge.points[0].x + edge.points[1].x) / 2., edge.points[0].y, )); assert!(bounds.contains(point)); let mut input = Input::default(); send( &graph, &mut input, mouse::Event::CursorMoved { position: point }, point, bounds, ); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), point, bounds, ); assert!( matches!( send(&graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), point, bounds), Some(Message::TopologyCanvas(CanvasEvent::Begin { tool: Tool::Control, control: ControlKind::Choice, insertion: Insertion { link: Some(id), .. }, .. })) if id == f.link ), "{layer:?} control at {scale}" ); graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect; for drag in [false, true] { let mut input = Input::default(); let a = screen(position(&graph, f.nodes[0])); let b = screen(position(&graph, f.nodes[2])); assert!(bounds.contains(a) && bounds.contains(b)); send( &graph, &mut input, mouse::Event::CursorMoved { position: a }, a, bounds, ); send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), a, bounds, ); if !drag { send( &graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), a, bounds, ); } send( &graph, &mut input, mouse::Event::CursorMoved { position: b }, b, bounds, ); if !drag { send( &graph, &mut input, mouse::Event::ButtonPressed(mouse::Button::Left), b, bounds, ); } assert!( matches!( send(&graph, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(Message::TopologyCanvas(CanvasEvent::Connect { source, target, .. })) if source == f.nodes[0] && target == f.nodes[2] ), "{layer:?} link at {scale}, drag={drag}" ); } } } }