use super::*; #[tokio::test] async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() { use crate::behavior::canvas_workspace::tests::Mounted; use iced::keyboard::{Event as KeyEvent, Modifiers}; let mut f = super::super::direct_tests::fixture(Layer::Oa); f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap(); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1440., 900.); let mut mounted = Mounted::new(&mut app).await; let bounds = mounted.geometry(&app).bounds("process-viewport"); let at = |app: &App, id| { let graph = Graph::new(app.project(), f.owner, id); let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect; bounds.position() + app.behavior.topology.pan + iced::Vector::new(rect.x + 60., rect.y + 20.) * app .behavior .topology .scale(graph.size, (app.window_size.width - 600.).max(200.)) }; let shift = |held| { iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held { Modifiers::SHIFT } else { Modifiers::empty() })) }; let first = at(&app, f.nodes[2]); mounted .batch(&mut app, &[shift(true)], mouse::Cursor::Available(first)) .await; mounted.click(&mut app, first).await; let second = at(&app, f.nodes[0]); mounted.click(&mut app, second).await; assert_eq!( app.multiselect_snapshot().unwrap().targets().len(), 2, "Shift before canvas focus reaches both clicks" ); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); app.busy = true; mounted.settle(&mut app).await; mounted .batch(&mut app, &[shift(false)], mouse::Cursor::Available(second)) .await; app.busy = false; mounted.settle(&mut app).await; let third = at(&app, f.nodes[1]); mounted.click(&mut app, third).await; assert_eq!( app.multiselect_snapshot().unwrap().targets().len(), 1, "Shift released during busy is not resurrected" ); app.busy = true; mounted .batch(&mut app, &[shift(true)], mouse::Cursor::Available(second)) .await; app.busy = false; mounted.settle(&mut app).await; mounted.click(&mut app, second).await; assert_eq!( app.multiselect_snapshot().unwrap().targets().len(), 2, "Shift held through busy survives child lifetime" ); mounted .batch( &mut app, &[iced::Event::Window(iced::window::Event::Unfocused)], mouse::Cursor::Unavailable, ) .await; mounted.click(&mut app, first).await; assert_eq!( app.multiselect_snapshot().unwrap().targets().len(), 1, "real window loss clears held modifiers" ); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); assert!(!app.document.can_undo()); let pan = app.behavior.topology.pan; let source = app.document.bundle().clone(); let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.); let end = blank + iced::Vector::new(-25., -20.); mounted .batch( &mut app, &[ iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }), iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), ], mouse::Cursor::Available(blank), ) .await; assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); assert_eq!(app.behavior.topology.pan, pan); assert_eq!(app.document.bundle(), &source); // Each batch rebuilds the production tree and applies all App messages. // Selection Clear must not retire the camera pan started by its press. assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); mounted .batch( &mut app, &[iced::Event::Mouse(mouse::Event::CursorMoved { position: end, })], mouse::Cursor::Available(end), ) .await; assert_eq!( app.behavior.topology.pan, pan + iced::Vector::new(-25., -20.) ); assert_eq!(app.document.bundle(), &source); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); mounted .batch( &mut app, &[iced::Event::Mouse(mouse::Event::ButtonReleased( mouse::Button::Left, ))], mouse::Cursor::Available(end), ) .await; assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); assert_eq!( app.behavior.topology.pan, pan + iced::Vector::new(-25., -20.) ); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo()); } #[tokio::test] async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera() { use crate::behavior::canvas_workspace::tests::Mounted; for layer in [Layer::Oa, Layer::Sa] { let mut f = super::super::direct_tests::fixture(layer); f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap(); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1440., 900.); let mut mounted = Mounted::new(&mut app).await; let bounds = mounted.geometry(&app).bounds("process-viewport"); let graph = Graph::new(app.project(), f.owner, f.owner); let scale = app .behavior .topology .scale(graph.size, (app.window_size.width - 600.).max(200.)); let at = |id, after| { let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect; bounds.position() + iced::Vector::new( rect.x + 60., rect.y + if after { rect.height - 15. } else { 20. }, ) * scale }; let points = [ at(f.nodes[2], false), at(f.nodes[0], false), at(f.nodes[1], true), ]; drop(graph); mounted .batch( &mut app, &[iced::Event::Keyboard( iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT), )], mouse::Cursor::Available(points[0]), ) .await; mounted.click(&mut app, points[0]).await; mounted.click(&mut app, points[1]).await; mounted .batch( &mut app, &[iced::Event::Keyboard( iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()), )], mouse::Cursor::Available(points[1]), ) .await; let snapshot = app.multiselect_snapshot().unwrap(); let source = app.document.bundle().clone(); let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); // Reuse the existing opt-in raw WGPU evidence convention. These are // actual production-tree pixels, not a reconstructed preview image. let record_feedback = |stage: &str, mounted: &mut Mounted, app: &App, cursor: Point| { let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else { return; }; let path = std::path::PathBuf::from(path); std::fs::create_dir_all(&path).unwrap(); let name = format!("process-{layer:?}-{stage}"); let pixels = mounted.pixels_at(app, mouse::Cursor::Available(cursor)); let width = app.window_size.width as u32; let height = app.window_size.height as u32; assert_eq!(pixels.len(), width as usize * height as usize * 4); let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path.join(format!("{name}.rgba"))) .unwrap(); std::io::Write::write_all(&mut file, &pixels).unwrap(); let metadata = serde_json::json!({ "width": width, "height": height, "format": "RGBA8", "stage": stage, "layer": format!("{layer:?}"), "canvas": [bounds.x, bounds.y, bounds.width, bounds.height], "cursor": [cursor.x, cursor.y], "scale": scale, "camera": [app.behavior.topology.pan.x, app.behavior.topology.pan.y], "selectionCount": snapshot.targets().len(), "selection": format!("{snapshot:?}"), "source": app.document.bundle(), }); let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path.join(format!("{name}.json"))) .unwrap(); std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap()) .unwrap(); assert_eq!(app.document.bundle(), &source); assert_eq!(app.multiselect_snapshot().unwrap(), snapshot); assert!(!app.document.can_undo()); }; record_feedback("before-press", &mut mounted, &app, points[1]); let pressed = mounted .batch( &mut app, &[ iced::Event::Mouse(mouse::Event::CursorMoved { position: points[1], }), iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), ], mouse::Cursor::Available(points[1]), ) .await; assert!( !pressed .iter() .any(|message| matches!(message, Message::DiagramSelection(_))) ); assert_eq!(app.multiselect_snapshot().unwrap(), snapshot); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo()); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); let moved = mounted .batch( &mut app, &[iced::Event::Mouse(mouse::Event::CursorMoved { position: points[2], })], mouse::Cursor::Available(points[2]), ) .await; assert!( !moved .iter() .any(|message| matches!(message, Message::DiagramSelection(_))) ); assert_eq!(app.multiselect_snapshot().unwrap(), snapshot); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo()); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); record_feedback("after-motion", &mut mounted, &app, points[2]); let messages = mounted .batch( &mut app, &[iced::Event::Mouse(mouse::Event::ButtonReleased( mouse::Button::Left, ))], mouse::Cursor::Available(points[2]), ) .await; assert!( messages.iter().any(|m| matches!( m, Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _)) )), "{messages:?}" ); assert_eq!( app.project().elements[&f.owner].steps().unwrap(), &vec![f.nodes[1], f.nodes[0], f.nodes[2]] ); assert_eq!( app.multiselect_snapshot().unwrap().targets(), snapshot.targets() ); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); let changed = app.document.bundle().clone(); let _ = app.update(Message::Undo); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo()); let _ = app.update(Message::Redo); assert_eq!(app.document.bundle(), &changed); } } #[tokio::test] async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() { use crate::behavior::canvas_workspace::{self, tests::Mounted}; use iced::Vector; use iced::advanced::widget; let initial = super::super::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(initial.p); let _ = app.update(Message::Select(initial.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); let mut mounted = Mounted::new(&mut app).await; for layer in [Layer::Oa, Layer::Sa] { for size in [Size::new(1100., 700.), Size::new(1440., 900.)] { let f = super::super::direct_tests::fixture(layer); app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = size; mounted.settle(&mut app).await; let geometry = mounted.geometry(&app); let scope = canvas_workspace::tests::scope(&app); for removed in ["Canvas", "Panels"] { assert!(!geometry.containers.contains_key(&scope.control_id(removed))); } assert!( !canvas_workspace::tests::focused(&mut mounted, &app, "viewport"), "direct editing needs no activation or initial keyboard focus grab" ); for scale in [0.5, 1., 2.] { let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom( context, Some(scale), ))); let geometry = mounted.geometry(&app); let bounds = geometry.bounds("process-viewport"); assert!(bounds.height > 350.); assert!( !geometry .scrolls .contains_key(&widget::Id::new("process-canvas")) ); let graph = Graph::new(app.project(), f.owner, f.owner); let edge = graph.edges.iter().find(|edge| edge.id == f.link).unwrap(); let segment = edge .points .windows(2) .max_by(|a, b| a[0].distance(a[1]).total_cmp(&b[0].distance(b[1]))) .unwrap(); let world = Point::new( (segment[0].x + segment[1].x) / 2., (segment[0].y + segment[1].y) / 2., ); let desired = Vector::new(85., 130.); let next_pan = desired - Vector::new(world.x, world.y) * scale; let delta = next_pan - app.behavior.topology.pan; let start = bounds.position() + Vector::new(130., 70.); let snapshot = app.document.snapshot(); let source = app.document.bundle().clone(); mounted.pan(&mut app, start, start + delta).await; let diff = app.behavior.topology.pan - next_pan; assert!(diff.x.hypot(diff.y) < 0.01); let tool = mounted.geometry(&app).bounds("behavior-tool:OR").center(); mounted.click(&mut app, tool).await; let messages = mounted.click(&mut app, bounds.position() + desired).await; assert!( messages.iter().any( |m| matches!(m, Message::TopologyCanvas(CanvasEvent::Begin { tool: Tool::Control, control: ControlKind::Choice, insertion, .. }) if insertion.link == Some(f.link) && insertion.before.is_none()) ), "{layer:?} {scale}: {messages:?}" ); let invocation = app.behavior.topology.draft.as_ref().unwrap().invocation; let _ = app.update(event(Event::Name(" raw choice 雨 ".into()))); let raw = app.behavior.recovery_snapshot().unwrap(); mounted.settle(&mut app).await; let shown = mounted.geometry(&app); assert_eq!(shown.bounds("process-viewport"), bounds); let actions = shown.bounds("process-actions"); let form = shown.scrolls[&widget::Id::new("process-form")].0; assert!(actions.y >= bounds.y && actions.y + actions.height <= form.y); assert!(form.y + form.height <= bounds.y + bounds.height && form.height > 250.); let pan = app.behavior.topology.pan; mounted .batch( &mut app, &[canvas_workspace::tests::key( iced::keyboard::key::Named::ArrowRight, )], mouse::Cursor::Available(bounds.position() + desired), ) .await; assert_eq!(app.behavior.topology.pan, pan); mounted.scroll(&app, "process-form", 500.); assert_eq!(mounted.geometry(&app).bounds("process-actions"), actions); for shelf in [Shelf::Selection, Shelf::Selection] { let _ = app.update(event(Event::Shelf(shelf))); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); assert_eq!( app.behavior.topology.draft.as_ref().unwrap().invocation, invocation ); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); } let _ = app.update(event(Event::Cancel)); assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo() && !app.document.can_redo()); app.behavior.topology.shelf = None; } } } } #[tokio::test] async fn production_process_canvas_focus_wheel_fit_and_stale_camera_are_presentation_only() { use crate::behavior::canvas_workspace::{ Event as Camera, tests::{Mounted, key}, }; use iced::Vector; let f = super::super::direct_tests::fixture(Layer::Sa); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1100., 700.); let mut mounted = Mounted::new(&mut app).await; let bounds = mounted.geometry(&app).bounds("process-viewport"); let point = bounds.position() + Vector::new(90., 120.); let accepted = app.document.snapshot(); let bundle = app.document.bundle().clone(); mounted .pan(&mut app, point, point + Vector::new(-20., 25.)) .await; let pan = app.behavior.topology.pan; mounted .batch( &mut app, &[key(iced::keyboard::key::Named::ArrowRight)], mouse::Cursor::Available(point), ) .await; assert_eq!(app.behavior.topology.pan, pan + Vector::new(-60., 0.)); let local = point - bounds.position(); let pan = app.behavior.topology.pan; let old_scale = app.behavior.topology.zoom.unwrap_or(1.); let before_world = (local - pan) / old_scale; mounted .batch( &mut app, &[ iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), iced::Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, }), ], mouse::Cursor::Available(point), ) .await; let after_world = (local - app.behavior.topology.pan) / app.behavior.topology.zoom.unwrap(); let diff = after_world - before_world; assert!(diff.x.hypot(diff.y) < 0.001); let context = app.topology_context().unwrap(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))); let fitted = (app.behavior.topology.pan, app.behavior.topology.zoom); let _ = app.update(event(Event::Shelf(Shelf::Selection))); mounted.settle(&mut app).await; app.window_size = Size::new(1440., 900.); mounted.settle(&mut app).await; assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), fitted, "Fit is a one-time camera action" ); for mismatch in 0..3 { let mut stale = app.topology_context().unwrap(); match mismatch { 0 => stale.document = Id::new_v4(), 1 => stale.owner = Id::new_v4(), _ => stale.revision += 1, } let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera( stale, app.camera_epoch, Camera::Pan(Vector::new(400., 400.)), ))); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), fitted ); } app.hosted.visible = true; let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera( app.topology_context().unwrap(), app.camera_epoch, Camera::Pan(Vector::new(30., 40.)), ))); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), fitted ); assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); assert_eq!(app.document.bundle(), &bundle); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_process_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags() { use crate::behavior::canvas_workspace::tests::*; let f = super::super::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1100., 700.); let mut mounted = Mounted::new(&mut app).await; let snapshot = app.document.snapshot(); let source = app.document.bundle().clone(); for boundary in ["Fit", "OR", "Details", "ZoomIn", "Bounds"] { let toolbar = capture_toolbar(&mut mounted, &app, "Fit"); let bounds = mounted.geometry(&app).bounds("process-viewport"); let point = bounds.position() + iced::Vector::new(95., 90.); let lifetime = app.camera_epoch; let pan = app.behavior.topology.pan; let first = point + iced::Vector::new(13., 9.); let last = first + iced::Vector::new(17., -3.); let emitted = mounted .batch( &mut app, &[ iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), iced::Event::Mouse(mouse::Event::CursorMoved { position: first }), iced::Event::Mouse(mouse::Event::CursorMoved { position: last }), ], mouse::Cursor::Available(last), ) .await; assert_eq!(app.behavior.topology.pan, pan + (last - point)); assert_eq!( app.camera_epoch, lifetime, "own ordered deltas retain their lifetime" ); let retained = camera_delta(&emitted); if boundary == "Bounds" { let size = Size::new(app.window_size.width + 90., app.window_size.height + 35.); let task = app.update(Message::WindowResized(size)); mounted.perform(&mut app, task).await; } else { let id = scope(&app).control_id(boundary); let at = mounted.geometry(&app).containers[&id].center(); let clicked = mounted.click(&mut app, at).await; assert!( focused(&mut mounted, &app, boundary), "same-batch pointer activation focuses the resulting live control without F6" ); if boundary == "Fit" { mounted .batch( &mut app, &[key(iced::keyboard::key::Named::Tab)], mouse::Cursor::Unavailable, ) .await; assert!( focused(&mut mounted, &app, "ZoomOut"), "Tab after the real press/release batch continues from Fit" ); assert!(clicked.iter().any(|m| matches!( intent_action(m), Message::TopologyCanvas(CanvasEvent::Zoom(_, None)) ))); assert_eq!( app.behavior.topology.pan, iced::Vector::ZERO, "Fit emitted after this same batch's old pan must still execute" ); } } mounted.settle(&mut app).await; assert_ne!( app.camera_epoch, lifetime, "{boundary} ends the old input lifetime" ); let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); let bounds = mounted.geometry(&app).bounds("process-viewport"); let released = release_middle( &mut mounted, &mut app, bounds.position() + iced::Vector::new(130., 145.), ) .await; assert!(!released.iter().any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(_, _, crate::behavior::canvas_workspace::Event::Pan(delta))) if *delta != iced::Vector::ZERO)), "{boundary} must cancel the retained widget drag"); let task = app.update(retained); mounted.perform(&mut app, task).await; assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera, "{boundary} must reject its pre-boundary App delta" ); let current_lifetime = app.camera_epoch; let task = app.update(toolbar); mounted.perform(&mut app, task).await; assert_eq!( app.camera_epoch, current_lifetime, "{boundary} rejects the retained toolbar lifetime" ); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera, "{boundary} retained Fit cannot alter the camera" ); } assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_process_keyboard_enters_activates_draws_focus_and_returns_to_inspector() { use crate::behavior::canvas_workspace::tests::*; use iced::keyboard::key::Named; let f = super::super::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1100., 700.); let mut mounted = Mounted::new(&mut app).await; let snapshot = app.document.snapshot(); let source = app.document.bundle().clone(); let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); assert!(!focused(&mut mounted, &app, "viewport")); let operations = mounted.operations; mounted .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) .await; assert!( mounted.operations > operations, "entry executes the returned App widget Task" ); assert!(focused(&mut mounted, &app, "viewport")); let mut backwards = key(Named::Tab); if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = &mut backwards { *modifiers = iced::keyboard::Modifiers::SHIFT; } mounted .batch(&mut app, &[backwards], mouse::Cursor::Unavailable) .await; assert!(focused(&mut mounted, &app, "ActualSize")); mounted .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) .await; assert!(focused(&mut mounted, &app, "viewport")); assert_eq!( mounted .geometry(&app) .focused .iter() .filter(|(_, _, focused)| *focused) .count(), 1 ); tab_to(&mut mounted, &mut app, "OR").await; let chosen = mounted .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) .await; assert!(chosen.iter().any(|m| matches!( intent_action(m), Message::TopologyCanvas(CanvasEvent::Choose(..)) ))); assert!( focused(&mut mounted, &app, "OR"), "tool activation retains its keyboard target" ); // The selected palette tool must still have a visibly distinct focus ring. let tool_on = mounted.pixels(&app); let geometry = mounted.geometry(&app); let mut tool_bounds = geometry.containers[&scope(&app).control_id("OR")]; let offset = geometry.scrolls[&scope(&app).control_id("palette-scroll")].1; tool_bounds.x -= offset.x; tool_bounds.y -= offset.y; mounted .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) .await; let tool_off = mounted.pixels(&app); assert_drawn_focus(&tool_off, &tool_on, app.window_size, tool_bounds); mounted .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) .await; // Focus alone changes the visible border of the real production Fit control. let before = mounted.pixels(&app); tab_to(&mut mounted, &mut app, "Fit").await; let id = scope(&app).control_id("Fit"); let bounds = mounted.geometry(&app).containers[&id]; let after = mounted.pixels(&app); assert_drawn_focus(&before, &after, app.window_size, bounds); let fitted = mounted .batch(&mut app, &[key(Named::Space)], mouse::Cursor::Unavailable) .await; assert!(fitted.iter().any(|m| matches!( intent_action(m), Message::TopologyCanvas(CanvasEvent::Zoom(_, None)) ))); assert_eq!(app.behavior.topology.pan, iced::Vector::ZERO); assert!(focused(&mut mounted, &app, "Fit")); tab_to(&mut mounted, &mut app, "viewport").await; assert!(focused(&mut mounted, &app, "viewport")); mounted .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) .await; assert!(!focused(&mut mounted, &app, "viewport")); mounted .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) .await; assert!( mounted .geometry(&app) .focused .iter() .any(|(_, _, focused)| *focused), "ordinary Tab resumes the production Inspector's own entry policy" ); assert!(!focused(&mut mounted, &app, "viewport")); mounted .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) .await; assert!(focused(&mut mounted, &app, "viewport")); assert_eq!( mounted .geometry(&app) .focused .iter() .filter(|(_, _, focused)| *focused) .count(), 1, "explicit canvas entry releases Inspector keyboard ownership" ); mounted .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) .await; assert!(!focused(&mut mounted, &app, "viewport")); assert_eq!( serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), raw ); // Typing after exit belongs to Inspector. Replaying the prior clean tool // action must retain those raw fields, never implicitly Apply them. mounted .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) .await; mounted .batch(&mut app, &[character("x")], mouse::Cursor::Unavailable) .await; assert!(app.draft_dirty()); let dirty_raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); assert_ne!(dirty_raw, raw); let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); for message in chosen { let task = app.update(message); mounted.perform(&mut app, task).await; } let emitted = mounted .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) .await; assert!( !emitted .iter() .any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) ); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); assert_eq!( serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), dirty_raw ); assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_process_caller_return_rejects_its_previous_camera_lifetime() { use crate::behavior::canvas_workspace::tests::*; let f = super::super::direct_tests::fixture(Layer::Oa); let mut project = f.p; let target = project .add_element(Kind::Process, Layer::Oa, "Reusable check", None) .unwrap(); let reference = occurrence( &project, f.owner, "Local use", OccurrenceBody::SequenceReference { sequence: Some(target), }, ); let use_id = reference.id; project = execute_batch(&project, &[Command::CreateElement(reference)]).unwrap(); let mut app = App::with_project(project); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1100., 700.); let mut mounted = Mounted::new(&mut app).await; let source = app.document.bundle().clone(); let snapshot = app.document.snapshot(); let bounds = mounted.geometry(&app).bounds("process-viewport"); let point = bounds.position() + iced::Vector::new(80., 100.); let emitted = mounted .pan(&mut app, point, point + iced::Vector::new(35., -25.)) .await; let retained = camera_delta(&emitted); let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); let toolbar = capture_toolbar(&mut mounted, &app, "Fit"); let task = app.update(Message::TopologyCanvas(CanvasEvent::Open( app.topology_context().unwrap(), use_id, ))); mounted.perform(&mut app, task).await; mounted.settle(&mut app).await; assert_eq!(app.topology_context().unwrap().owner, target); let target_bounds = mounted.geometry(&app).bounds("process-viewport"); let point = target_bounds.position() + iced::Vector::new(70., 90.); mounted .pan(&mut app, point, point + iced::Vector::new(-50., 20.)) .await; let task = app.update(Message::TopologyCanvas(CanvasEvent::Back( app.topology_context().unwrap(), ))); mounted.perform(&mut app, task).await; mounted.settle(&mut app).await; assert_eq!(app.topology_context().unwrap().owner, f.owner); assert_eq!(app.selected, use_id); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); let task = app.update(retained); mounted.perform(&mut app, task).await; assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); let lifetime = app.camera_epoch; let task = app.update(toolbar); mounted.perform(&mut app, task).await; assert_eq!( app.camera_epoch, lifetime, "A to B and Back cannot revive toolbar intent" ); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); mounted .pan(&mut app, point, point + iced::Vector::new(12., -8.)) .await; assert_eq!( app.behavior.topology.pan, camera.0 + iced::Vector::new(12., -8.) ); assert_eq!(app.document.bundle(), &source); assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_review_owners() { use crate::behavior::canvas_workspace::tests::*; use iced::keyboard::key::Named; let f = super::super::direct_tests::fixture(Layer::Oa); let mut project = f.p; let target = project .add_element(Kind::FunctionalChain, Layer::Sa, "Counterpart", None) .unwrap(); let trace = Id::new_v4(); project.traces.insert( trace, Trace { id: trace, alias: "canvas_focus_trace".into(), name: "Review camera ownership".into(), description: String::new(), oa: f.owner, sa: target, kind: TraceKind::Manual, baseline: None, }, ); let mut app = App::with_project(project); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1440., 900.); let mut mounted = Mounted::new(&mut app).await; let source = app.document.bundle().clone(); let snapshot = app.document.snapshot(); let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await; assert!( mounted .input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable) .is_empty(), "a second F6 cannot enter while the first focus operation is pending" ); // Native Iced completes the Chain synchronously. Complete it before a new // keyboard request, retaining the same Apply to challenge its ownership // guard again after it has already observed a valid owner. mounted.operate(&app, &mut *retained_apply); assert!(focused(&mut mounted, &app, "viewport")); let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable); assert_eq!(entry.len(), 1); let delayed = app.update(entry[0].clone()); let draft = app.draft.as_ref().unwrap(); let parent_id = crate::forms::reference_chooser::trigger::field_id(draft.reference_generation(), "/parent"); let parent_bounds = mounted .geometry(&app) .focused .iter() .find(|(id, _, _)| id.as_ref() == Some(&parent_id)) .expect("actual Inspector reference trigger is mounted") .1; let request = draft.reference_open(app.project(), "/parent", parent_bounds); let point = mounted.geometry(&app).bounds("process-viewport").position() + iced::Vector::new(80., 100.); press_middle(&mut mounted, &mut app, point).await; let moved = point + iced::Vector::new(12., 8.); let emitted = mounted .batch( &mut app, &[iced::Event::Mouse(mouse::Event::CursorMoved { position: moved, })], mouse::Cursor::Available(moved), ) .await; let retained = camera_delta(&emitted); let root_inputs = mounted.geometry(&app).text_inputs; let task = app.update(Message::FieldReferenceOpen(request)); mounted.perform(&mut app, task).await; assert!( app.reference_input_active(), "the real App chooser must open" ); let popup_focus = mounted .geometry(&app) .focused .into_iter() .filter(|(_, _, value)| *value) .collect::>(); assert!( popup_focus .iter() .any(|(id, bounds, focused)| id.is_none() && *focused && !root_inputs.contains(bounds)), "the newly mounted nested chooser search, absent from root inputs, must be focused" ); mounted.perform(&mut app, delayed).await; mounted.operate(&app, &mut *retained_apply); assert_eq!( mounted .geometry(&app) .focused .into_iter() .filter(|(_, _, value)| *value) .collect::>(), popup_focus, "both delayed Collect and deliberately retained Apply respect the actual nested overlay owner" ); assert!(!focused(&mut mounted, &app, "viewport")); let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); for key_name in [Named::F6, Named::Tab, Named::ArrowRight] { let emitted = mounted .batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable) .await; assert!( !emitted .iter() .any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) ); } // Tab may dismiss the chooser to its owning field; Escape closes it if still open. if app.reference_input_active() { mounted .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) .await; } assert!(!app.reference_input_active()); let returned_focus = mounted .geometry(&app) .focused .into_iter() .filter(|(_, _, value)| *value) .collect::>(); mounted.operate(&app, &mut *retained_apply); assert_eq!( mounted .geometry(&app) .focused .into_iter() .filter(|(_, _, value)| *value) .collect::>(), returned_focus, "the same owner after chooser Back has a different lifetime; retained Apply must stay inert" ); release_middle(&mut mounted, &mut app, point + iced::Vector::new(50., 35.)).await; let task = app.update(retained); mounted.perform(&mut app, task).await; assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera, "a disabled-and-reenabled canvas cannot resume its pre-chooser pan" ); assert_eq!( serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), raw ); let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await; mounted.operate(&app, &mut *retained_apply); assert!(focused(&mut mounted, &app, "viewport")); let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable); let delayed = app.update( entry .into_iter() .find(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) .unwrap(), ); let task = app.update(Message::StartSemanticReview(trace)); mounted.perform(&mut app, task).await; let facet = FacetSelector::Header(HeaderFacet::Parent); for row in [ crate::semantic_review::RowEvent::Select(facet.clone()), crate::semantic_review::RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), ] { let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { panic!("actual Review dialog opens"); }; let message = state.address_event(row); let task = app.update(Message::Workflow(crate::workflow::Event::semantic(message))); mounted.perform(&mut app, task).await; } let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { panic!("Review remains open"); }; let review = state.recovery_snapshot().unwrap(); let review_id = crate::forms::reference_chooser::trigger::field_id( state .reference_form(app.project()) .unwrap() .reference_generation(), "/parent", ); mounted .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) .await; assert!( mounted .geometry(&app) .focused .iter() .any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused) ); let scrolls = mounted.geometry(&app).scrolls; mounted.perform(&mut app, delayed).await; mounted.operate(&app, &mut *retained_apply); assert_eq!( mounted.geometry(&app).scrolls, scrolls, "a retained Apply must not scroll the new owner" ); for key_name in [Named::F6, Named::ArrowRight] { let emitted = mounted .batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable) .await; assert!( !emitted .iter() .any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) ); } assert!( mounted .geometry(&app) .focused .iter() .any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused) ); let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { panic!("Review retains ownership"); }; assert_eq!(state.recovery_snapshot().unwrap(), review); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); assert_eq!( serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), raw ); assert_eq!(app.document.bundle(), &source); assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_process_form_replacement_resets_scroll_without_a_closed_render() { use crate::behavior::canvas_workspace::tests::Mounted; use iced::advanced::widget; let f = super::super::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1100., 520.); let mut mounted = Mounted::new(&mut app).await; let source = app.document.bundle().clone(); let snapshot = app.document.snapshot(); let bounds = mounted.geometry(&app).bounds("process-viewport"); let point = bounds.position() + iced::Vector::new(65., 80.); mounted .pan(&mut app, point, point + iced::Vector::new(-15., 24.)) .await; let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); let task = app.update(event(Event::Start(f.owner, Tool::Branch, None))); mounted.perform(&mut app, task).await; mounted.settle(&mut app).await; let session = app.behavior.topology.draft.as_ref().unwrap().invocation; mounted.scroll(&app, "process-form", 700.); let offset = mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1; assert!( offset.y > 0., "real production Branch fields must overflow this viewport" ); let task = app.update(event(Event::Name("Preserve this same session".into()))); mounted.perform(&mut app, task).await; assert_eq!( mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1, offset ); assert_eq!( app.behavior.topology.draft.as_ref().unwrap().invocation, session ); let task = app.update(event(Event::Cancel)); mounted.perform(&mut app, task).await; let task = app.update(event(Event::Start(f.owner, Tool::Branch, None))); mounted.perform(&mut app, task).await; assert_ne!( app.behavior.topology.draft.as_ref().unwrap().invocation, session ); // No closed-state App::view was rendered between Cancel and Start. let geometry = mounted.geometry(&app); assert_eq!( geometry.scrolls[&widget::Id::new("process-form")].1, iced::Vector::ZERO ); assert_eq!(geometry.bounds("process-viewport"), bounds); assert_eq!( (app.behavior.topology.pan, app.behavior.topology.zoom), camera ); assert_eq!(app.document.bundle(), &source); assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes() { use crate::behavior::canvas_workspace::tests::*; let f = super::super::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1440., 900.); let mut mounted = Mounted::new(&mut app).await; let snapshot = app.document.snapshot(); let source = app.document.bundle().clone(); retained_toolbar_boundaries(&mut mounted, &mut app, "OR").await; assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo() && !app.document.can_redo()); } #[tokio::test] async fn production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool() { use crate::behavior::canvas_workspace::tests::*; let f = super::super::direct_tests::fixture(Layer::Oa); let mut app = App::with_project(f.p); let _ = app.update(Message::Select(f.owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); app.window_size = Size::new(1100., 420.); let mut mounted = Mounted::new(&mut app).await; let snapshot = app.document.snapshot(); let source = app.document.bundle().clone(); overflow_last_tool(&mut mounted, &mut app, "->").await; assert_eq!(app.behavior.topology.tool, CanvasTool::Connect); assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo() && !app.document.can_redo()); }