From 9c79a6e0fb6ee5492ab5f90f8163d00a509c5656 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Fri, 2 Oct 2026 00:45:40 -0700 Subject: [PATCH] Verify Help preserves mixed-view caret and retires held Draw gestures --- .../oa-desktop/src/app/context_help_tests.rs | 535 ++++++++++++++++++ 1 file changed, 535 insertions(+) diff --git a/crates/oa-desktop/src/app/context_help_tests.rs b/crates/oa-desktop/src/app/context_help_tests.rs index 154957a0..970182c6 100644 --- a/crates/oa-desktop/src/app/context_help_tests.rs +++ b/crates/oa-desktop/src/app/context_help_tests.rs @@ -721,3 +721,538 @@ async fn context_help_barrier_retires_text_selection_drag_without_changing_the_c "An unheld pointer no longer extends the pre-Help text selection" ); } + +// Union coverage: Help must own input before mixed-canvas draft/Draw admission. +struct MixedHelp { + app: App, + diagram: Id, + source: Id, + target: Id, + edge: Id, + placement: Id, + tool: crate::connection_tool::Tool, +} + +fn mixed_help(layer: Layer) -> MixedHelp { + use crate::connection_tool::Tool; + let mut p = Project::empty(); + let (kind, other, tool) = match layer { + Layer::Oa => (Kind::Activity, Layer::Sa, Tool::Interaction), + Layer::Sa => (Kind::Function, Layer::Oa, Tool::FunctionalExchange), + }; + let source = p.add_element(kind, layer, "Send", None).unwrap(); + let target = p.add_element(kind, layer, "Receive", None).unwrap(); + p.ensure_diagram(layer); + let diagram = p.ensure_diagram(other); + p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability; + let placement = p.place(diagram, source, Position { x: 20., y: 20. }); + p.place(diagram, target, Position { x: 400., y: 20. }); + let local_kind = if other == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let local = p + .add_element(local_kind, other, "Other layer work", None) + .unwrap(); + p.place(diagram, local, Position { x: 400., y: 240. }); + let edge_kind = if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + let edge = p.relate(edge_kind, "Existing transfer", source, target); + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = other; + app.active_diagram = Some(diagram); + app.selected = diagram; + app.refresh_draft(); + app.reconcile_multiselect(); + choose(&mut app, placement, multiselect::Choice::Replace); + assert_eq!(app.selected, source); + assert_eq!((app.layer, app.active_diagram), (other, Some(diagram))); + assert_ne!(app.selected, edge); + assert!( + project_view(app.project(), diagram) + .edges + .iter() + .any(|visible| visible.id == edge) + ); + MixedHelp { + app, + diagram, + source, + target, + edge, + placement, + tool, + } +} + +fn mixed_help_popup(f: &mut MixedHelp) -> Message { + use crate::model_menu::{Event, Request}; + let mut request = Request::object(f.source, iced::Point::ORIGIN); + request.canvas = f.app.route_context(); + request.placement = Some(f.placement); + send(&mut f.app, Message::Menu(Event::Open(request))); + let popup = crate::model_menu::popup(&f.app).expect("actual mixed-canvas popup"); + let label = format!("Draw {}", f.tool.label().to_lowercase()); + let action = popup + .entries + .into_iter() + .find_map(|entry| match entry { + crate::context_menu::Entry::Action { + label: actual, + message, + .. + } if actual == label => message, + _ => None, + }) + .expect("visible opposite-layer source offers Draw"); + assert!(matches!(&action, Message::Menu(Event::Invoke(token, _)) if *token == popup.token)); + action +} + +fn mixed_help_workspace(app: &App) -> impl PartialEq + std::fmt::Debug + use<> { + ( + ( + app.page, + app.layer, + app.active_diagram, + app.selected, + app.selected_node, + ), + (app.zoom, app.pan), + app.multiselect_snapshot().map(|s| s.targets().to_vec()), + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + (app.source_editor.text(), app.source_version), + ( + app.busy, + app.file_prompt.is_some(), + app.pending.is_some(), + app.workflow.is_some(), + ), + app.model_menu.as_ref().map(|m| m.token), + ) +} + +fn mixed_help_state(app: &App) -> impl PartialEq + std::fmt::Debug + use<> { + ( + mixed_help_workspace(app), + app.connection + .as_ref() + .map(|s| (s.tool, s.source, s.context, s.candidates.clone())), + ) +} + +fn mixed_help_document( + app: &App, + before: &oa_core::source_project::CompiledSourceProject, + revision: u64, +) { + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.document.revision(), revision); + assert!(!app.document.can_undo()); + assert!(!app.document.can_redo()); +} + +#[test] +fn mixed_context_help_blocks_navigation_draft_draw_and_save_before_fast_paths() { + use crate::connection_edit::Event as Draw; + use crate::model_menu::{Event as Menu, Request}; + for layer in [Layer::Oa, Layer::Sa] { + for draft in 0..3 { + let mut f = mixed_help(layer); + let queued_popup = mixed_help_popup(&mut f); + if draft == 0 { + send(&mut f.app, queued_popup.clone()); + let session = f + .app + .connection + .as_ref() + .expect("clean row has an admitted Draw session"); + assert_eq!((session.tool, session.source), (f.tool, Some(f.source))); + assert!(session.candidates.contains(&f.target)); + } else { + send(&mut f.app, Message::Menu(Menu::Close)); + } + match draft { + 1 => send( + &mut f.app, + Message::Field("/name".into(), "Pending mixed name".into()), + ), + 2 => send( + &mut f.app, + Message::Field("/alias".into(), "invalid alias".into()), + ), + _ => {} + } + assert_eq!(f.app.draft_dirty(), draft != 0); + assert_eq!(f.app.connection.is_some(), draft == 0); + let command = f.app.draft.as_ref().unwrap().command().unwrap(); + assert_eq!( + oa_core::prepare_command(f.app.project(), &command).is_err(), + draft == 2 + ); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let original = mixed_help_state(&f.app); + send(&mut f.app, Message::Help); + assert!(f.app.context_help); + assert_eq!(mixed_help_state(&f.app), original); + let current = f.app.route_context().unwrap(); + let mut stale = current; + stale.epoch = stale.epoch.wrapping_add(1); + let mut request = Request::object(f.target, iced::Point::ORIGIN); + request.canvas = Some(current); + for message in [ + Message::CanvasSelect(current, f.edge), + Message::CanvasSelect(stale, f.edge), + Message::Menu(Menu::Open(request)), + queued_popup, + Message::Connection(Draw::Endpoint(current, f.target)), + Message::Connection(Draw::Endpoint(stale, f.target)), + Message::Connection(Draw::Restart(current)), + Message::Connection(Draw::Restart(stale)), + Message::Save(false), + Message::Save(true), + Message::Apply, + Message::Undo, + ] { + assert!( + iced_runtime::task::into_stream(f.app.update(message)).is_none(), + "blocked input must not schedule I/O or widget work" + ); + assert!(f.app.context_help); + assert_eq!(mixed_help_state(&f.app), original); + mixed_help_document(&f.app, &before, revision); + } + send(&mut f.app, Message::CloseContextHelp); + assert!(!f.app.context_help); + assert_eq!( + mixed_help_state(&f.app), + original, + "closing cannot replay queued input" + ); + mixed_help_document(&f.app, &before, revision); + } + } +} + +async fn mixed_help_click_close( + mounted: &mut crate::behavior::canvas_workspace::tests::Mounted, + app: &mut App, +) { + use iced::{advanced::widget, mouse}; + // Observe real header containers and require the actual button's message. + // No direct Close dispatch masquerades as pointer/focus coverage. + #[derive(Default)] + struct Bounds(Vec); + impl widget::Operation for Bounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, _: Option<&widget::Id>, bounds: iced::Rectangle) { + self.0.push(bounds); + } + } + let article = + mounted.geometry(app).scrolls[&widget::Id::new(crate::guide::ARTICLE_SCROLL_ID)].0; + let mut bounds = Bounds::default(); + mounted.operate(app, &mut bounds); + for rect in bounds.0.into_iter().filter(|r| { + r.width < 180. + && r.height < 60. + && r.x > article.x + article.width - 180. + && r.y < article.y + && r.y > article.y - 80. + }) { + let at = rect.center(); + let messages = mounted.input( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: at }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(at), + ); + assert!( + messages + .iter() + .all(|m| matches!(m, Message::CloseContextHelp)), + "a candidate Close click must not leak another workspace command: {messages:?}" + ); + if !messages.is_empty() { + assert_eq!( + messages.len(), + 1, + "one pointer click emits exactly one Close" + ); + let task = app.update(messages.into_iter().next().unwrap()); + mounted.perform(app, task).await; + return; + } + } + panic!("mounted Help Close button did not emit CloseContextHelp"); +} + +#[tokio::test] +async fn mounted_mixed_context_help_preserves_name_and_invalid_alias_carets_then_applies_once() { + use crate::behavior::canvas_workspace::tests::{Mounted, character, key}; + use iced::keyboard::key::Named; + for layer in [Layer::Oa, Layer::Sa] { + for invalid in [false, true] { + for escape in [false, true] { + let mut f = mixed_help(layer); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let mut mounted = Mounted::new(&mut f.app).await; + let mut inputs: Vec<_> = mounted + .geometry(&f.app) + .text_inputs + .into_iter() + .filter(|b| b.x > f.app.window_size.width - 320.) + .collect(); + inputs.sort_by(|a, b| a.y.total_cmp(&b.y)); + // Production forms render Name then Alias; assert the resulting raw field. + let path = if invalid { "/alias" } else { "/name" }; + let old = f.app.draft.as_ref().unwrap().inputs[path].raw.clone(); + mounted + .click(&mut f.app, inputs[usize::from(invalid)].center()) + .await; + help_key(&mut mounted, &mut f.app, key(Named::Home)).await; + let prefix = if invalid { "bad alias " } else { "DRAFT " }; + for value in prefix.chars() { + help_key(&mut mounted, &mut f.app, character(&value.to_string())).await; + } + help_key(&mut mounted, &mut f.app, key(Named::ArrowLeft)).await; + let raw = format!("{prefix}{old}"); + assert_eq!(f.app.draft.as_ref().unwrap().inputs[path].raw, raw); + let command = f.app.draft.as_ref().unwrap().command().unwrap(); + assert_eq!( + oa_core::prepare_command(f.app.project(), &command).is_err(), + invalid + ); + let state = mixed_help_state(&f.app); + help_key(&mut mounted, &mut f.app, key(Named::F1)).await; + assert!(f.app.context_help); + let article = iced::advanced::widget::Id::new(crate::guide::ARTICLE_SCROLL_ID); + help_key(&mut mounted, &mut f.app, key(Named::PageDown)).await; + assert!(mounted.geometry(&f.app).scrolls[&article].1.y > 0.); + help_key(&mut mounted, &mut f.app, key(Named::End)).await; + help_key(&mut mounted, &mut f.app, character("IGNORED")).await; + let messages = mounted + .batch( + &mut f.app, + &[iced::Event::InputMethod( + iced::advanced::input_method::Event::Commit("雨".into()), + )], + iced::mouse::Cursor::Unavailable, + ) + .await; + assert!(!messages.iter().any(|m| matches!(m, Message::Field(..)))); + assert_eq!(mixed_help_state(&f.app), state); + mixed_help_document(&f.app, &before, revision); + if escape { + help_key(&mut mounted, &mut f.app, key(Named::Escape)).await; + } else { + mixed_help_click_close(&mut mounted, &mut f.app).await; + } + assert!(!f.app.context_help); + assert_eq!(mixed_help_state(&f.app), state); + help_key(&mut mounted, &mut f.app, character("X")).await; + let expected_raw = format!("{}X {old}", prefix.trim_end()); + assert_eq!( + f.app.draft.as_ref().unwrap().inputs[path].raw, + expected_raw, + "exact mid-string caret survives without refocusing" + ); + let surface = ( + f.app.page, + f.app.layer, + f.app.active_diagram, + f.app.selected, + f.app.zoom, + f.app.pan, + ); + if invalid { + let recovery = + serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()) + .unwrap(); + assert!(!f.app.apply()); + assert_eq!( + serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()) + .unwrap(), + recovery + ); + mixed_help_document(&f.app, &before, revision); + assert_eq!( + ( + f.app.page, + f.app.layer, + f.app.active_diagram, + f.app.selected, + f.app.zoom, + f.app.pan + ), + surface + ); + } else { + let mut expected = Document::from_bundle( + before.bundle.clone(), + oa_core::source_project::ProjectLimits::default(), + ) + .unwrap(); + expected + .execute(Command::Rename(f.source, expected_raw.clone())) + .unwrap(); + let mut expected_project = before.decoded.project.clone(); + expected_project.elements.get_mut(&f.source).unwrap().name = expected_raw; + assert!(f.app.apply()); + assert_eq!(f.app.project(), &expected_project); + assert_eq!(f.app.document.bundle(), expected.bundle()); + assert_eq!(f.app.document.revision(), revision + 1); + assert!(!f.app.document.can_redo()); + assert_eq!( + ( + f.app.page, + f.app.layer, + f.app.active_diagram, + f.app.selected, + f.app.zoom, + f.app.pan + ), + surface + ); + send(&mut f.app, Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + assert!(f.app.document.can_redo()); + send(&mut f.app, Message::Redo); + assert_eq!(f.app.project(), &expected_project); + assert_eq!(f.app.document.bundle(), expected.bundle()); + assert!(!f.app.document.can_redo()); + } + } + } + } +} + +#[tokio::test] +async fn mounted_mixed_context_help_preserves_draw_session_but_retires_held_release() { + use crate::behavior::canvas_workspace::tests::{Mounted, key}; + use crate::connection_edit::Event as Draw; + use iced::{keyboard::key::Named, mouse}; + for layer in [Layer::Oa, Layer::Sa] { + let mut f = mixed_help(layer); + f.app.window_size = iced::Size::new(2000., 1400.); + f.app.zoom = 1.; + f.app.pan = iced::Vector::ZERO; + f.app.fit_to_view = false; + let mut mounted = Mounted::new(&mut f.app).await; + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let popup = mixed_help_popup(&mut f); + send(&mut f.app, popup); + let session = f.app.connection.as_ref().expect("actual popup armed Draw"); + assert_eq!((session.tool, session.source), (f.tool, Some(f.source))); + assert!(session.candidates.contains(&f.target)); + let context = session.context; + send(&mut f.app, Message::Connection(Draw::Restart(context))); + let session = f.app.connection.as_ref().unwrap(); + assert_eq!((session.tool, session.source), (f.tool, None)); + let context = session.context; + let bounds = mounted + .geometry(&f.app) + .bounds(crate::allocation_input::CANVAS_ID); + let view = project_view(f.app.project(), f.diagram); + let point = |id| { + let node = view.nodes.iter().find(|node| node.element == id).unwrap(); + let point = iced::Point::new( + bounds.x + (node.position.x + node.size.width / 2.) * f.app.zoom + f.app.pan.x, + bounds.y + (node.position.y + node.size.height / 2.) * f.app.zoom + f.app.pan.y, + ); + assert!(bounds.contains(point)); + point + }; + let source = point(f.source); + let target = point(f.target); + let messages = mounted + .batch( + &mut f.app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: source }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + ], + mouse::Cursor::Available(source), + ) + .await; + assert!(messages.iter().any(|m| matches!(m, Message::Connection(Draw::Endpoint(c, id)) if *c == context && *id == f.source))); + let session = f.app.connection.as_ref().unwrap(); + assert_eq!((session.tool, session.source), (f.tool, Some(f.source))); + assert!(session.candidates.contains(&f.target)); + let context = session.context; + // The admitted multi-click tool/source is reading state. Only the held + // widget gesture is retired; a later deliberate click is still valid. + let state = mixed_help_state(&f.app); + mixed_help_document(&f.app, &before, revision); + help_key(&mut mounted, &mut f.app, key(Named::F1)).await; + assert!(f.app.context_help); + assert_eq!(mixed_help_state(&f.app), state); + for message in [ + Message::Connection(Draw::Endpoint(context, f.target)), + Message::Connection(Draw::Restart(context)), + ] { + assert!(iced_runtime::task::into_stream(f.app.update(message)).is_none()); + assert_eq!(mixed_help_state(&f.app), state); + mixed_help_document(&f.app, &before, revision); + } + let release = [ + iced::Event::Mouse(mouse::Event::CursorMoved { position: target }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ]; + let messages = mounted + .batch(&mut f.app, &release, mouse::Cursor::Available(target)) + .await; + assert!(!messages.iter().any(|m| matches!(m, Message::Connection(_)))); + assert_eq!(mixed_help_state(&f.app), state); + mixed_help_document(&f.app, &before, revision); + help_key(&mut mounted, &mut f.app, key(Named::Escape)).await; + assert!(!f.app.context_help); + let messages = mounted + .batch(&mut f.app, &release, mouse::Cursor::Available(target)) + .await; + assert!( + !messages.iter().any(|m| matches!(m, Message::Connection(_))), + "a release after closing Help cannot resume the held press" + ); + assert_eq!(mixed_help_state(&f.app), state); + mixed_help_document(&f.app, &before, revision); + // Observe the real fresh-click message without executing a randomly + // identified creation; existing connection tests own its semantic delta. + let current = f.app.route_context().unwrap(); + let messages = mounted.input( + &f.app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: target }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(target), + ); + let connections: Vec<_> = messages + .iter() + .filter(|m| matches!(m, Message::Connection(_))) + .collect(); + assert_eq!(connections.len(), 1); + assert!( + matches!(connections[0], Message::Connection(Draw::Endpoint(c, id)) if *c == current && *id == f.target) + ); + assert_eq!(mixed_help_state(&f.app), state); + mixed_help_document(&f.app, &before, revision); + } +}