diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c5a7a68..060952bc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3593,2378 +3593,4 @@ mod datetime_tests; mod inbox_tests; #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use super::*; - use egui_kittest::kittest::Queryable; - - fn shape_rot(app: &SlideApp) -> f64 { - let d = app.session.active().unwrap(); - let id = d.selection.shapes[0]; - d.shape(id).unwrap().xfrm.unwrap().rot - } - - /// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; - /// letting go is one undo step. Off the canvas it does nothing. - #[test] - fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); - let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); - harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); - harness.run_steps(1); - let canvas = harness.state().canvas_rect.expect("canvas"); - let outside = egui::pos2(1.0, 1.0); - assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); - - harness.input_mut().events.push(egui::Event::PointerMoved(outside)); - harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); - harness.run_steps(1); - assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); - - harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); - harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); - harness.run_steps(1); - harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); - harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); - harness.run_steps(1); - assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); - harness.run_steps(1); - assert!(harness.state().session.active().unwrap().interaction.is_none()); - harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); - assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); - } - - /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). - #[test] - fn web_export_all_slides_gives_one_file_per_slide() { - let mut doc = deckcraft_model::Presentation::default(); - if let Some(first) = doc.slides.first().cloned() { - doc.slides.push(first.clone()); - doc.slides.push(first); - } - assert_eq!(doc.slides.len(), 3); - let all = web_image_files(&doc, 1, true, "Deck", "png", false); - let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); - assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); - let one = web_image_files(&doc, 1, false, "Deck", "png", false); - assert_eq!(one.len(), 1); - assert_eq!(one[0].0, "Deck.png"); - } - - /// The web PNG download honours "Transparent background" (#784): the slide background is - /// left out, so the corner is transparent; a JPEG download stays opaque. - #[test] - fn web_png_export_can_leave_the_background_transparent() { - use std::cell::RefCell; - use std::rc::Rc; - let got: Rc)>>> = Rc::default(); - let log = got.clone(); - let services = Services { - download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))), - ..Default::default() - }; - let mut app = SlideApp::new(Session::with_new(), services); - let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3]; - assert!(app.export_as("png", json!({}))); - assert!(app.export_as("png", json!({"transparentBackground": true}))); - assert!(app.export_as("jpeg", json!({"transparentBackground": true}))); - let got = got.borrow(); - assert_eq!(got.len(), 3); - assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default"); - assert_eq!(corner_alpha(&got[1].1), 0, "transparent background"); - assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); - } - - fn title_text(app: &SlideApp) -> String { - let d = app.session.active().unwrap(); - let sh = &d.doc.slides[0].shapes[0]; - sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() - } - - #[test] - fn quick_access_toolbar_adds_removes_resets_and_persists() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let ids = |app: &SlideApp| app.ui.qat.iter().map(|q| q.id.clone()).collect::>(); - assert_eq!(ids(harness.state()), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); - let app = harness.state_mut(); - app.run("app.qat", json!({"add": "insert.table", "label": "Table", "icon": "table"})).unwrap(); - app.run("app.qat", json!({"add": "insert.table"})).unwrap(); - app.run("app.qat", json!({"add": "view.pane", "params": {"pane": "selection"}, "label": "Selection Pane"})).unwrap(); - assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); - app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); - assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); - assert_eq!(app.ui.qat[5].label, "Table"); - // Saved with the rest of the UI state and read back. - let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); - assert_eq!(back.qat, app.ui.qat); - harness.run_steps(2); - let app = harness.state_mut(); - app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); - assert_eq!(app.ui.qat.len(), 6); - app.run("app.qat", json!({"reset": true})).unwrap(); - assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); - // An older settings file without the toolbar gets the defaults. - let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); - assert_eq!(old.qat.len(), 6); - } - - #[test] - fn opening_a_file_with_missing_fonts_shows_a_toast_once() { - use egui_kittest::kittest::Queryable; - let mut p = deckcraft_model::Presentation::default(); - let slide = std::sync::Arc::make_mut(&mut p.slides[0]); - let mut body = deckcraft_model::text::TextBody::from_text("Hello"); - body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into()); - slide.shapes[0].text = Some(body); - let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap()); - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - assert!(harness.state().toast.is_none(), "a new presentation has nothing to say"); - harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap(); - harness.run_steps(2); - let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default(); - assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}"); - assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some()); - // Closed, it stays closed for that document. - harness.get_by_label("Close").click(); - harness.run_steps(2); - assert!(harness.state().toast.is_none()); - harness.run_steps(2); - assert!(harness.state().toast.is_none()); - } - - /// #115: Change Picture replaced nothing — it inserted a second picture. - #[test] - fn change_picture_replaces_the_selected_picture() { - let svg = |c: &str| { - format!("").into_bytes() - }; - let media = |app: &SlideApp| { - let d = app.session.active().unwrap(); - let pics: Vec<_> = d.doc.slides[0] - .shapes - .iter() - .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) - .collect(); - assert_eq!(pics.len(), 1, "one picture, replaced in place"); - pics[0] - }; - let insert = |app: &mut SlideApp| { - let id = - app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() - ["id"] - .clone(); - app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); - media(app) - }; - - // Desktop: the file dialog returns a path. - let blue = svg("blue"); - let services = Services { - pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), - read: Some(Box::new(move |_: &str| Ok(blue.clone()))), - ..Default::default() - }; - let mut app = SlideApp::new(Session::with_new(), services); - let before = insert(&mut app); - app.run("app.changePictureDialog", json!({})).unwrap(); - assert_ne!(media(&app), before); - - // Web: the browser picker delivers the file through the inbox later. - let inbox = Inbox::default(); - let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let record = started.clone(); - let services = Services { - open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), - inbox: Some(inbox.clone()), - ..Default::default() - }; - let mut app = SlideApp::new(Session::with_new(), services); - let before = insert(&mut app); - app.run("app.changePictureDialog", json!({})).unwrap(); - let id = started.lock().unwrap()[0]; - inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") })); - app.drain_inbox(); - assert_ne!(media(&app), before); - } - - /// #17: Home created a new blank presentation instead of going to the start screen. - #[test] - fn home_shows_the_start_screen_over_the_open_presentation() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - // The app keeps requesting repaints, so step frames instead of run(). - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - - harness.state_mut().run("app.home", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().backstage_open()); - assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation"); - - // Typing on the start screen doesn't reach the presentation behind it; Escape goes back. - harness.event(egui::Event::Text("a".into())); - harness.key_press(egui::Key::Backspace); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), ""); - assert!(!harness.state().session.active().unwrap().is_dirty()); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(!harness.state().backstage_open()); - - // Home again toggles; picking a theme on the start screen (a new presentation) leaves it. - harness.state_mut().run("app.home", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().backstage_open()); - harness.state_mut().run("app.home", json!({})).unwrap(); - assert!(!harness.state().backstage_open()); - harness.state_mut().run("app.home", json!({"on": true})).unwrap(); - harness.state_mut().run("file.new", json!({})).unwrap(); - harness.run_steps(2); - assert!(!harness.state().backstage_open(), "a new presentation leaves Home"); - assert_eq!(harness.state().session.documents().len(), 2); - } - - /// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real - /// egui key events (Windows keys). - #[test] - fn ctrl_word_keys_edit_text() { - use egui::{Key, Modifiers}; - let mut session = Session::with_new(); - session.mac_keys = false; - session.prefs.autocorrect = false; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(session, Services::default()), - ); - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.event(egui::Event::Text("Hello, world. Foo bar".into())); - harness.run_steps(1); - assert_eq!(title_text(harness.state()), "Hello, world. Foo bar"); - let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap(); - let ctrl = Modifiers::COMMAND; - let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT; - - // Ctrl+Left stops at every word and punctuation run, as in PowerPoint. - let mut stops = vec![]; - for _ in 0..6 { - harness.key_press_modifiers(ctrl, Key::ArrowLeft); - harness.run_steps(1); - stops.push(caret(&harness).caret.1); - } - assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]); - harness.key_press_modifiers(ctrl, Key::ArrowRight); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (0, 5)); - - // Ctrl+Shift+Right selects to the next stops. - harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); - harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); - harness.run_steps(1); - let t = caret(&harness); - assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12))); - - // Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one - // before the caret. - harness.key_press_modifiers(ctrl, Key::Delete); - harness.run_steps(1); - assert_eq!(title_text(harness.state()), "Helloworld. Foo bar"); - harness.key_press(Key::End); - harness.key_press_modifiers(ctrl, Key::Backspace); - harness.run_steps(1); - assert_eq!(title_text(harness.state()), "Helloworld. Foo "); - - // Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends. - harness.key_press(Key::Enter); - harness.event(egui::Event::Text("Next".into())); - harness.key_press_modifiers(ctrl, Key::ArrowUp); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (1, 0)); - harness.key_press_modifiers(ctrl, Key::ArrowUp); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (0, 0)); - harness.key_press_modifiers(ctrl, Key::ArrowDown); - harness.run_steps(1); - assert_eq!(caret(&harness).caret, (1, 0)); - harness.key_press_modifiers(ctrl_shift, Key::Home); - harness.run_steps(1); - let t = caret(&harness); - assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0))); - - // Ctrl+Shift+> (egui reports the Period key) grows the selected text's font. - let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64(); - let before = size(&mut harness); - harness.key_press_modifiers(ctrl_shift, Key::Period); - harness.run_steps(1); - let after = size(&mut harness).unwrap(); - assert!(after > before.unwrap_or(0.0), "{before:?} → {after}"); - harness.key_press_modifiers(ctrl, Key::OpenBracket); - harness.run_steps(1); - assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks"); - } - - /// Export… had no visible control: only the command palette reached it. - #[test] - fn title_bar_export_opens_the_export_dialog() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - // Quick access: Home, Save, Export, each 24 points wide with 2 between, from x = 10. - let pos = egui::pos2(10.0 + 2.0 * 26.0 + 12.0, ribbon::TITLE_H / 2.0); - harness.hover_at(pos); - harness.run_steps(1); - harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); - harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); - harness.run_steps(2); - assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); - } - - /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and - /// swallows the key press, so only the V release reaches the app. - #[test] - fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { - let session = Session::with_new(); - let png = { - let d = session.active().unwrap(); - deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 - }; - assert!(!png.is_empty()); - let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(session, services), - ); - harness.run_steps(2); - let pictures = |app: &SlideApp| { - let d = app.session.active().unwrap(); - d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() - }; - let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; - // A text paste (Paste event on the press) does not also paste the image on release. - harness.event(egui::Event::Paste("hello".into())); - harness.run_steps(2); - harness.event(v(false)); - harness.run_steps(2); - assert_eq!(pictures(harness.state()), 0); - // A V release without Command/Ctrl is not a paste. - harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); - harness.run_steps(2); - assert_eq!(pictures(harness.state()), 0); - // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. - harness.event(v(false)); - harness.run_steps(2); - assert_eq!(pictures(harness.state()), 1); - } - - /// #78: after Tab in a text box, typing and deleting no longer reached the text. - #[test] - fn typing_goes_on_after_tab_in_a_text_box() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - { - let mut session = Session::with_new(); - session.prefs.autocorrect = false; - SlideApp::new(session, Services::default()) - }, - ); - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.event(egui::Event::Text("ab".into())); - harness.run_steps(2); - harness.key_press(egui::Key::Tab); - harness.run_steps(2); - harness.event(egui::Event::Text("c".into())); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "ab\tc"); - harness.key_press(egui::Key::Backspace); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "ab\t"); - assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); - // Tab at the start of a paragraph indents it, and typing goes on there too (#93). - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - harness.key_press(egui::Key::Tab); - harness.run_steps(2); - harness.event(egui::Event::Text("d".into())); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "ab\t\nd"); - let level = |h: &egui_kittest::Harness| { - h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level - }; - assert_eq!(level(&harness), 1); - } - - /// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus - /// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the - /// list could neither be typed into nor continued with Enter. - #[test] - fn tab_in_an_edited_list_keeps_the_keyboard() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.event(egui::Event::Text("one".into())); - harness.run_steps(2); - harness.key_press(egui::Key::Enter); - harness.run_steps(2); - harness.key_press(egui::Key::Tab); // Increase List Level - harness.run_steps(2); - harness.event(egui::Event::Text("sub".into())); - harness.run_steps(2); - harness.key_press(egui::Key::Enter); // continues the list - harness.run_steps(2); - harness.event(egui::Event::Text("two".into())); - harness.run_steps(2); - assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text"); - let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap(); - let levels: Vec = body.paragraphs.iter().map(|p| p.level).collect(); - assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level"); - } - - #[test] - fn home_needs_an_open_presentation() { - let mut app = SlideApp::new(Session::new(), Services::default()); - app.run("app.home", json!({})).unwrap(); - assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); - } - - /// View › New Window is a second window on the open presentation. Its slide is its own; - /// the editor's selection stays where it was. Closing the window leaves the document open. - #[test] - fn new_window_shows_the_same_document_on_its_own_slide() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("slide.go", &json!({"index": 1})).unwrap(); - let err = { - let mut empty = SlideApp::new(Session::new(), Services::default()); - empty.run("view.newWindow", json!({})).unwrap_err() - }; - assert!(err.contains("presentation"), "{err}"); - - let name = app.session.active().unwrap().title(); - let opened = app.run("view.newWindow", json!({})).unwrap(); - assert_eq!(opened["open"], true); - assert_eq!(opened["window"], 2); - assert_eq!(opened["slide"], 1); - assert_eq!(opened["title"], format!("{name}:2")); - assert_eq!(app.session.active().unwrap().selection.slide, 1); - - let moved = app.run("view.newWindow", json!({"delta": -1})).unwrap(); - assert_eq!(moved["slide"], 0); - assert_eq!(app.doc_window.as_ref().unwrap().slide, 0); - assert_eq!(app.session.active().unwrap().selection.slide, 1, "the editor stays on its slide"); - - let again = app.run("view.newWindow", json!({})).unwrap(); - assert_eq!(again["window"], 2); - assert_eq!(again["slide"], 0, "opening again keeps the window's slide"); - - app.session.execute("file.close", &json!({})).unwrap(); - doc_window::show(&mut app, &egui::Context::default()); - assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); - assert!(app.session.active().is_none()); - } - - #[test] - fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); - let v = - app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); - assert_eq!(v["spacing"], 72.0); - assert_eq!(v["snap"], false); - assert_eq!(v["smartGuides"], false); - assert_eq!(v["gridlines"], true); - assert_eq!(v["guides"], true); - assert!(app.dialog.is_none()); - assert!(app.ui.gridlines && app.ui.guides); - assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); - - app.run("view.gridOptions", json!({"snap": true})).unwrap(); - assert!(app.session.prefs.snap_to_grid); - assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); - assert!(app.dialog.is_none()); - - for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { - assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); - } - assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); - assert!(app.session.prefs.snap_to_grid); - - app.run("view.gridOptions", json!({})).unwrap(); - assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); - assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); - } - - #[test] - fn grid_options_dialog_lists_spacing_and_display() { - use egui_kittest::kittest::Queryable; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - harness.state_mut().run("view.gridOptions", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.query_by_label_contains("Display grid on screen").is_some()); - assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); - assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); - let show_grid = harness - .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") - .next() - .unwrap_or_else(|| panic!("no grid checkbox")); - show_grid.click_accesskit(); - harness.run_steps(2); - harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); - assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); - assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); - - harness.state_mut().run("view.gridOptions", json!({})).unwrap(); - harness.run_steps(2); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") - .next() - .unwrap_or_else(|| panic!("no Cancel button")) - .click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); - assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); - assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); - } - - /// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog - /// changed while it was open; Done keeps it. - #[test] - fn preferences_cancel_restores_the_settings_done_keeps_them() { - use egui_kittest::kittest::Queryable; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let before = harness.state().session.prefs.clone(); - let dark_before = harness.state().ui.brightness; - let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| { - harness.state_mut().run("app.preferences", json!({})).unwrap(); - harness.run_steps(2); - for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] { - harness - .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label) - .next() - .unwrap_or_else(|| panic!("no {label} checkbox")) - .click_accesskit(); - harness.run_steps(2); - } - assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance"); - assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides); - }; - let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| { - let s = harness.state(); - assert!(s.dialog.is_none(), "{how} closes Preferences"); - assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance"); - assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides"); - assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid"); - }; - - change(&mut harness); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") - .next() - .unwrap_or_else(|| panic!("no Cancel button")) - .click_accesskit(); - harness.run_steps(2); - unchanged(&harness, "Cancel"); - - change(&mut harness); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - unchanged(&harness, "Esc"); - - change(&mut harness); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Done") - .next() - .unwrap_or_else(|| panic!("no Done button")) - .click_accesskit(); - harness.run_steps(2); - let s = harness.state(); - assert!(s.dialog.is_none(), "Done closes Preferences"); - assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance"); - assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides"); - assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid"); - } - - /// About dialog: Close and Cancel each close the dialog. - #[test] - fn about_dialog_close_and_cancel_close_it() { - use egui_kittest::kittest::Queryable; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - harness.state_mut().run("app.about", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().dialog.is_some()); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") - .next() - .unwrap_or_else(|| panic!("no Cancel button in About dialog")) - .click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Cancel closes About dialog"); - - harness.state_mut().run("app.about", json!({})).unwrap(); - harness.run_steps(2); - assert!(harness.state().dialog.is_some()); - harness - .query_all_by_role_and_label(egui::accesskit::Role::Button, "Close") - .next() - .unwrap_or_else(|| panic!("no Close button in About dialog")) - .click_accesskit(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Close closes About dialog"); - } - - fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let app = harness.state_mut(); - // Zoomed out, the canvas shows plenty of pasteboard around the slide. - app.ui.zoom = Some(0.4); - app.session.prefs.snap_to_grid = false; - app.session.prefs.smart_guides = false; - harness - } - - fn selected(app: &SlideApp) -> Vec { - app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect() - } - - fn shape_x(app: &SlideApp, id: u64) -> f64 { - let st = app.session.active().unwrap(); - st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x - } - - /// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where - /// they can be selected and moved. - #[test] - fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() { - let _gpu = crate::gpu_lock::gpu_lock(); - let mut harness = canvas_harness(); - let off = - harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"] - .as_u64() - .unwrap(); - harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap(); - harness.run_steps(3); - - let app = harness.state(); - let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale); - // The texture reaches past the slide's right edge to the shape's, and no further left/up. - assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}"); - assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}"); - let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k); - assert!(app.canvas_rect.unwrap().contains(centre)); - - // Drawn at full strength on the pasteboard (when a GPU renderer is available here). - if let Ok(img) = harness.render() { - let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0; - let shape = px(centre); - let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k)); - let doc = &harness.state().session.active().unwrap().doc; - let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1); - let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4; - assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}"); - assert_ne!(shape, board); - } - - // Click selects it. - harness.hover_at(centre); - harness.run_steps(1); - harness.drag_at(centre); - harness.run_steps(1); - harness.drop_at(centre); - harness.run_steps(2); - assert_eq!(selected(harness.state()), vec![off]); - - // Drag moves it, further off the slide. - let to = centre + egui::vec2(40.0, 0.0); - harness.hover_at(centre); - harness.run_steps(1); - harness.drag_at(centre); - harness.run_steps(1); - for i in 1..=4 { - harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0)); - harness.run_steps(1); - } - harness.drop_at(to); - harness.run_steps(2); - let x = shape_x(harness.state(), off); - assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}"); - // The pasteboard followed it. - let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap()); - assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}"); - - // Idle frames don't render the canvas again. - let n = harness.state().textures.canvas_renders; - harness.run_steps(3); - assert_eq!(harness.state().textures.canvas_renders, n); - } - - /// A slide with nothing off it renders just the slide, as before. - #[test] - fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() { - let mut harness = canvas_harness(); - harness.run_steps(3); - let app = harness.state(); - let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap()); - assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}"); - let n = app.textures.canvas_renders; - harness.run_steps(3); - assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); - } - - /// #431: the ribbon's Copy and Cut, the slide's context menu, the menus and the control - /// channel all run the commands; they put the text on the system clipboard, as Ctrl+C and - /// Ctrl+X do. - #[test] - fn copy_and_cut_commands_reach_the_system_clipboard() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let copied = |h: &egui_kittest::Harness<'_, SlideApp>| { - h.output().platform_output.commands.iter().find_map(|c| match c { - egui::OutputCommand::CopyText(t) => Some(t.clone()), - _ => None, - }) - }; - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.state_mut().session.type_text("Aurora").unwrap(); - harness.state_mut().session.key("Escape", Mods::default()).unwrap(); - harness.run_steps(1); - assert_eq!(copied(&harness), None); - - harness.state_mut().run("edit.copy", json!({})).unwrap(); - harness.run_steps(1); - assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Copy puts the text on the clipboard"); - harness.run_steps(1); - assert_eq!(copied(&harness), None, "only once"); - - harness.state_mut().run("edit.cut", json!({})).unwrap(); - harness.run_steps(1); - assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Cut does too"); - assert!(harness.state().session.active().unwrap().doc.slides[0].shapes.iter().all(|s| s.id.0 != title), "and removes the shape"); - } - - /// #182: Ctrl+C then Ctrl+V did nothing for objects without text (a rectangle), because - /// egui-winit drops Ctrl+V when the system clipboard is empty; the context menu worked. - #[test] - fn copy_then_paste_shortcut_duplicates_an_object_without_text() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); - let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len(); - let before = count(&harness); - - // What the copy leaves on the system clipboard is what egui-winit hands back on Ctrl+V. - assert_eq!(system_clipboard_text(""), OBJECTS_MARKER); - assert_eq!(system_clipboard_text("hello"), "hello"); - harness.event(egui::Event::Copy); - harness.run_steps(2); - harness.event(egui::Event::Paste(OBJECTS_MARKER.to_string())); - harness.run_steps(2); - assert_eq!(count(&harness), before + 1, "the shortcut pastes a copy of the rectangle"); - let d = harness.state().session.active().unwrap(); - let pasted = d.doc.slides[0].shapes.last().unwrap(); - assert!(pasted.text.as_ref().is_none_or(|t| t.text().is_empty()), "a copy of the rectangle, not a text box holding the marker"); - } - - /// #188: Ctrl/Cmd+A with the slide thumbnail pane focused selects every slide; with the - /// canvas focused it still selects shapes. - #[test] - fn select_all_follows_the_focused_pane() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - for _ in 0..2 { - harness.state_mut().run("slide.new", json!({})).unwrap(); - } - harness.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - harness.run_steps(3); - let click = |h: &mut egui_kittest::Harness<'_, SlideApp>, pos: egui::Pos2| { - h.event(egui::Event::PointerMoved(pos)); - for pressed in [true, false] { - h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); - } - h.run_steps(2); - }; - // Press in the thumbnail pane, then Cmd+A. - let w = harness.state().ui.thumbs_width; - click(&mut harness, egui::pos2(w / 2.0, 300.0)); - assert!(harness.state().slides_focused); - harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - harness.run_steps(2); - let st = harness.state().session.active().unwrap(); - assert_eq!(st.selection.slides.len(), 3, "every slide is selected"); - - // A press on the canvas moves focus away: Cmd+A selects shapes again. - click(&mut harness, egui::pos2(700.0, 400.0)); - assert!(!harness.state().slides_focused); - harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); - harness.run_steps(2); - let st = harness.state().session.active().unwrap(); - assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); - } - - fn harness() -> egui_kittest::Harness<'static, SlideApp> { - egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::new(), Services::default()), - ) - } - - /// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws - /// the open error right there (the status bar is hidden while the backstage is open, so the - /// message used to vanish). - #[test] - fn missing_recent_is_marked_and_its_error_shows() { - let mut harness = harness(); - let missing = "/deckcraft-tests/172/missing.deckcraft"; - harness.state_mut().ui.recent = vec![missing.to_string()]; - harness.run_steps(2); - // The row is labelled with the file's name. Clicking it raises the open error, and the - // drawn message is a second node that mentions the name (the OS text after it varies). - harness.get_by_label("missing.deckcraft").click(); - harness.run_steps(2); - // The file is read in the background (#350): wait for that read to finish. - for _ in 0..500 { - if harness.state().opening.is_none() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - harness.run_steps(1); - } - harness.run_steps(2); - assert!(harness.state().toast.is_some(), "the open error was raised"); - assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); - } - - /// #172: a Recent entry can be removed from the list. - #[test] - fn recent_entry_can_be_removed() { - let mut harness = harness(); - harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; - harness.run_steps(2); - let removes = harness.get_all_by_label("Remove from Recent").collect::>(); - assert_eq!(removes.len(), 2); - // The remove button must sit inside the visible part of the row even when the page's - // content is wider than the window. - assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); - removes[1].click(); - harness.run_steps(1); - assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); - } - - /// The existence probe tells present files from missing ones and never touches remote paths. - #[test] - fn recent_exists_tells_missing_from_present() { - let mut app = SlideApp::new(Session::new(), Services::default()); - let mut path = std::env::temp_dir(); - path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); - std::fs::write(&path, b"").unwrap(); - assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); - assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); - assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); - std::fs::remove_file(&path).ok(); - } - - /// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. - #[test] - fn the_language_dialog_marks_the_selected_text() { - use egui_kittest::kittest::Queryable; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); - harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); - harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); - // The anchored window settles over its first frames; click once it stays put. - harness.run_steps(4); - harness.get_by_label("Français (France)").click(); - harness.run_steps(2); - harness.get_by_label("OK").click(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - let d = harness.state().session.active().unwrap(); - let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); - assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); - } - - /// Review > Language > Whole presentation marks every text box, not only the selected one. - #[test] - fn the_language_dialog_marks_the_whole_presentation() { - use egui_kittest::kittest::Queryable; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let s = &mut harness.state_mut().session; - let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; - s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); - s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); - s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap(); - s.execute("text.selectAll", &json!({})).unwrap(); - harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); - harness.run_steps(4); - harness.get_by_label("Whole presentation").click(); - harness.run_steps(2); - harness.get_by_label("Español (España)").click(); - harness.run_steps(2); - harness.get_by_label("OK").click(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - let d = harness.state().session.active().unwrap(); - for sl in &d.doc.slides { - let body = sl.shapes[0].text.as_ref().unwrap(); - assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); - } - } - - /// With nothing selected, Review > Language applies to the whole presentation without - /// choosing it, and Selected text can't be picked. - #[test] - fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() { - use egui_kittest::kittest::{NodeT, Queryable}; - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let s = &mut harness.state_mut().session; - let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; - s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); - s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); - s.execute("edit.select", &json!({"ids": []})).unwrap(); - assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty()); - harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); - harness.run_steps(4); - assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled()); - harness.get_by_label("Español (España)").click(); - harness.run_steps(2); - harness.get_by_label("OK").click(); - harness.run_steps(2); - assert!(harness.state().dialog.is_none()); - let d = harness.state().session.active().unwrap(); - for sl in &d.doc.slides { - let body = sl.shapes[0].text.as_ref().unwrap(); - assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); - } - } - - /// Speaker notes keep their formatting when edited in the notes pane: typing into a bold - /// heading, Enter in a bulleted point, then saving as .pptx and reopening. - #[test] - fn editing_notes_in_the_pane_keeps_their_formatting() { - use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; - let point = |runs: Vec| Paragraph { - level: 1, - props: ParaProps { - margin_left: Some(22.5), - indent: Some(-22.5), - bullet: Some(Bullet::Char { char: "§".into() }), - bullet_font: Some("Wingdings".into()), - ..Default::default() - }, - runs, - ..Default::default() - }; - let bold = RunProps { bold: Some(true), ..Default::default() }; - let notes = TextBody { - paragraphs: vec![ - Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, - Paragraph::new("Žiaci pracujú vo dvojiciach."), - point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), - point(vec![Run::new("Reflexia")]), - Paragraph::new("Pomôcky: počítač"), - ], - ..Default::default() - }; - let mut session = Session::with_new(); - session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(session, Services::default()), - ); - harness.run_steps(2); - let id = egui::Id::unique("notes_edit"); - let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { - let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); - egui::TextEdit::store_state(&h.ctx, id, st); - }; - let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); - let text = notes.text(); - harness.ctx.memory_mut(|m| m.request_focus(id)); - harness.run_steps(1); - // Type into the bold heading. - caret(&mut harness, chars(&text, "prehľad")); - harness.run_steps(1); - harness.event(egui::Event::Text(" hodiny".into())); - harness.run_steps(1); - // Enter after a bulleted point, and type the next one. - let text = text.replacen("prehľad", "prehľad hodiny", 1); - caret(&mut harness, chars(&text, "cyklus")); - harness.run_steps(1); - harness.key_press(egui::Key::Enter); - harness.event(egui::Event::Text("Nový bod".into())); - harness.run_steps(1); - // Leave the field: the notes are saved. - harness.ctx.memory_mut(|m| m.surrender_focus(id)); - harness.run_steps(2); - let check = |n: &TextBody| { - assert_eq!( - n.text(), - "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" - ); - assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); - assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); - assert_eq!(n.paragraphs[1].props, ParaProps::default()); - assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); - assert_eq!(n.paragraphs[2].runs[1].props.bold, None); - for i in [2, 3, 4] { - assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); - } - assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); - }; - check(&harness.state().session.active().unwrap().doc.slides[0].notes); - // Saved as .pptx and reopened. - let s = &mut harness.state_mut().session; - let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); - s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); - check(&s.active().unwrap().doc.slides[0].notes); - } - - fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), services), - ); - h.run_steps(2); - h - } - - /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. - #[test] - fn copy_offers_html_and_paste_keeps_bold() { - use std::cell::RefCell; - use std::rc::Rc; - let seen = Rc::new(RefCell::new(None)); - let record = seen.clone(); - let services = Services { - clipboard_set_html: Some(Box::new(move |html, plain| { - *record.borrow_mut() = Some((html.to_string(), plain.to_string())); - true - })), - ..Default::default() - }; - let mut h = step_app(services); - let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; - h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); - h.event(egui::Event::Text("Hi".into())); - h.run_steps(1); - h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); - h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); - h.event(egui::Event::Copy); - h.run_steps(1); - let (html, plain) = seen.borrow().clone().expect("html offered"); - assert!(html.contains(""), "{html}"); - assert_eq!(plain, "Hi"); - assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); - - let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; - let mut h = step_app(services); - h.event(egui::Event::Paste("Hi".into())); - h.run_steps(1); - let st = h.state().session.active().unwrap(); - let body = st - .current_slide() - .unwrap() - .shapes - .iter() - .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) - .and_then(|sh| sh.text.clone()) - .expect("pasted text"); - assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); - } - - /// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can - /// still paste it here (#182). - #[test] - fn a_textless_shape_copies_as_the_objects_marker() { - let mut h = step_app(Services::default()); - h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); - h.event(egui::Event::Copy); - h.run_steps(1); - let cmds = &h.output().platform_output.commands; - assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); - } - - fn redder(px: [u8; 4]) -> i32 { - px[0] as i32 - px[1].max(px[2]) as i32 - } - - /// A fade entrance is hidden at the start of the preview clock and opaque once it has played. - #[test] - fn preview_hides_an_entrance_until_it_plays() { - let mut session = Session::with_new(); - let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); - session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); - session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); - session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); - let doc = session.active().unwrap().doc.clone(); - let tl = deckcraft_anim::ShowState::timeline(&doc, 0); - let shape = deckcraft_model::ShapeId(text as u32); - let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); - assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); - let mid = para(0.25, 0); - assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); - assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); - - session.execute("animation.clear", &json!({})).unwrap(); - let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); - session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); - session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); - let doc = session.active().unwrap().doc.clone(); - let tl = deckcraft_anim::ShowState::timeline(&doc, 0); - let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); - let paint = |time: Option| { - let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); - let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; - let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; - deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) - }; - let rest = paint(None); - let start = paint(Some(0.0)); - let end = paint(Some(tl.total_duration() + 0.05)); - assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); - assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); - assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); - } - - #[test] - fn animation_preview_plays_on_the_canvas_then_stops() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let none = harness.state_mut().run("animation.preview", json!({})).unwrap(); - assert_eq!(none["playing"], false); - assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); - assert!(harness.state().show.is_none()); - - let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] - .as_u64() - .unwrap(); - harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); - let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); - assert_eq!(started["playing"], true); - assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); - harness.run_steps(1); - let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); - assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); - let renders = harness.state().textures.canvas_renders; - harness.run_steps(6); - let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); - assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); - assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); - assert!(harness.state().show.is_none()); - - harness.key_press(egui::Key::Escape); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); - - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_some()); - harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); - assert!(harness.state().anim_preview.is_none()); - - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - let centre = harness.state().slide_rect.unwrap().center(); - harness.hover_at(centre); - harness.run_steps(1); - harness.drag_at(centre); - harness.run_steps(2); - assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); - - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); - - harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); - harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); - harness.state_mut().run("animation.preview", json!({})).unwrap(); - harness.run_steps(1); - assert!(harness.state().anim_preview.is_some()); - // 400 ms effect plus the hold. - harness.run_steps(30); - assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); - assert!(harness.state().show.is_none()); - } - - fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. - harness.state_mut().ui.zoom = Some(0.4); - harness - } - - fn slide_at(app: &SlideApp) -> usize { - app.session.active().unwrap().selection.slide - } - - /// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a - /// mouse notch. The harness runs one frame per queued event, so they have to share a frame or - /// none of them reaches the page threshold. - fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { - let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); - let input = harness.input_mut(); - input.events.push(egui::Event::PointerMoved(centre)); - let step = delta / 4.0; - for _ in 0..4 { - input.events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: step, - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Trackpad, - modifiers: egui::Modifiers::NONE, - }); - } - harness.run_steps(1); - } - - #[test] - fn page_flick_uses_the_stronger_axis() { - assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); - assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); - assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); - assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); - } - - /// Test helper: place the pointer over the canvas centre, inject wheel notches, and step once. - fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { - let centre = harness.state().canvas_rect.unwrap().center(); - let input = harness.input_mut(); - input.events.push(egui::Event::PointerMoved(centre)); - for &y in notches { - input.events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, y), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers: egui::Modifiers::NONE, - }); - } - harness.run_steps(1); - } - - #[test] - fn mouse_wheel_keeps_fast_notches_in_both_directions_without_a_smoothed_tail() { - let mut h = flick_harness(); - for _ in 0..8 { - h.state_mut().run("slide.new", json!({})).unwrap(); - } - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.run_steps(1); - - mouse_wheel(&mut h, &[-1.0]); - assert_eq!(slide_at(h.state()), 1, "one notch responds immediately"); - mouse_wheel(&mut h, &[-1.0]); - assert_eq!(slide_at(h.state()), 2, "the next frame does not lose a notch to the cooldown"); - mouse_wheel(&mut h, &[-1.0, -1.0]); - assert_eq!(slide_at(h.state()), 4, "notches batched into a frame are retained"); - mouse_wheel(&mut h, &[-3.0]); - assert_eq!(slide_at(h.state()), 7, "an accelerated wheel event keeps its steps"); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 7, "smoothing must not turn more pages"); - mouse_wheel(&mut h, &[2.0]); - assert_eq!(slide_at(h.state()), 5, "reversing direction responds immediately"); - mouse_wheel(&mut h, &[100.0]); - assert_eq!(slide_at(h.state()), 0, "stop at the first slide"); - mouse_wheel(&mut h, &[-100.0]); - assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); - } - - /// A high-resolution wheel on Windows sends one notch as eight 0.125-line events. - #[test] - fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() { - let mut h = flick_harness(); - for _ in 0..4 { - h.state_mut().run("slide.new", json!({})).unwrap(); - } - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.run_steps(1); - - for _ in 0..7 { - mouse_wheel(&mut h, &[-0.125]); - assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page"); - } - mouse_wheel(&mut h, &[-0.125]); - assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide"); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page"); - - mouse_wheel(&mut h, &[-0.5]); - mouse_wheel(&mut h, &[0.5, 0.25]); - assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction"); - mouse_wheel(&mut h, &[0.25]); - assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); - } - - /// A fraction of a line left over in one presentation does not carry into another. - #[test] - fn mouse_wheel_fraction_does_not_carry_into_another_presentation() { - let mut h = flick_harness(); - h.state_mut().run("slide.new", json!({})).unwrap(); - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.run_steps(1); - mouse_wheel(&mut h, &[-0.75]); - assert_eq!(slide_at(h.state()), 0); - - let a = h.state().session.active_index().unwrap(); - h.state_mut().run("file.new", json!({})).unwrap(); - h.state_mut().run("slide.new", json!({})).unwrap(); - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.state_mut().ui.zoom = Some(0.4); - h.run_steps(2); - mouse_wheel(&mut h, &[-0.25]); - assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page"); - mouse_wheel(&mut h, &[-0.75]); - assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does"); - - // Back in the first one, leave 0.75 of a line, then switch away and back with the pointer - // off the canvas (on the tabs): the canvas never sees a hover in the other presentation. - let b = h.state().session.active_index().unwrap(); - h.state_mut().session.set_active(a); - h.state_mut().ui.zoom = Some(0.4); - h.run_steps(2); - mouse_wheel(&mut h, &[-0.75]); - assert_eq!(slide_at(h.state()), 0); - h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); - h.run_steps(1); - h.state_mut().session.set_active(b); - h.run_steps(2); - h.state_mut().session.set_active(a); - h.run_steps(2); - mouse_wheel(&mut h, &[-0.25]); - assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); - } - - /// Wheel input over a zoomed canvas pans it, while the same input outside the canvas must not - /// navigate slides. - #[test] - fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { - let mut h = flick_harness(); - h.state_mut().run("slide.new", json!({})).unwrap(); - h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); - h.state_mut().ui.zoom = Some(3.0); - h.run_steps(2); - let before = h.state().slide_rect.unwrap(); - mouse_wheel(&mut h, &[-3.0]); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 0); - assert!(h.state().slide_rect.unwrap().min.y < before.min.y, "the enlarged canvas scrolls"); - - h.state_mut().ui.zoom = Some(0.4); - h.run_steps(2); - h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); - h.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, -3.0), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - modifiers: egui::Modifiers::NONE, - }); - h.run_steps(30); - assert_eq!(slide_at(h.state()), 0, "wheel input outside the canvas is ignored"); - } - - /// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. - /// The frames of one gesture do not skip ahead. - #[test] - fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.run_steps(1); - assert_eq!(slide_at(harness.state()), 0); - - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); - - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); - - harness.run_steps(30); - flick(&mut harness, egui::vec2(24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); - } - - #[test] - fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.run_steps(1); - flick(&mut harness, egui::vec2(-2.0, -24.0)); - assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); - - harness.state_mut().ui.zoom = Some(3.0); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.run_steps(2); - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); - } - - /// `show.start {from}` that names no slide of the deck is refused, over `run` and over the - /// control channel, instead of starting at slide 0 or on the end screen. A hidden slide is a - /// slide: From Current Slide plays it. - #[test] - fn show_start_refuses_a_from_that_is_not_a_slide() { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({})).unwrap(); - s.execute("slide.new", &json!({})).unwrap(); - s.execute("slide.hide", &json!({"index": 1, "hidden": true})).unwrap(); - let mut app = SlideApp::new(s, Services::default()); - let ctx = egui::Context::default(); - for from in [json!(-1), json!(1.5), json!("1"), json!(3), json!(u64::MAX)] { - assert!(app.run("show.start", json!({"from": from})).is_err(), "{from}"); - assert!(app.show.is_none(), "{from}"); - let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": from})); - let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!("{from}") }; - assert_eq!(answer["ok"], false, "{from}: {answer}"); - assert!(app.show.is_none(), "{from}"); - } - app.run("show.start", json!({"from": 1})).unwrap(); - assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.state.ended)), Some((1, false))); - app.show = None; - let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": 2, "reading": true})); - let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!() }; - assert_eq!(answer["ok"], true, "{answer}"); - assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.reading)), Some((2, true))); - } - - #[test] - fn a_swipe_during_the_show_turns_the_page() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); - harness.state_mut().start_show(0, true); - harness.run_steps(2); - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); - harness.run_steps(30); - flick(&mut harness, egui::vec2(24.0, 0.0)); - assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); - } - - /// The pen is drawing. A flick must not turn the page out from under the stroke. - #[test] - fn the_pen_ignores_a_swipe_during_the_show() { - let mut harness = flick_harness(); - harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); - harness.state_mut().start_show(0, true); - harness.run_steps(2); - harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); - harness.run_steps(1); - flick(&mut harness, egui::vec2(-24.0, 0.0)); - assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); - } - - /// Preferences › Interface size (#767): dragging the slider leaves the interface size alone - /// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag. - #[test] - fn interface_size_applies_when_the_slider_is_let_go() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - harness.state_mut().run("app.preferences", json!({})).unwrap(); - harness.run_steps(4); - let before = harness.state().ui.ui_scale; - let rect = - harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect(); - let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0)); - harness.hover_at(from); - harness.run_steps(1); - harness.drag_at(from); - harness.run_steps(1); - for k in 1..=8 { - harness.hover_at(from.lerp(to, k as f32 / 8.0)); - harness.run_steps(1); - } - assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held"); - harness.drop_at(to); - harness.run_steps(2); - let after = harness.state().ui.ui_scale; - assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}"); - assert!(harness.state().dialog.is_some(), "Preferences stays open"); - } - /// The status bar's zoom slider has no room for a visible label; it still needs an accessible - /// name, and adding that name must not change the slider's shared control geometry at either - /// size. - #[test] - fn status_zoom_slider_is_named_without_growing_the_status_bar() { - for size in [egui::vec2(900.0, 560.0), egui::vec2(1440.0, 900.0)] { - let mut harness = egui_kittest::Harness::builder().with_size(size).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(3); - let mut zoom = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Zoom"); - let rect = zoom.next().unwrap_or_else(|| panic!("a named Zoom slider at {size:?}")).rect(); - assert!(zoom.next().is_none(), "exactly one Zoom slider"); - // egui 0.37's slider uses its own `slider_width` and the theme's control height - // (22 px), not the old hard-coded 110x18. Assert that real, shared geometry so the - // status row stays consistent with every other control instead of loosening a bound. - let style = harness.ctx.global_style(); - let (expected_w, expected_h) = (style.spacing.slider_width, style.spacing.interact_size.y); - assert!( - (rect.width() - expected_w).abs() <= 0.5 && (rect.height() - expected_h).abs() <= 0.5, - "the status-bar slider uses the shared {expected_w}x{expected_h} control size at {size:?}: {rect:?}" - ); - } - } - - /// Accessibility names survive the port: the Preferences dialog and the Format Shape fill page - /// expose no unnamed input widgets, and the fill Transparency row has one named slider rather - /// than a visible label duplicated by `Slider::text`. - #[test] - fn preferences_and_format_pane_name_every_input() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(3); - harness.state_mut().run("app.preferences", json!({})).unwrap(); - harness.run_steps(3); - harness.check_accessibility(); - harness.key_press(egui::Key::Escape); - harness.run_steps(2); - assert!(harness.state().dialog.is_none(), "Preferences closed before the pane check"); - - harness.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); - harness.state_mut().ui.pane = Some("format".into()); - harness.state_mut().ui.format_tab = "fill".into(); - harness.run_steps(3); - harness.check_accessibility(); - let sliders: Vec<_> = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").collect(); - assert_eq!(sliders.len(), 1, "one fill Transparency slider"); - assert!(sliders[0].rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", sliders[0].rect()); - } - - /// A control `ui.screenshot` is admitted once, issued from a settled real UI pass, completed by - /// kittest's screenshot callback, and replied to exactly once after a logic drain. - #[test] - fn ui_screenshot_round_trips_through_the_callable_slot() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - let (tx, rx) = std::sync::mpsc::channel(); - harness.state_mut().control_rx = Some(rx); - let (req, reply) = ControlRequest::new("ui.screenshot", json!({})); - tx.send(req).unwrap(); - harness.run_steps(2); - assert_eq!(harness.state().capture.pending(), 1, "the request is admitted"); - harness.state_mut().capture.force_settle(); - harness.run_steps(4); - let reply = reply.recv_timeout(std::time::Duration::from_secs(5)).expect("screenshot reply"); - assert_eq!(reply["ok"], true, "{reply}"); - assert!(reply["result"]["width"].as_u64().is_some_and(|w| w > 0)); - assert_eq!(harness.state().capture.pending(), 0); - assert_eq!(harness.state().capture.reserved_bytes(), 0, "the reservation is released after the reply"); - } - - /// A hidden/logic-only iteration drains control requests and autosave/screenshot queues; it does - /// not need a painted frame for those side effects. - #[test] - fn logic_drains_control_without_a_ui_pass() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - let (req, reply) = ControlRequest::new("ui.inspect", json!({})); - tx.send(req).unwrap(); - app.logic(&ctx); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("reply from a logic-only call"); - assert_eq!(v["ok"], true, "{v}"); - } - - /// One synthetic pointer batch is injected at most once while it waits for a real UI pass. - /// eframe carries the whole `RawInput` from a logic-only frame into the next full pass, so a - /// document or layout change between them must retract the already-appended events too. - #[test] - fn synthetic_input_is_one_pending_batch_and_never_replays() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1, "the one pending event is injected once"); - assert!(app.synthetic.is_empty()); - // eframe's next logic-only frame hands the same RawInput back to the hook. The batch is - // still unacknowledged, so it must not be appended again. - let mut carried = raw.clone(); - app.raw_input_hook(&ctx, &mut carried); - assert_eq!(carried.events.len(), 1, "the unacknowledged batch is not injected twice"); - - // A settled UI pass acknowledges it; the next batch may then be injected. This test has - // no renderer, so clear the frame's texture uploads explicitly: egui 0.37 panics when a - // `TexturesDelta` with pending uploads is dropped. - app.fonts_ready = true; - app.styled = true; - ctx.run_ui(Default::default(), |ui| app.ui(ui)).drop_without_applying_deltas(); - - // Changing the document before the next pass drops a batch queued for the old one. - app.queue_input(egui::Event::PointerMoved(egui::pos2(20.0, 20.0))); - app.session.execute("file.new", &json!({})).unwrap(); - let mut raw2 = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw2); - assert!(raw2.events.is_empty(), "a batch queued for the old document is dropped"); - assert!(app.synthetic.is_empty()); - - // A pending batch for the current document is injected, then a document change before - // the next pass must retract it from the carried RawInput while keeping the newer click - // queued for the new document. - // The host supplies an identical pointer event before the injected batch: retraction must - // remove the batch at its recorded index, not the first identical window in the batch. - let moved = egui::Event::PointerMoved(egui::pos2(30.0, 30.0)); - app.queue_input(moved.clone()); - let mut raw3 = egui::RawInput::default(); - raw3.events.push(moved.clone()); - app.raw_input_hook(&ctx, &mut raw3); - assert_eq!(raw3.events, vec![moved.clone(), moved.clone()], "a settled pass permits the next batch"); - app.session.execute("file.new", &json!({})).unwrap(); - let newer = egui::Event::PointerMoved(egui::pos2(40.0, 40.0)); - app.queue_input(newer.clone()); - let host = egui::Event::Text("host".into()); - let mut carried3 = raw3.clone(); - carried3.events.push(host.clone()); - app.raw_input_hook(&ctx, &mut carried3); - assert_eq!( - carried3.events, - vec![moved, host, newer], - "the stale batch is retracted, host input and the newer click for the changed document are kept" - ); - assert!(app.synthetic.is_empty(), "the newer batch was injected, not cleared with the stale one"); - assert!(app.synthetic_pending, "the newer batch is now the one in flight"); - - let mut raw4 = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw4); - assert!(raw4.events.is_empty(), "the in-flight batch is not injected twice"); - } - - /// The first real pass installs the interface fonts and asks egui to discard it before any - /// layout. A control click that arrived in that same pass has already been consumed by egui, - /// so the app must put it back in the queue for the next frame instead of acknowledging and - /// losing it. - #[test] - fn a_control_click_on_the_first_font_discard_is_requeued_and_lands() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - let ctx = egui::Context::default(); - let pass = |app: &mut SlideApp, time: f64| { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut raw); - ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - }; - pass(&mut app, 1.0); - let size = app.session.active().unwrap().doc.slide_size; - app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); - app.session.execute("edit.deselect", &json!({})).unwrap(); - pass(&mut app, 2.0); - let point = app.slide_rect.expect("the canvas has been laid out").center(); - assert!(app.session.active().unwrap().selection.shapes.is_empty()); - - // Back to the first-frame state: the next pass installs fonts and is discarded. - app.styled = false; - app.fonts_ready = false; - app.queue_input(egui::Event::PointerMoved(point)); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::NONE, - }); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::NONE, - }); - pass(&mut app, 3.0); - assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the discarded pass did not run the canvas"); - assert!(!app.synthetic.is_empty(), "the click was requeued for the next frame"); - - // One pointer event is injected per pass: move, press, release. - for time in [4.0, 5.0, 6.0] { - pass(&mut app, time); - } - assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the requeued click selected the slide's shape"); - assert!(!app.synthetic_pending && app.synthetic.is_empty(), "the click was consumed exactly once"); - } - - /// A modal opening changes what a queued window-point event means even when the document and - /// canvas are unchanged; the injected batch is retracted before it reaches the modal. - #[test] - fn synthetic_batch_is_retracted_when_a_modal_opens_first() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1); - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "the click queued for the canvas does not reach the modal"); - assert!(!app.synthetic_pending); - } - - /// A control click is admitted for the full pass it was queued for. In that pass egui has - /// already derived `PointerState` from the injected press/release, so retracting only the raw - /// events would still let the click reach a document opened by the same pass's logic. The - /// control request therefore waits: the old target's real `logic` + `ui` pass consumes the - /// whole pointer gesture, and the next pass (no synthetic input) applies the document change. - #[test] - fn synthetic_click_is_consumed_on_its_old_target_before_logic_changes_the_document() { - // The destination has one shape over the whole slide, so a click that leaked into it - // would leave the shape selected. Opening it creates a fresh document state with an - // empty selection. - let mut destination = Session::with_new(); - let size = destination.active().unwrap().doc.slide_size; - destination.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); - let data = destination.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); - - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - let ctx = egui::Context::default(); - let pass = |app: &mut SlideApp, time: f64| { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut raw); - ctx.run_ui(raw, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - }; - - pass(&mut app, 1.0); - app.run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap(); - assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the old document has a selection to clear"); - pass(&mut app, 2.0); - let old = app.session.active().unwrap().uid; - let slide = app.slide_rect.expect("the canvas has been laid out"); - // Well away from the inserted rectangle, so a press on the bare canvas clears the - // selection in the old document. - let point = egui::pos2(slide.min.x + slide.width() * 0.8, slide.min.y + slide.height() * 0.8); - app.queue_input(egui::Event::PointerMoved(point)); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::NONE, - }); - app.queue_input(egui::Event::PointerButton { - pos: point, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::NONE, - }); - - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - let (req, reply) = - ControlRequest::new("engine.execute", json!({"command": "file.openBytes", "params": {"name": "destination.deckcraft", "data": data}})); - tx.send(req).unwrap(); - - // `raw_input_hook` admits one pointer event per pass. Every one of those passes must stay - // on the old target; the control request may not create the destination before ui sees - // the press/release. - for time in [3.0, 4.0, 5.0] { - pass(&mut app, time); - assert_eq!(app.session.active().unwrap().uid, old, "the click's pass stays on its captured target"); - assert_eq!(app.session.documents().len(), 1, "logic must not open the destination under the click"); - } - assert!(!app.synthetic_pending, "the full passes consumed the gesture"); - assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the press selected the bare canvas on the old document"); - - // With no synthetic input pending, the next logic applies the deferred request. The - // destination's full-slide shape is unselected because no pointer state from the old - // pass was replayed into it. - pass(&mut app, 6.0); - let reply = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the click"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.session.documents().len(), 2); - let active = app.session.active().unwrap(); - assert_ne!(active.uid, old, "the destination opened only after the click was consumed"); - assert!(!active.doc.slides[0].shapes.is_empty(), "the destination really has a clickable shape"); - assert!(active.selection.shapes.is_empty(), "the old pass's click did not select the destination shape"); - } - - /// A queued control click belongs to the full-screen show, opening window and palette - /// selection that were on screen when it was queued; each transition is its own target. - #[test] - fn input_target_tracks_slideshow_opening_and_palette_selection() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let base = app.input_target(); - app.start_show(0, true); - assert_ne!(app.input_target(), base, "a slideshow is a different input target"); - let started = app.input_target(); - app.show.as_mut().unwrap().state.step += 1; - let stepped = app.input_target(); - assert_ne!(stepped, started, "the show step changes which click areas are live"); - let reading_now = app.show.as_ref().unwrap().reading; - app.show.as_mut().unwrap().reading = !reading_now; - let reading = app.input_target(); - assert_ne!(reading, stepped, "the reading layout moves the slide"); - app.show.as_mut().unwrap().grid = true; - let grid = app.input_target(); - assert_ne!(grid, reading, "See All Slides turns a click into a thumbnail choice"); - app.show.as_mut().unwrap().ended = true; - let ended = app.input_target(); - assert_ne!(ended, grid, "the end screen has different click targets"); - app.show = None; - assert_eq!(app.input_target(), base, "leaving the show returns to the editor target"); - - // Starting the show again is a new instance even at the same slide, so an old batch cannot - // match by value after an ABA transition. - app.start_show(0, true); - let first_instance = app.input_target(); - app.start_show(0, true); - assert_ne!(app.input_target(), first_instance, "a new show instance is a new target"); - app.show = None; - - app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); - assert_ne!(app.input_target(), base, "the background-open window is a different target"); - let opening = app.input_target(); - app.opening.as_mut().unwrap().generation = 2; - assert_ne!(app.input_target(), opening, "reopening the same path is a new attempt"); - app.opening = None; - - app.palette = Some(("open".into(), 0)); - let first = app.input_target(); - app.palette = Some(("open".into(), 1)); - assert_ne!(app.input_target(), first, "the palette's highlighted entry is part of the target"); - app.palette = None; - - app.ui.tab = "Insert".into(); - assert_ne!(app.input_target(), base, "another ribbon tab is another click target"); - let insert = app.input_target(); - app.ui.ribbon_collapsed = true; - assert_ne!(app.input_target(), insert, "collapsing the ribbon changes the layout under a click"); - app.ui.ribbon_collapsed = false; - app.ribbon_scroll.insert("Insert".into(), 64.0_f32.to_bits()); - assert_ne!(app.input_target(), insert, "scrolling the ribbon moves the buttons under a click"); - app.ui.tab = "Home".into(); - app.ribbon_scroll.remove("Insert"); - assert_eq!(app.input_target(), base); - } - - /// A click queued for the editor is retracted when a slideshow, background-open window or - /// different palette entry takes over before the input pass. - #[test] - fn input_target_changes_retract_a_queued_click() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let pointer = || egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); - - app.queue_input(pointer()); - app.start_show(0, true); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "a click queued for the editor does not enter the slideshow"); - assert!(app.synthetic.is_empty()); - app.show = None; - - app.queue_input(pointer()); - app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "a click queued for the editor does not enter the opening window"); - app.opening = None; - - app.palette = Some(("open".into(), 0)); - app.queue_input(pointer()); - app.palette = Some(("open".into(), 1)); - let mut raw = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.is_empty(), "a click queued for one palette selection does not activate another"); - assert!(app.synthetic.is_empty()); - } - - /// A logic-only callback does not consume events, but eframe carries the whole RawInput into - /// the next full pass. The hook injects without guessing the phase; when a control request - /// drained by that logic-only callback changes the document, the carried batch is retracted at - /// the next hook instead of being delivered into the new document. - #[test] - fn hidden_logic_pass_carries_a_synthetic_batch_and_drops_it_when_the_document_changes() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut hidden = egui::RawInput::default(); - hidden.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut hidden); - assert_eq!(hidden.events.len(), 1, "the batch is injected into the RawInput eframe will carry"); - assert!(app.synthetic.is_empty()); - assert!(app.synthetic_pending, "run_logic leaves the batch unacknowledged"); - - let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); - tx.send(req).unwrap(); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the hidden pass"); - assert_eq!(v["ok"], true, "{v}"); - assert_eq!(app.session.documents().len(), 2, "the hidden pass opened another document"); - assert!(app.synthetic_pending, "a logic-only callback cannot retract the carried batch"); - - // eframe hands the hidden RawInput to the next visible pass, appending new host input. - let mut visible = hidden.clone(); - visible.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - let host = egui::Event::Text("host".into()); - visible.events.push(host.clone()); - app.raw_input_hook(&ctx, &mut visible); - assert_eq!(visible.events, vec![host.clone()], "the click queued for the old document is dropped"); - assert!(app.synthetic.is_empty()); - ctx.run_ui(visible, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - assert!(app.synthetic.is_empty(), "nothing replays after the visible pass"); - } - - /// eframe may run logic-only for a *visible* root when a redraw deadline is overdue. The - /// previous pass's InputState is still live, so retraction must not drain an identical event - /// from it; the batch belongs to the carried RawInput and is removed at the next hook. - #[test] - fn visible_logic_pass_does_not_retract_from_the_previous_input_state() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - let pointer = egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); - - // Live InputState from the previous full pass, with an identical event at the same index. - let mut previous = egui::RawInput { time: Some(1.0), ..Default::default() }; - previous.events.push(pointer.clone()); - ctx.run_ui(previous, |_ui| {}).drop_without_applying_deltas(); - - app.queue_input(pointer.clone()); - let mut raw = egui::RawInput { time: Some(2.0), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events, vec![pointer.clone()]); - - let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); - tx.send(req).unwrap(); - let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the visible logic pass"); - assert_eq!(v["ok"], true, "{v}"); - assert_eq!(app.session.documents().len(), 2); - assert!(app.synthetic_pending, "the carried batch is still pending"); - assert_eq!(ctx.input(|i| i.events.clone()), vec![pointer.clone()], "the previous pass's events are untouched"); - - let mut next = raw.clone(); - next.time = Some(3.0); - let host = egui::Event::Text("host".into()); - next.events.push(host.clone()); - app.raw_input_hook(&ctx, &mut next); - assert_eq!(next.events, vec![host.clone()], "the stale batch is removed from the carried RawInput"); - assert!(!app.synthetic_pending); - assert!(app.synthetic.is_empty()); - } - - /// A batch injected in a logic-only pass is carried into the full pass with a refreshed - /// `RawInput::time`. That full pass must consume it on the old target: a control request - /// drained by the same logic waits until the next pass instead of changing the document - /// after egui has already derived the carried pointer state. - #[test] - fn carried_batch_is_consumed_before_the_full_pass_applies_a_control_change() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - let ctx = egui::Context::default(); - let (tx, rx) = std::sync::mpsc::channel(); - app.control_rx = Some(rx); - app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); - let mut hidden = egui::RawInput { time: Some(1.0), ..Default::default() }; - hidden.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut hidden); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - assert!(app.synthetic_pending, "the logic-only pass keeps the carried batch"); - - // eframe refreshes the carried input's time before calling the hook for the full pass. - let mut full = hidden.clone(); - full.time = Some(2.0); - app.raw_input_hook(&ctx, &mut full); - assert!(app.synthetic_pending, "the carried batch is not injected twice"); - - let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); - tx.send(req).unwrap(); - ctx.run_ui(full, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - assert_eq!(app.session.documents().len(), 1, "the full pass's UI sees its captured document"); - assert!(!app.synthetic_pending, "the full pass consumed the carried batch"); - - // The next pass has no synthetic input, so logic applies the deferred request. - let mut next = egui::RawInput { time: Some(3.0), ..Default::default() }; - next.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut next); - ctx.run_ui(next, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the full pass"); - assert_eq!(v["ok"], true, "{v}"); - assert_eq!(app.session.documents().len(), 2, "the deferred control request ran on the next pass"); - } - - /// `visible() == false` does not prove a logic-only pass: a screenshot or - /// `RequestPaintWhileHidden` makes eframe run the full UI. The synthetic batch must be - /// delivered there, not postponed until the viewport is visible. - #[test] - fn hidden_full_pass_receives_synthetic_ui_input() { - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.fonts_ready = true; - app.styled = true; - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - app.queue_input(egui::Event::Key { - key: egui::Key::Escape, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }); - let ctx = egui::Context::default(); - let mut hidden = egui::RawInput { time: Some(4.0), ..Default::default() }; - hidden.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - app.raw_input_hook(&ctx, &mut hidden); - assert_eq!(hidden.events.len(), 1, "a forced hidden full pass still gets the event"); - assert!(app.synthetic_pending); - - ctx.run_ui(hidden, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - assert!(app.dialog.is_none(), "the synthetic Escape reached the UI in the hidden full pass"); - assert!(!app.synthetic_pending, "the full pass acknowledged the batch"); - assert!(app.synthetic.is_empty()); - } - - /// egui 0.37 carries a clipboard image as `Event::PasteImage`; it inserts once, without - /// asking the host clipboard service for the same image again. - #[test] - fn paste_image_event_inserts_one_picture() { - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( - |ui, app: &mut SlideApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SlideApp::new(Session::with_new(), Services::default()), - ); - harness.run_steps(2); - let before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); - let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)); - harness.event(egui::Event::PasteImage(std::sync::Arc::new(image))); - harness.run_steps(2); - let after = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); - assert_eq!(after, before + 1, "one image is inserted"); - } - - /// A minimized/occluded show keeps playing from logic alone: the clock advances on wall - /// time, an After transition advances the slide without a draw, and a visible pass resumes - /// from the show state instead of inventing a paused-show policy. - #[test] - fn hidden_viewport_advances_show_and_presenter_clocks_from_logic() { - /// Nested helper in the hidden-show test: build root `RawInput` with the given time and - /// visibility flags. - fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - raw - } - - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 0, "after": 1000})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); - app.start_show(0, false); - let ctx = egui::Context::default(); - - // A real visible UI pass starts the show and its clocks. There is no renderer here, so - // each frame's texture uploads are cleared explicitly (egui 0.37 panics on drop). - ctx.run_ui(raw_at(10.0, false, false), |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let start = app.show.as_ref().expect("show started").clock_snapshot(); - assert_eq!(start.step_start, 10.0); - assert_eq!(start.show_start, 10.0); - - // Hidden logic starts at the last visible egui time, then advances on the monotonic - // instant. Two seconds later the slide's After: 1 s transition has advanced the show. - let t0 = web_time::Instant::now(); - app.show.as_mut().expect("show started").observe_visible_frame(10.0, t0); - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0)); - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(2))); - let hidden = app.show.as_ref().expect("hidden show"); - assert_eq!(hidden.state.slide, 1, "the After transition advances without a draw"); - assert!((hidden.clock_snapshot().slide_start - 12.0).abs() < 1e-9); - assert!((hidden.presenter_elapsed(12.0) - 2.0).abs() < 1e-9, "presenter time keeps running"); - - // A later hidden tick does not replay the transition or stack time. - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(3))); - assert_eq!(app.show.as_ref().unwrap().state.slide, 1); - assert!((app.show.as_ref().unwrap().clock_snapshot().slide_start - 12.0).abs() < 1e-9); - - // A visible pass re-anchors to egui's real time and keeps the state it reached hidden. - ctx.run_ui(raw_at(20.0, false, false), |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let resumed = app.show.as_ref().expect("show resumed"); - assert_eq!(resumed.state.slide, 1); - assert!((resumed.presenter_elapsed(20.0) - 10.0).abs() < 1e-9); - assert!((resumed.clock_snapshot().slide_start - 12.0).abs() < 1e-9); - } - - /// A visible window can receive an overdue logic-only pass before the next paint. That pass - /// still carries the previous full frame's `InputState::time`; treating it as a visible frame - /// re-anchors the hidden clock to the later monotonic instant and loses elapsed show time. - /// Re-anchoring belongs to `show::ui`, which only runs on a real visible pass. - #[test] - fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { - /// Nested helper in the overdue-logic test: build root `RawInput` with the given time and - /// visibility flags. - fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { - let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; - raw.viewports.insert( - egui::ViewportId::ROOT, - egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, - ); - raw - } - - let mut app = SlideApp::new(Session::with_new(), Services::default()); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("slide.new", &json!({})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 0, "after": 2500})).unwrap(); - app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); - app.start_show(0, false); - let ctx = egui::Context::default(); - - // The full visible pass is the only pass that anchors the show: egui time 10. - ctx.run_ui(raw_at(10.0, false, false), |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }) - .drop_without_applying_deltas(); - let visible_anchor = web_time::Instant::now(); - - // An overdue visible logic-only pass does not refresh InputState::time. It must not move - // the hidden anchor from the full visible frame to this later instant. - let _ = ctx.run_logic(&raw_at(12.0, false, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(2))); - assert_eq!(ctx.input(|i| i.time), 10.0, "run_logic retains the last full pass's egui time"); - assert_eq!(app.show.as_ref().unwrap().state.slide, 0); - - // Three seconds after the full visible frame is past the slide's 2.5 s After delay. If the - // logic-only pass had re-anchored at +2 s, this hidden tick would still be only +1 s and - // the slide would not advance. - let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); - assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); - } -} +mod tests; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs new file mode 100644 index 00000000..9765fe85 --- /dev/null +++ b/crates/ui-egui/src/tests.rs @@ -0,0 +1,2373 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; +use egui_kittest::kittest::Queryable; + +fn shape_rot(app: &SlideApp) -> f64 { + let d = app.session.active().unwrap(); + let id = d.selection.shapes[0]; + d.shape(id).unwrap().xfrm.unwrap().rot +} + +/// A two-finger twist is egui's Rotate event. Over the canvas it turns the selection; +/// letting go is one undo step. Off the canvas it does nothing. +#[test] +fn a_trackpad_twist_rotates_the_shape_under_the_canvas() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 100.0, 60.0]})).unwrap(); + let id = id.get("id").and_then(|v| v.as_u64()).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.run_steps(1); + let canvas = harness.state().canvas_rect.expect("canvas"); + let outside = egui::pos2(1.0, 1.0); + assert!(!canvas.contains(outside), "the ribbon corner is not the canvas: {canvas:?}"); + + harness.input_mut().events.push(egui::Event::PointerMoved(outside)); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "off the canvas, still {}", shape_rot(harness.state())); + + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(20.0_f32.to_radians())); + harness.run_steps(1); + harness.input_mut().events.push(egui::Event::PointerMoved(canvas.center())); + harness.input_mut().events.push(egui::Event::Rotate(10.0_f32.to_radians())); + harness.run_steps(1); + assert!((shape_rot(harness.state()) - 30.0).abs() < 0.05, "twist landed on {}", shape_rot(harness.state())); + harness.run_steps(1); + assert!(harness.state().session.active().unwrap().interaction.is_none()); + harness.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state())); +} + +/// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). +#[test] +fn web_export_all_slides_gives_one_file_per_slide() { + let mut doc = deckcraft_model::Presentation::default(); + if let Some(first) = doc.slides.first().cloned() { + doc.slides.push(first.clone()); + doc.slides.push(first); + } + assert_eq!(doc.slides.len(), 3); + let all = web_image_files(&doc, 1, true, "Deck", "png", false); + let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); + let one = web_image_files(&doc, 1, false, "Deck", "png", false); + assert_eq!(one.len(), 1); + assert_eq!(one[0].0, "Deck.png"); +} + +/// The web PNG download honours "Transparent background" (#784): the slide background is +/// left out, so the corner is transparent; a JPEG download stays opaque. +#[test] +fn web_png_export_can_leave_the_background_transparent() { + use std::cell::RefCell; + use std::rc::Rc; + let got: Rc)>>> = Rc::default(); + let log = got.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3]; + assert!(app.export_as("png", json!({}))); + assert!(app.export_as("png", json!({"transparentBackground": true}))); + assert!(app.export_as("jpeg", json!({"transparentBackground": true}))); + let got = got.borrow(); + assert_eq!(got.len(), 3); + assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default"); + assert_eq!(corner_alpha(&got[1].1), 0, "transparent background"); + assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); +} + +fn title_text(app: &SlideApp) -> String { + let d = app.session.active().unwrap(); + let sh = &d.doc.slides[0].shapes[0]; + sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() +} + +#[test] +fn quick_access_toolbar_adds_removes_resets_and_persists() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let ids = |app: &SlideApp| app.ui.qat.iter().map(|q| q.id.clone()).collect::>(); + assert_eq!(ids(harness.state()), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); + let app = harness.state_mut(); + app.run("app.qat", json!({"add": "insert.table", "label": "Table", "icon": "table"})).unwrap(); + app.run("app.qat", json!({"add": "insert.table"})).unwrap(); + app.run("app.qat", json!({"add": "view.pane", "params": {"pane": "selection"}, "label": "Selection Pane"})).unwrap(); + assert!(app.run("app.qat", json!({"add": "no.such"})).is_err()); + app.run("app.qat", json!({"remove": "edit.redo"})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "show.fromStart", "insert.table", "view.pane"]); + assert_eq!(app.ui.qat[5].label, "Table"); + // Saved with the rest of the UI state and read back. + let back: UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert_eq!(back.qat, app.ui.qat); + harness.run_steps(2); + let app = harness.state_mut(); + app.run("app.qat", json!({"remove": "view.pane", "params": {"pane": "selection"}})).unwrap(); + assert_eq!(app.ui.qat.len(), 6); + app.run("app.qat", json!({"reset": true})).unwrap(); + assert_eq!(ids(app), ["app.home", "file.save", "app.exportDialog", "edit.undo", "edit.redo", "show.fromStart"]); + // An older settings file without the toolbar gets the defaults. + let old: UiState = serde_json::from_value(json!({"tab": "Home"})).unwrap(); + assert_eq!(old.qat.len(), 6); +} + +#[test] +fn opening_a_file_with_missing_fonts_shows_a_toast_once() { + use egui_kittest::kittest::Queryable; + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into()); + slide.shapes[0].text = Some(body); + let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap()); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + assert!(harness.state().toast.is_none(), "a new presentation has nothing to say"); + harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap(); + harness.run_steps(2); + let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default(); + assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}"); + assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some()); + // Closed, it stays closed for that document. + harness.get_by_label("Close").click(); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); +} + +/// #115: Change Picture replaced nothing — it inserted a second picture. +#[test] +fn change_picture_replaces_the_selected_picture() { + let svg = |c: &str| { + format!("").into_bytes() + }; + let media = |app: &SlideApp| { + let d = app.session.active().unwrap(); + let pics: Vec<_> = d.doc.slides[0] + .shapes + .iter() + .filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None }) + .collect(); + assert_eq!(pics.len(), 1, "one picture, replaced in place"); + pics[0] + }; + let insert = |app: &mut SlideApp| { + let id = + app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap() + ["id"] + .clone(); + app.session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + media(app) + }; + + // Desktop: the file dialog returns a path. + let blue = svg("blue"); + let services = Services { + pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))), + read: Some(Box::new(move |_: &str| Ok(blue.clone()))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + assert_ne!(media(&app), before); + + // Web: the browser picker delivers the file through the inbox later. + let inbox = Inbox::default(); + let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = started.clone(); + let services = Services { + open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))), + inbox: Some(inbox.clone()), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let before = insert(&mut app); + app.run("app.changePictureDialog", json!({})).unwrap(); + let id = started.lock().unwrap()[0]; + inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") })); + app.drain_inbox(); + assert_ne!(media(&app), before); +} + +/// #17: Home created a new blank presentation instead of going to the start screen. +#[test] +fn home_shows_the_start_screen_over_the_open_presentation() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + + harness.state_mut().run("app.home", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().backstage_open()); + assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation"); + + // Typing on the start screen doesn't reach the presentation behind it; Escape goes back. + harness.event(egui::Event::Text("a".into())); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), ""); + assert!(!harness.state().session.active().unwrap().is_dirty()); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().backstage_open()); + + // Home again toggles; picking a theme on the start screen (a new presentation) leaves it. + harness.state_mut().run("app.home", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().backstage_open()); + harness.state_mut().run("app.home", json!({})).unwrap(); + assert!(!harness.state().backstage_open()); + harness.state_mut().run("app.home", json!({"on": true})).unwrap(); + harness.state_mut().run("file.new", json!({})).unwrap(); + harness.run_steps(2); + assert!(!harness.state().backstage_open(), "a new presentation leaves Home"); + assert_eq!(harness.state().session.documents().len(), 2); +} + +/// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real +/// egui key events (Windows keys). +#[test] +fn ctrl_word_keys_edit_text() { + use egui::{Key, Modifiers}; + let mut session = Session::with_new(); + session.mac_keys = false; + session.prefs.autocorrect = false; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("Hello, world. Foo bar".into())); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Hello, world. Foo bar"); + let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap(); + let ctrl = Modifiers::COMMAND; + let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT; + + // Ctrl+Left stops at every word and punctuation run, as in PowerPoint. + let mut stops = vec![]; + for _ in 0..6 { + harness.key_press_modifiers(ctrl, Key::ArrowLeft); + harness.run_steps(1); + stops.push(caret(&harness).caret.1); + } + assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]); + harness.key_press_modifiers(ctrl, Key::ArrowRight); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (0, 5)); + + // Ctrl+Shift+Right selects to the next stops. + harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); + harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); + harness.run_steps(1); + let t = caret(&harness); + assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12))); + + // Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one + // before the caret. + harness.key_press_modifiers(ctrl, Key::Delete); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Helloworld. Foo bar"); + harness.key_press(Key::End); + harness.key_press_modifiers(ctrl, Key::Backspace); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Helloworld. Foo "); + + // Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends. + harness.key_press(Key::Enter); + harness.event(egui::Event::Text("Next".into())); + harness.key_press_modifiers(ctrl, Key::ArrowUp); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (1, 0)); + harness.key_press_modifiers(ctrl, Key::ArrowUp); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (0, 0)); + harness.key_press_modifiers(ctrl, Key::ArrowDown); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (1, 0)); + harness.key_press_modifiers(ctrl_shift, Key::Home); + harness.run_steps(1); + let t = caret(&harness); + assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0))); + + // Ctrl+Shift+> (egui reports the Period key) grows the selected text's font. + let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64(); + let before = size(&mut harness); + harness.key_press_modifiers(ctrl_shift, Key::Period); + harness.run_steps(1); + let after = size(&mut harness).unwrap(); + assert!(after > before.unwrap_or(0.0), "{before:?} → {after}"); + harness.key_press_modifiers(ctrl, Key::OpenBracket); + harness.run_steps(1); + assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks"); +} + +/// Export… had no visible control: only the command palette reached it. +#[test] +fn title_bar_export_opens_the_export_dialog() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + // Quick access: Home, Save, Export, each 24 points wide with 2 between, from x = 10. + let pos = egui::pos2(10.0 + 2.0 * 26.0 + 12.0, ribbon::TITLE_H / 2.0); + harness.hover_at(pos); + harness.run_steps(1); + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + harness.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); +} + +/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and +/// swallows the key press, so only the V release reaches the app. +#[test] +fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + assert!(!png.is_empty()); + let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + let pictures = |app: &SlideApp| { + let d = app.session.active().unwrap(); + d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() + }; + let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // A text paste (Paste event on the press) does not also paste the image on release. + harness.event(egui::Event::Paste("hello".into())); + harness.run_steps(2); + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // A V release without Command/Ctrl is not a paste. + harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 1); +} + +/// #78: after Tab in a text box, typing and deleting no longer reached the text. +#[test] +fn typing_goes_on_after_tab_in_a_text_box() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + { + let mut session = Session::with_new(); + session.prefs.autocorrect = false; + SlideApp::new(session, Services::default()) + }, + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("ab".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("c".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\tc"); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t"); + assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); + // Tab at the start of a paragraph indents it, and typing goes on there too (#93). + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("d".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t\nd"); + let level = |h: &egui_kittest::Harness| { + h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level + }; + assert_eq!(level(&harness), 1); +} + +/// #93: Tab in an edited list indented the item but handed the keyboard to egui's focus +/// cycle: the stray focus (e.g. on the window's drag handle) swallowed every later key, so the +/// list could neither be typed into nor continued with Enter. +#[test] +fn tab_in_an_edited_list_keeps_the_keyboard() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("one".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); // Increase List Level + harness.run_steps(2); + harness.event(egui::Event::Text("sub".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); // continues the list + harness.run_steps(2); + harness.event(egui::Event::Text("two".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "one\nsub\ntwo", "typing after Tab must reach the text"); + let body = harness.state().session.active().unwrap().doc.slides[0].shapes[0].text.clone().unwrap(); + let levels: Vec = body.paragraphs.iter().map(|p| p.level).collect(); + assert_eq!(levels, vec![0, 1, 1], "Tab indents one level; Enter keeps the level"); +} + +#[test] +fn home_needs_an_open_presentation() { + let mut app = SlideApp::new(Session::new(), Services::default()); + app.run("app.home", json!({})).unwrap(); + assert!(!app.backstage_open(), "with nothing open the start screen shows anyway"); +} + +/// View › New Window is a second window on the open presentation. Its slide is its own; +/// the editor's selection stays where it was. Closing the window leaves the document open. +#[test] +fn new_window_shows_the_same_document_on_its_own_slide() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.go", &json!({"index": 1})).unwrap(); + let err = { + let mut empty = SlideApp::new(Session::new(), Services::default()); + empty.run("view.newWindow", json!({})).unwrap_err() + }; + assert!(err.contains("presentation"), "{err}"); + + let name = app.session.active().unwrap().title(); + let opened = app.run("view.newWindow", json!({})).unwrap(); + assert_eq!(opened["open"], true); + assert_eq!(opened["window"], 2); + assert_eq!(opened["slide"], 1); + assert_eq!(opened["title"], format!("{name}:2")); + assert_eq!(app.session.active().unwrap().selection.slide, 1); + + let moved = app.run("view.newWindow", json!({"delta": -1})).unwrap(); + assert_eq!(moved["slide"], 0); + assert_eq!(app.doc_window.as_ref().unwrap().slide, 0); + assert_eq!(app.session.active().unwrap().selection.slide, 1, "the editor stays on its slide"); + + let again = app.run("view.newWindow", json!({})).unwrap(); + assert_eq!(again["window"], 2); + assert_eq!(again["slide"], 0, "opening again keeps the window's slide"); + + app.session.execute("file.close", &json!({})).unwrap(); + doc_window::show(&mut app, &egui::Context::default()); + assert!(app.doc_window.is_none(), "closing the presentation closes the extra window"); + assert!(app.session.active().is_none()); +} + +#[test] +fn grid_options_sets_spacing_and_display_or_opens_the_dialog() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + assert!((app.session.prefs.grid_spacing - 6.0).abs() < 1e-9); + let v = + app.run("view.gridOptions", json!({"spacing": 72.0, "snap": false, "smartGuides": false, "gridlines": true, "guides": true})).unwrap(); + assert_eq!(v["spacing"], 72.0); + assert_eq!(v["snap"], false); + assert_eq!(v["smartGuides"], false); + assert_eq!(v["gridlines"], true); + assert_eq!(v["guides"], true); + assert!(app.dialog.is_none()); + assert!(app.ui.gridlines && app.ui.guides); + assert!(!app.session.prefs.snap_to_grid && !app.ui.smart_guides); + + app.run("view.gridOptions", json!({"snap": true})).unwrap(); + assert!(app.session.prefs.snap_to_grid); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.dialog.is_none()); + + for bad in [json!({"spacing": 0}), json!({"spacing": 289}), json!({"spacing": "72"}), json!({"spacing": null}), json!({"snap": 1})] { + assert!(app.run("view.gridOptions", bad).is_err(), "rejected a bad grid option"); + } + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); + assert!(app.session.prefs.snap_to_grid); + + app.run("view.gridOptions", json!({})).unwrap(); + assert_eq!(app.dialog.as_ref().map(|d| d.id.as_str()), Some("gridOptions")); + assert!((app.session.prefs.grid_spacing - 72.0).abs() < 1e-9); +} + +#[test] +fn grid_options_dialog_lists_spacing_and_display() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label_contains("Display grid on screen").is_some()); + assert!(harness.query_by_label_contains("Snap objects to grid").is_some()); + assert!(harness.query_by_label_contains("Spacing (pt)").is_some()); + let show_grid = harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, "Display grid on screen") + .next() + .unwrap_or_else(|| panic!("no grid checkbox")); + show_grid.click_accesskit(); + harness.run_steps(2); + harness.query_all_by_role_and_label(egui::accesskit::Role::Button, "OK").next().unwrap_or_else(|| panic!("no OK button")).click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "OK closes Grid Options"); + assert!(harness.state().ui.gridlines, "OK applies the grid checkbox"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); + + harness.state_mut().run("view.gridOptions", json!({})).unwrap(); + harness.run_steps(2); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes Grid Options"); + assert!(harness.state().ui.gridlines, "Cancel leaves the grid on"); + assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9); +} + +/// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog +/// changed while it was open; Done keeps it. +#[test] +fn preferences_cancel_restores_the_settings_done_keeps_them() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let before = harness.state().session.prefs.clone(); + let dark_before = harness.state().ui.brightness; + let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| { + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(2); + for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] { + harness + .query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label) + .next() + .unwrap_or_else(|| panic!("no {label} checkbox")) + .click_accesskit(); + harness.run_steps(2); + } + assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance"); + assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides); + }; + let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| { + let s = harness.state(); + assert!(s.dialog.is_none(), "{how} closes Preferences"); + assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance"); + assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides"); + assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid"); + }; + + change(&mut harness); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button")) + .click_accesskit(); + harness.run_steps(2); + unchanged(&harness, "Cancel"); + + change(&mut harness); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + unchanged(&harness, "Esc"); + + change(&mut harness); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Done") + .next() + .unwrap_or_else(|| panic!("no Done button")) + .click_accesskit(); + harness.run_steps(2); + let s = harness.state(); + assert!(s.dialog.is_none(), "Done closes Preferences"); + assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance"); + assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides"); + assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid"); +} + +/// About dialog: Close and Cancel each close the dialog. +#[test] +fn about_dialog_close_and_cancel_close_it() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("app.about", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel") + .next() + .unwrap_or_else(|| panic!("no Cancel button in About dialog")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Cancel closes About dialog"); + + harness.state_mut().run("app.about", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.state().dialog.is_some()); + harness + .query_all_by_role_and_label(egui::accesskit::Role::Button, "Close") + .next() + .unwrap_or_else(|| panic!("no Close button in About dialog")) + .click_accesskit(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Close closes About dialog"); +} + +fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let app = harness.state_mut(); + // Zoomed out, the canvas shows plenty of pasteboard around the slide. + app.ui.zoom = Some(0.4); + app.session.prefs.snap_to_grid = false; + app.session.prefs.smart_guides = false; + harness +} + +fn selected(app: &SlideApp) -> Vec { + app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect() +} + +fn shape_x(app: &SlideApp, id: u64) -> f64 { + let st = app.session.active().unwrap(); + st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x +} + +/// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where +/// they can be selected and moved. +#[test] +fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() { + let _gpu = crate::gpu_lock::gpu_lock(); + let mut harness = canvas_harness(); + let off = + harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap(); + harness.run_steps(3); + + let app = harness.state(); + let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale); + // The texture reaches past the slide's right edge to the shape's, and no further left/up. + assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}"); + assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}"); + let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k); + assert!(app.canvas_rect.unwrap().contains(centre)); + + // Drawn at full strength on the pasteboard (when a GPU renderer is available here). + if let Ok(img) = harness.render() { + let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0; + let shape = px(centre); + let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k)); + let doc = &harness.state().session.active().unwrap().doc; + let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1); + let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4; + assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}"); + assert_ne!(shape, board); + } + + // Click selects it. + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(1); + harness.drop_at(centre); + harness.run_steps(2); + assert_eq!(selected(harness.state()), vec![off]); + + // Drag moves it, further off the slide. + let to = centre + egui::vec2(40.0, 0.0); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(1); + for i in 1..=4 { + harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0)); + harness.run_steps(1); + } + harness.drop_at(to); + harness.run_steps(2); + let x = shape_x(harness.state(), off); + assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}"); + // The pasteboard followed it. + let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap()); + assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}"); + + // Idle frames don't render the canvas again. + let n = harness.state().textures.canvas_renders; + harness.run_steps(3); + assert_eq!(harness.state().textures.canvas_renders, n); +} + +/// A slide with nothing off it renders just the slide, as before. +#[test] +fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() { + let mut harness = canvas_harness(); + harness.run_steps(3); + let app = harness.state(); + let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap()); + assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}"); + let n = app.textures.canvas_renders; + harness.run_steps(3); + assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); +} + +/// #431: the ribbon's Copy and Cut, the slide's context menu, the menus and the control +/// channel all run the commands; they put the text on the system clipboard, as Ctrl+C and +/// Ctrl+X do. +#[test] +fn copy_and_cut_commands_reach_the_system_clipboard() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let copied = |h: &egui_kittest::Harness<'_, SlideApp>| { + h.output().platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + }; + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.type_text("Aurora").unwrap(); + harness.state_mut().session.key("Escape", Mods::default()).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness), None); + + harness.state_mut().run("edit.copy", json!({})).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Copy puts the text on the clipboard"); + harness.run_steps(1); + assert_eq!(copied(&harness), None, "only once"); + + harness.state_mut().run("edit.cut", json!({})).unwrap(); + harness.run_steps(1); + assert_eq!(copied(&harness).as_deref(), Some("Aurora"), "Cut does too"); + assert!(harness.state().session.active().unwrap().doc.slides[0].shapes.iter().all(|s| s.id.0 != title), "and removes the shape"); +} + +/// #182: Ctrl+C then Ctrl+V did nothing for objects without text (a rectangle), because +/// egui-winit drops Ctrl+V when the system clipboard is empty; the context menu worked. +#[test] +fn copy_then_paste_shortcut_duplicates_an_object_without_text() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); + let count = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.doc().unwrap().current_slide().unwrap().shapes.len(); + let before = count(&harness); + + // What the copy leaves on the system clipboard is what egui-winit hands back on Ctrl+V. + assert_eq!(system_clipboard_text(""), OBJECTS_MARKER); + assert_eq!(system_clipboard_text("hello"), "hello"); + harness.event(egui::Event::Copy); + harness.run_steps(2); + harness.event(egui::Event::Paste(OBJECTS_MARKER.to_string())); + harness.run_steps(2); + assert_eq!(count(&harness), before + 1, "the shortcut pastes a copy of the rectangle"); + let d = harness.state().session.active().unwrap(); + let pasted = d.doc.slides[0].shapes.last().unwrap(); + assert!(pasted.text.as_ref().is_none_or(|t| t.text().is_empty()), "a copy of the rectangle, not a text box holding the marker"); +} + +/// #188: Ctrl/Cmd+A with the slide thumbnail pane focused selects every slide; with the +/// canvas focused it still selects shapes. +#[test] +fn select_all_follows_the_focused_pane() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + for _ in 0..2 { + harness.state_mut().run("slide.new", json!({})).unwrap(); + } + harness.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + harness.run_steps(3); + let click = |h: &mut egui_kittest::Harness<'_, SlideApp>, pos: egui::Pos2| { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(2); + }; + // Press in the thumbnail pane, then Cmd+A. + let w = harness.state().ui.thumbs_width; + click(&mut harness, egui::pos2(w / 2.0, 300.0)); + assert!(harness.state().slides_focused); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + assert_eq!(st.selection.slides.len(), 3, "every slide is selected"); + + // A press on the canvas moves focus away: Cmd+A selects shapes again. + click(&mut harness, egui::pos2(700.0, 400.0)); + assert!(!harness.state().slides_focused); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + harness.run_steps(2); + let st = harness.state().session.active().unwrap(); + assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes"); +} + +fn harness() -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::new(), Services::default()), + ) +} + +/// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws +/// the open error right there (the status bar is hidden while the backstage is open, so the +/// message used to vanish). +#[test] +fn missing_recent_is_marked_and_its_error_shows() { + let mut harness = harness(); + let missing = "/deckcraft-tests/172/missing.deckcraft"; + harness.state_mut().ui.recent = vec![missing.to_string()]; + harness.run_steps(2); + // The row is labelled with the file's name. Clicking it raises the open error, and the + // drawn message is a second node that mentions the name (the OS text after it varies). + harness.get_by_label("missing.deckcraft").click(); + harness.run_steps(2); + // The file is read in the background (#350): wait for that read to finish. + for _ in 0..500 { + if harness.state().opening.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + harness.run_steps(1); + } + harness.run_steps(2); + assert!(harness.state().toast.is_some(), "the open error was raised"); + assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry"); +} + +/// #172: a Recent entry can be removed from the list. +#[test] +fn recent_entry_can_be_removed() { + let mut harness = harness(); + harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()]; + harness.run_steps(2); + let removes = harness.get_all_by_label("Remove from Recent").collect::>(); + assert_eq!(removes.len(), 2); + // The remove button must sit inside the visible part of the row even when the page's + // content is wider than the window. + assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect()); + removes[1].click(); + harness.run_steps(1); + assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]); +} + +/// The existence probe tells present files from missing ones and never touches remote paths. +#[test] +fn recent_exists_tells_missing_from_present() { + let mut app = SlideApp::new(Session::new(), Services::default()); + let mut path = std::env::temp_dir(); + path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id())); + std::fs::write(&path, b"").unwrap(); + assert!(app.recent_exists(&path.to_string_lossy()), "the file exists"); + assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft")); + assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed"); + std::fs::remove_file(&path).ok(); +} + +/// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text. +#[test] +fn the_language_dialog_marks_the_selected_text() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap(); + harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + // The anchored window settles over its first frames; click once it stays put. + harness.run_steps(4); + harness.get_by_label("Français (France)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR")); +} + +/// Review > Language > Whole presentation marks every text box, not only the selected one. +#[test] +fn the_language_dialog_marks_the_whole_presentation() { + use egui_kittest::kittest::Queryable; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let s = &mut harness.state_mut().session; + let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); + s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); + s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + harness.run_steps(4); + harness.get_by_label("Whole presentation").click(); + harness.run_steps(2); + harness.get_by_label("Español (España)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + for sl in &d.doc.slides { + let body = sl.shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); + } +} + +/// With nothing selected, Review > Language applies to the whole presentation without +/// choosing it, and Selected text can't be picked. +#[test] +fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let s = &mut harness.state_mut().session; + let title = s.active().unwrap().doc.slides[0].shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hola"})).unwrap(); + s.execute("slide.new", &json!({"title": "Adiós"})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty()); + harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); + harness.run_steps(4); + assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled()); + harness.get_by_label("Español (España)").click(); + harness.run_steps(2); + harness.get_by_label("OK").click(); + harness.run_steps(2); + assert!(harness.state().dialog.is_none()); + let d = harness.state().session.active().unwrap(); + for sl in &d.doc.slides { + let body = sl.shapes[0].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES")); + } +} + +/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold +/// heading, Enter in a bulleted point, then saving as .pptx and reopening. +#[test] +fn editing_notes_in_the_pane_keeps_their_formatting() { + use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody}; + let point = |runs: Vec| Paragraph { + level: 1, + props: ParaProps { + margin_left: Some(22.5), + indent: Some(-22.5), + bullet: Some(Bullet::Char { char: "§".into() }), + bullet_font: Some("Wingdings".into()), + ..Default::default() + }, + runs, + ..Default::default() + }; + let bold = RunProps { bold: Some(true), ..Default::default() }; + let notes = TextBody { + paragraphs: vec![ + Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() }, + Paragraph::new("Žiaci pracujú vo dvojiciach."), + point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]), + point(vec![Run::new("Reflexia")]), + Paragraph::new("Pomôcky: počítač"), + ], + ..Default::default() + }; + let mut session = Session::with_new(); + session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + harness.run_steps(2); + let id = egui::Id::unique("notes_edit"); + let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { + let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); + egui::TextEdit::store_state(&h.ctx, id, st); + }; + let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap(); + let text = notes.text(); + harness.ctx.memory_mut(|m| m.request_focus(id)); + harness.run_steps(1); + // Type into the bold heading. + caret(&mut harness, chars(&text, "prehľad")); + harness.run_steps(1); + harness.event(egui::Event::Text(" hodiny".into())); + harness.run_steps(1); + // Enter after a bulleted point, and type the next one. + let text = text.replacen("prehľad", "prehľad hodiny", 1); + caret(&mut harness, chars(&text, "cyklus")); + harness.run_steps(1); + harness.key_press(egui::Key::Enter); + harness.event(egui::Event::Text("Nový bod".into())); + harness.run_steps(1); + // Leave the field: the notes are saved. + harness.ctx.memory_mut(|m| m.surrender_focus(id)); + harness.run_steps(2); + let check = |n: &TextBody| { + assert_eq!( + n.text(), + "Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač" + ); + assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}"); + assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(n.paragraphs[1].props, ParaProps::default()); + assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label"); + assert_eq!(n.paragraphs[2].runs[1].props.bold, None); + for i in [2, 3, 4] { + assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}"); + } + assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default())); + }; + check(&harness.state().session.active().unwrap().doc.slides[0].notes); + // Saved as .pptx and reopened. + let s = &mut harness.state_mut().session; + let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone(); + s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap(); + check(&s.active().unwrap().doc.slides[0].notes); +} + +fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), services), + ); + h.run_steps(2); + h +} + +/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. +#[test] +fn copy_offers_html_and_paste_keeps_bold() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(None)); + let record = seen.clone(); + let services = Services { + clipboard_set_html: Some(Box::new(move |html, plain| { + *record.borrow_mut() = Some((html.to_string(), plain.to_string())); + true + })), + ..Default::default() + }; + let mut h = step_app(services); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + h.event(egui::Event::Text("Hi".into())); + h.run_steps(1); + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let (html, plain) = seen.borrow().clone().expect("html offered"); + assert!(html.contains(""), "{html}"); + assert_eq!(plain, "Hi"); + assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_)))); + + let services = Services { clipboard_html: Some(Box::new(|| Some("

Hi

".into()))), ..Default::default() }; + let mut h = step_app(services); + h.event(egui::Event::Paste("Hi".into())); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + let body = st + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi")) + .and_then(|sh| sh.text.clone()) + .expect("pasted text"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); +} + +/// A shape with no text copies out as the objects marker (not a picture), so Ctrl+V can +/// still paste it here (#182). +#[test] +fn a_textless_shape_copies_as_the_objects_marker() { + let mut h = step_app(Services::default()); + h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.0]})).unwrap(); + h.event(egui::Event::Copy); + h.run_steps(1); + let cmds = &h.output().platform_output.commands; + assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); +} + +fn redder(px: [u8; 4]) -> i32 { + px[0] as i32 - px[1].max(px[2]) as i32 +} + +/// A fade entrance is hidden at the start of the preview clock and opaque once it has played. +#[test] +fn preview_hides_an_entrance_until_it_plays() { + let mut session = Session::with_new(); + let text = session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 220.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("text.set", &json!({"id": text, "text": "Alpha\nBeta"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + session.execute("animation.options", &json!({"textBuild": "byParagraph"})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let shape = deckcraft_model::ShapeId(text as u32); + let para = |time: f64, i: usize| preview::shape_state_at(&tl, shape, time).paras.into_iter().find(|(p, _)| *p == i).map(|(_, s)| s).unwrap(); + assert!(!para(0.0, 0).visible && !para(0.0, 1).visible, "entrances have not started"); + let mid = para(0.25, 0); + assert!(mid.visible && mid.opacity > 0.05 && mid.opacity < 0.95, "{mid:?}"); + assert!(!para(0.25, 1).visible, "the next paragraph waits for its step"); + + session.execute("animation.clear", &json!({})).unwrap(); + let id = session.execute("shape.insert", &json!({"preset": "rect", "rect": [400.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + session.execute("shape.fill", &json!({"id": id, "color": "#CC2200"})).unwrap(); + session.execute("animation.set", &json!({"effect": "fade", "duration": 1000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let tl = deckcraft_anim::ShowState::timeline(&doc, 0); + let (w, h) = (doc.slide_size.width.round().max(1.0) as u32, doc.slide_size.height.round().max(1.0) as u32); + let paint = |time: Option| { + let f = |sid: deckcraft_model::ShapeId| preview::shape_state_at(&tl, sid, time.unwrap_or(0.0)); + let state: Option<&dyn Fn(deckcraft_model::ShapeId) -> deckcraft_render::ShapeState> = if time.is_some() { Some(&f) } else { None }; + let opts = deckcraft_render::RenderOpts { scale: 1.0, size: Some((w, h)), state, ..Default::default() }; + deckcraft_render::render_slide(&doc, 0, &opts).pixel(460, 340) + }; + let rest = paint(None); + let start = paint(Some(0.0)); + let end = paint(Some(tl.total_duration() + 0.05)); + assert!(redder(rest) > 40, "the shape is filled at rest {rest:?}"); + assert!(redder(start) < redder(rest) / 2, "hidden at the first preview frame {start:?} vs {rest:?}"); + assert!(redder(end) > 40, "visible again once the sequence has played {end:?}"); +} + +#[test] +fn animation_preview_plays_on_the_canvas_then_stops() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(0.05).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let none = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(none["playing"], false); + assert!(harness.state().status.as_ref().is_some_and(|s| s.0.contains("no animations"))); + assert!(harness.state().show.is_none()); + + let id = harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [200.0, 280.0, 180.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("animation.set", &json!({"effect": "fade", "duration": 400})).unwrap(); + let started = harness.state_mut().run("animation.preview", json!({})).unwrap(); + assert_eq!(started["playing"], true); + assert!(harness.state().show.is_none(), "preview stays on the editor canvas"); + harness.run_steps(1); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!(elapsed < 0.02, "the clock latches on the first frame, got {elapsed}"); + let renders = harness.state().textures.canvas_renders; + harness.run_steps(6); + let elapsed = harness.state().anim_preview.as_ref().map(|p| p.elapsed).unwrap(); + assert!((0.2..0.5).contains(&elapsed), "{elapsed}"); + assert!(harness.state().textures.canvas_renders > renders, "each preview frame redraws the slide"); + assert!(harness.state().show.is_none()); + + harness.key_press(egui::Key::Escape); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "Escape stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + harness.state_mut().run("animation.preview", json!({"on": false})).unwrap(); + assert!(harness.state().anim_preview.is_none()); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + let centre = harness.state().slide_rect.unwrap().center(); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(2); + assert!(harness.state().anim_preview.is_none(), "a click on the slide stops the preview"); + + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_none(), "leaving the slide stops the preview"); + + harness.state_mut().session.execute("slide.previous", &json!({})).unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + harness.state_mut().run("animation.preview", json!({})).unwrap(); + harness.run_steps(1); + assert!(harness.state().anim_preview.is_some()); + // 400 ms effect plus the hold. + harness.run_steps(30); + assert!(harness.state().anim_preview.is_none(), "the preview ends after the sequence"); + assert!(harness.state().show.is_none()); +} + +fn flick_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + // Fitted: the slide is smaller than the window, so a flick turns the page instead of panning. + harness.state_mut().ui.zoom = Some(0.4); + harness +} + +fn slide_at(app: &SlideApp) -> usize { + app.session.active().unwrap().selection.slide +} + +/// One frame of a trackpad flick: four short samples, each under egui's cutoff for smoothing a +/// mouse notch. The harness runs one frame per queued event, so they have to share a frame or +/// none of them reaches the page threshold. +fn flick(harness: &mut egui_kittest::Harness<'static, SlideApp>, delta: egui::Vec2) { + let centre = harness.state().canvas_rect.unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))).center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + let step = delta / 4.0; + for _ in 0..4 { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: step, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Trackpad, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); +} + +#[test] +fn page_flick_uses_the_stronger_axis() { + assert_eq!(crate::canvas::page_flick(egui::vec2(-24.0, -4.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(24.0, 4.0)), Some(false)); + assert_eq!(crate::canvas::page_flick(egui::vec2(-4.0, -24.0)), Some(true)); + assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); +} + +/// Test helper: place the pointer over the canvas centre, inject wheel notches, and step once. +fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { + let centre = harness.state().canvas_rect.unwrap().center(); + let input = harness.input_mut(); + input.events.push(egui::Event::PointerMoved(centre)); + for &y in notches { + input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }); + } + harness.run_steps(1); +} + +#[test] +fn mouse_wheel_keeps_fast_notches_in_both_directions_without_a_smoothed_tail() { + let mut h = flick_harness(); + for _ in 0..8 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 1, "one notch responds immediately"); + mouse_wheel(&mut h, &[-1.0]); + assert_eq!(slide_at(h.state()), 2, "the next frame does not lose a notch to the cooldown"); + mouse_wheel(&mut h, &[-1.0, -1.0]); + assert_eq!(slide_at(h.state()), 4, "notches batched into a frame are retained"); + mouse_wheel(&mut h, &[-3.0]); + assert_eq!(slide_at(h.state()), 7, "an accelerated wheel event keeps its steps"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 7, "smoothing must not turn more pages"); + mouse_wheel(&mut h, &[2.0]); + assert_eq!(slide_at(h.state()), 5, "reversing direction responds immediately"); + mouse_wheel(&mut h, &[100.0]); + assert_eq!(slide_at(h.state()), 0, "stop at the first slide"); + mouse_wheel(&mut h, &[-100.0]); + assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); +} + +/// A high-resolution wheel on Windows sends one notch as eight 0.125-line events. +#[test] +fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() { + let mut h = flick_harness(); + for _ in 0..4 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + for _ in 0..7 { + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page"); + } + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page"); + + mouse_wheel(&mut h, &[-0.5]); + mouse_wheel(&mut h, &[0.5, 0.25]); + assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction"); + mouse_wheel(&mut h, &[0.25]); + assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); +} + +/// A fraction of a line left over in one presentation does not carry into another. +#[test] +fn mouse_wheel_fraction_does_not_carry_into_another_presentation() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 0); + + let a = h.state().session.active_index().unwrap(); + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.25]); + assert_eq!(slide_at(h.state()), 0, "a quarter line in the new presentation does not turn the page"); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 1, "a whole line in the new presentation does"); + + // Back in the first one, leave 0.75 of a line, then switch away and back with the pointer + // off the canvas (on the tabs): the canvas never sees a hover in the other presentation. + let b = h.state().session.active_index().unwrap(); + h.state_mut().session.set_active(a); + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.75]); + assert_eq!(slide_at(h.state()), 0); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.run_steps(1); + h.state_mut().session.set_active(b); + h.run_steps(2); + h.state_mut().session.set_active(a); + h.run_steps(2); + mouse_wheel(&mut h, &[-0.25]); + assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); +} + +/// Wheel input over a zoomed canvas pans it, while the same input outside the canvas must not +/// navigate slides. +#[test] +fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { + let mut h = flick_harness(); + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.state_mut().ui.zoom = Some(3.0); + h.run_steps(2); + let before = h.state().slide_rect.unwrap(); + mouse_wheel(&mut h, &[-3.0]); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0); + assert!(h.state().slide_rect.unwrap().min.y < before.min.y, "the enlarged canvas scrolls"); + + h.state_mut().ui.zoom = Some(0.4); + h.run_steps(2); + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(-10.0, -10.0))); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 0, "wheel input outside the canvas is ignored"); +} + +/// A sideways swipe on a fitted slide turns the page, like PowerPoint on a Mac trackpad. +/// The frames of one gesture do not skip ahead. +#[test] +fn a_sideways_trackpad_swipe_turns_the_page_and_a_second_flick_waits() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + assert_eq!(slide_at(harness.state()), 0); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "swipe left goes to the next slide"); + + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 1, "the next frame of the same gesture does not skip a slide"); + + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "swipe right goes back"); +} + +#[test] +fn a_vertical_wheel_still_turns_the_page_and_a_zoomed_slide_pans_instead() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-2.0, -24.0)); + assert_eq!(slide_at(harness.state()), 1, "a vertical wheel still goes to the next slide"); + + harness.state_mut().ui.zoom = Some(3.0); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(slide_at(harness.state()), 0, "zoomed in, a sideways swipe pans instead of turning the page"); +} + +/// `show.start {from}` that names no slide of the deck is refused, over `run` and over the +/// control channel, instead of starting at slide 0 or on the end screen. A hidden slide is a +/// slide: From Current Slide plays it. +#[test] +fn show_start_refuses_a_from_that_is_not_a_slide() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.hide", &json!({"index": 1, "hidden": true})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + let ctx = egui::Context::default(); + for from in [json!(-1), json!(1.5), json!("1"), json!(3), json!(u64::MAX)] { + assert!(app.run("show.start", json!({"from": from})).is_err(), "{from}"); + assert!(app.show.is_none(), "{from}"); + let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": from})); + let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!("{from}") }; + assert_eq!(answer["ok"], false, "{from}: {answer}"); + assert!(app.show.is_none(), "{from}"); + } + app.run("show.start", json!({"from": 1})).unwrap(); + assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.state.ended)), Some((1, false))); + app.show = None; + let (req, _rx) = control::ControlRequest::new("show.start", json!({"from": 2, "reading": true})); + let control::Outcome::Done(answer) = control::handle(&mut app, &ctx, &req) else { panic!() }; + assert_eq!(answer["ok"], true, "{answer}"); + assert_eq!(app.show.as_ref().map(|s| (s.state.slide, s.reading)), Some((2, true))); +} + +#[test] +fn a_swipe_during_the_show_turns_the_page() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1)); + harness.run_steps(30); + flick(&mut harness, egui::vec2(24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); +} + +/// The pen is drawing. A flick must not turn the page out from under the stroke. +#[test] +fn the_pen_ignores_a_swipe_during_the_show() { + let mut harness = flick_harness(); + harness.state_mut().session.execute("slide.new", &json!({})).unwrap(); + harness.state_mut().start_show(0, true); + harness.run_steps(2); + harness.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::P); + harness.run_steps(1); + flick(&mut harness, egui::vec2(-24.0, 0.0)); + assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0)); +} + +/// Preferences › Interface size (#767): dragging the slider leaves the interface size alone +/// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag. +#[test] +fn interface_size_applies_when_the_slider_is_let_go() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(4); + let before = harness.state().ui.ui_scale; + let rect = + harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect(); + let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0)); + harness.hover_at(from); + harness.run_steps(1); + harness.drag_at(from); + harness.run_steps(1); + for k in 1..=8 { + harness.hover_at(from.lerp(to, k as f32 / 8.0)); + harness.run_steps(1); + } + assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held"); + harness.drop_at(to); + harness.run_steps(2); + let after = harness.state().ui.ui_scale; + assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}"); + assert!(harness.state().dialog.is_some(), "Preferences stays open"); +} +/// The status bar's zoom slider has no room for a visible label; it still needs an accessible +/// name, and adding that name must not change the slider's shared control geometry at either +/// size. +#[test] +fn status_zoom_slider_is_named_without_growing_the_status_bar() { + for size in [egui::vec2(900.0, 560.0), egui::vec2(1440.0, 900.0)] { + let mut harness = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(3); + let mut zoom = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Zoom"); + let rect = zoom.next().unwrap_or_else(|| panic!("a named Zoom slider at {size:?}")).rect(); + assert!(zoom.next().is_none(), "exactly one Zoom slider"); + // egui 0.37's slider uses its own `slider_width` and the theme's control height + // (22 px), not the old hard-coded 110x18. Assert that real, shared geometry so the + // status row stays consistent with every other control instead of loosening a bound. + let style = harness.ctx.global_style(); + let (expected_w, expected_h) = (style.spacing.slider_width, style.spacing.interact_size.y); + assert!( + (rect.width() - expected_w).abs() <= 0.5 && (rect.height() - expected_h).abs() <= 0.5, + "the status-bar slider uses the shared {expected_w}x{expected_h} control size at {size:?}: {rect:?}" + ); + } +} + +/// Accessibility names survive the port: the Preferences dialog and the Format Shape fill page +/// expose no unnamed input widgets, and the fill Transparency row has one named slider rather +/// than a visible label duplicated by `Slider::text`. +#[test] +fn preferences_and_format_pane_name_every_input() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(3); + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(3); + harness.check_accessibility(); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Preferences closed before the pane check"); + + harness.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + harness.state_mut().ui.pane = Some("format".into()); + harness.state_mut().ui.format_tab = "fill".into(); + harness.run_steps(3); + harness.check_accessibility(); + let sliders: Vec<_> = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").collect(); + assert_eq!(sliders.len(), 1, "one fill Transparency slider"); + assert!(sliders[0].rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", sliders[0].rect()); +} + +/// A control `ui.screenshot` is admitted once, issued from a settled real UI pass, completed by +/// kittest's screenshot callback, and replied to exactly once after a logic drain. +#[test] +fn ui_screenshot_round_trips_through_the_callable_slot() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + let (tx, rx) = std::sync::mpsc::channel(); + harness.state_mut().control_rx = Some(rx); + let (req, reply) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(req).unwrap(); + harness.run_steps(2); + assert_eq!(harness.state().capture.pending(), 1, "the request is admitted"); + harness.state_mut().capture.force_settle(); + harness.run_steps(4); + let reply = reply.recv_timeout(std::time::Duration::from_secs(5)).expect("screenshot reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(reply["result"]["width"].as_u64().is_some_and(|w| w > 0)); + assert_eq!(harness.state().capture.pending(), 0); + assert_eq!(harness.state().capture.reserved_bytes(), 0, "the reservation is released after the reply"); +} + +/// A hidden/logic-only iteration drains control requests and autosave/screenshot queues; it does +/// not need a painted frame for those side effects. +#[test] +fn logic_drains_control_without_a_ui_pass() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let (req, reply) = ControlRequest::new("ui.inspect", json!({})); + tx.send(req).unwrap(); + app.logic(&ctx); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("reply from a logic-only call"); + assert_eq!(v["ok"], true, "{v}"); +} + +/// One synthetic pointer batch is injected at most once while it waits for a real UI pass. +/// eframe carries the whole `RawInput` from a logic-only frame into the next full pass, so a +/// document or layout change between them must retract the already-appended events too. +#[test] +fn synthetic_input_is_one_pending_batch_and_never_replays() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the one pending event is injected once"); + assert!(app.synthetic.is_empty()); + // eframe's next logic-only frame hands the same RawInput back to the hook. The batch is + // still unacknowledged, so it must not be appended again. + let mut carried = raw.clone(); + app.raw_input_hook(&ctx, &mut carried); + assert_eq!(carried.events.len(), 1, "the unacknowledged batch is not injected twice"); + + // A settled UI pass acknowledges it; the next batch may then be injected. This test has + // no renderer, so clear the frame's texture uploads explicitly: egui 0.37 panics when a + // `TexturesDelta` with pending uploads is dropped. + app.fonts_ready = true; + app.styled = true; + ctx.run_ui(Default::default(), |ui| app.ui(ui)).drop_without_applying_deltas(); + + // Changing the document before the next pass drops a batch queued for the old one. + app.queue_input(egui::Event::PointerMoved(egui::pos2(20.0, 20.0))); + app.session.execute("file.new", &json!({})).unwrap(); + let mut raw2 = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw2); + assert!(raw2.events.is_empty(), "a batch queued for the old document is dropped"); + assert!(app.synthetic.is_empty()); + + // A pending batch for the current document is injected, then a document change before + // the next pass must retract it from the carried RawInput while keeping the newer click + // queued for the new document. + // The host supplies an identical pointer event before the injected batch: retraction must + // remove the batch at its recorded index, not the first identical window in the batch. + let moved = egui::Event::PointerMoved(egui::pos2(30.0, 30.0)); + app.queue_input(moved.clone()); + let mut raw3 = egui::RawInput::default(); + raw3.events.push(moved.clone()); + app.raw_input_hook(&ctx, &mut raw3); + assert_eq!(raw3.events, vec![moved.clone(), moved.clone()], "a settled pass permits the next batch"); + app.session.execute("file.new", &json!({})).unwrap(); + let newer = egui::Event::PointerMoved(egui::pos2(40.0, 40.0)); + app.queue_input(newer.clone()); + let host = egui::Event::Text("host".into()); + let mut carried3 = raw3.clone(); + carried3.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut carried3); + assert_eq!( + carried3.events, + vec![moved, host, newer], + "the stale batch is retracted, host input and the newer click for the changed document are kept" + ); + assert!(app.synthetic.is_empty(), "the newer batch was injected, not cleared with the stale one"); + assert!(app.synthetic_pending, "the newer batch is now the one in flight"); + + let mut raw4 = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw4); + assert!(raw4.events.is_empty(), "the in-flight batch is not injected twice"); +} + +/// The first real pass installs the interface fonts and asks egui to discard it before any +/// layout. A control click that arrived in that same pass has already been consumed by egui, +/// so the app must put it back in the queue for the next frame instead of acknowledging and +/// losing it. +#[test] +fn a_control_click_on_the_first_font_discard_is_requeued_and_lands() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let pass = |app: &mut SlideApp, time: f64| { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + }; + pass(&mut app, 1.0); + let size = app.session.active().unwrap().doc.slide_size; + app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + app.session.execute("edit.deselect", &json!({})).unwrap(); + pass(&mut app, 2.0); + let point = app.slide_rect.expect("the canvas has been laid out").center(); + assert!(app.session.active().unwrap().selection.shapes.is_empty()); + + // Back to the first-frame state: the next pass installs fonts and is discarded. + app.styled = false; + app.fonts_ready = false; + app.queue_input(egui::Event::PointerMoved(point)); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + pass(&mut app, 3.0); + assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the discarded pass did not run the canvas"); + assert!(!app.synthetic.is_empty(), "the click was requeued for the next frame"); + + // One pointer event is injected per pass: move, press, release. + for time in [4.0, 5.0, 6.0] { + pass(&mut app, time); + } + assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the requeued click selected the slide's shape"); + assert!(!app.synthetic_pending && app.synthetic.is_empty(), "the click was consumed exactly once"); +} + +/// A modal opening changes what a queued window-point event means even when the document and +/// canvas are unchanged; the injected batch is retracted before it reaches the modal. +#[test] +fn synthetic_batch_is_retracted_when_a_modal_opens_first() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "the click queued for the canvas does not reach the modal"); + assert!(!app.synthetic_pending); +} + +/// A control click is admitted for the full pass it was queued for. In that pass egui has +/// already derived `PointerState` from the injected press/release, so retracting only the raw +/// events would still let the click reach a document opened by the same pass's logic. The +/// control request therefore waits: the old target's real `logic` + `ui` pass consumes the +/// whole pointer gesture, and the next pass (no synthetic input) applies the document change. +#[test] +fn synthetic_click_is_consumed_on_its_old_target_before_logic_changes_the_document() { + // The destination has one shape over the whole slide, so a click that leaked into it + // would leave the shape selected. Opening it creates a fresh document state with an + // empty selection. + let mut destination = Session::with_new(); + let size = destination.active().unwrap().doc.slide_size; + destination.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + let data = destination.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let pass = |app: &mut SlideApp, time: f64| { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + }; + + pass(&mut app, 1.0); + app.run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap(); + assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the old document has a selection to clear"); + pass(&mut app, 2.0); + let old = app.session.active().unwrap().uid; + let slide = app.slide_rect.expect("the canvas has been laid out"); + // Well away from the inserted rectangle, so a press on the bare canvas clears the + // selection in the old document. + let point = egui::pos2(slide.min.x + slide.width() * 0.8, slide.min.y + slide.height() * 0.8); + app.queue_input(egui::Event::PointerMoved(point)); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let (req, reply) = + ControlRequest::new("engine.execute", json!({"command": "file.openBytes", "params": {"name": "destination.deckcraft", "data": data}})); + tx.send(req).unwrap(); + + // `raw_input_hook` admits one pointer event per pass. Every one of those passes must stay + // on the old target; the control request may not create the destination before ui sees + // the press/release. + for time in [3.0, 4.0, 5.0] { + pass(&mut app, time); + assert_eq!(app.session.active().unwrap().uid, old, "the click's pass stays on its captured target"); + assert_eq!(app.session.documents().len(), 1, "logic must not open the destination under the click"); + } + assert!(!app.synthetic_pending, "the full passes consumed the gesture"); + assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the press selected the bare canvas on the old document"); + + // With no synthetic input pending, the next logic applies the deferred request. The + // destination's full-slide shape is unselected because no pointer state from the old + // pass was replayed into it. + pass(&mut app, 6.0); + let reply = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the click"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.session.documents().len(), 2); + let active = app.session.active().unwrap(); + assert_ne!(active.uid, old, "the destination opened only after the click was consumed"); + assert!(!active.doc.slides[0].shapes.is_empty(), "the destination really has a clickable shape"); + assert!(active.selection.shapes.is_empty(), "the old pass's click did not select the destination shape"); +} + +/// A queued control click belongs to the full-screen show, opening window and palette +/// selection that were on screen when it was queued; each transition is its own target. +#[test] +fn input_target_tracks_slideshow_opening_and_palette_selection() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let base = app.input_target(); + app.start_show(0, true); + assert_ne!(app.input_target(), base, "a slideshow is a different input target"); + let started = app.input_target(); + app.show.as_mut().unwrap().state.step += 1; + let stepped = app.input_target(); + assert_ne!(stepped, started, "the show step changes which click areas are live"); + let reading_now = app.show.as_ref().unwrap().reading; + app.show.as_mut().unwrap().reading = !reading_now; + let reading = app.input_target(); + assert_ne!(reading, stepped, "the reading layout moves the slide"); + app.show.as_mut().unwrap().grid = true; + let grid = app.input_target(); + assert_ne!(grid, reading, "See All Slides turns a click into a thumbnail choice"); + app.show.as_mut().unwrap().ended = true; + let ended = app.input_target(); + assert_ne!(ended, grid, "the end screen has different click targets"); + app.show = None; + assert_eq!(app.input_target(), base, "leaving the show returns to the editor target"); + + // Starting the show again is a new instance even at the same slide, so an old batch cannot + // match by value after an ABA transition. + app.start_show(0, true); + let first_instance = app.input_target(); + app.start_show(0, true); + assert_ne!(app.input_target(), first_instance, "a new show instance is a new target"); + app.show = None; + + app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); + assert_ne!(app.input_target(), base, "the background-open window is a different target"); + let opening = app.input_target(); + app.opening.as_mut().unwrap().generation = 2; + assert_ne!(app.input_target(), opening, "reopening the same path is a new attempt"); + app.opening = None; + + app.palette = Some(("open".into(), 0)); + let first = app.input_target(); + app.palette = Some(("open".into(), 1)); + assert_ne!(app.input_target(), first, "the palette's highlighted entry is part of the target"); + app.palette = None; + + app.ui.tab = "Insert".into(); + assert_ne!(app.input_target(), base, "another ribbon tab is another click target"); + let insert = app.input_target(); + app.ui.ribbon_collapsed = true; + assert_ne!(app.input_target(), insert, "collapsing the ribbon changes the layout under a click"); + app.ui.ribbon_collapsed = false; + app.ribbon_scroll.insert("Insert".into(), 64.0_f32.to_bits()); + assert_ne!(app.input_target(), insert, "scrolling the ribbon moves the buttons under a click"); + app.ui.tab = "Home".into(); + app.ribbon_scroll.remove("Insert"); + assert_eq!(app.input_target(), base); +} + +/// A click queued for the editor is retracted when a slideshow, background-open window or +/// different palette entry takes over before the input pass. +#[test] +fn input_target_changes_retract_a_queued_click() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let pointer = || egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); + + app.queue_input(pointer()); + app.start_show(0, true); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for the editor does not enter the slideshow"); + assert!(app.synthetic.is_empty()); + app.show = None; + + app.queue_input(pointer()); + app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for the editor does not enter the opening window"); + app.opening = None; + + app.palette = Some(("open".into(), 0)); + app.queue_input(pointer()); + app.palette = Some(("open".into(), 1)); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for one palette selection does not activate another"); + assert!(app.synthetic.is_empty()); +} + +/// A logic-only callback does not consume events, but eframe carries the whole RawInput into +/// the next full pass. The hook injects without guessing the phase; when a control request +/// drained by that logic-only callback changes the document, the carried batch is retracted at +/// the next hook instead of being delivered into the new document. +#[test] +fn hidden_logic_pass_carries_a_synthetic_batch_and_drops_it_when_the_document_changes() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut hidden = egui::RawInput::default(); + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + assert_eq!(hidden.events.len(), 1, "the batch is injected into the RawInput eframe will carry"); + assert!(app.synthetic.is_empty()); + assert!(app.synthetic_pending, "run_logic leaves the batch unacknowledged"); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the hidden pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2, "the hidden pass opened another document"); + assert!(app.synthetic_pending, "a logic-only callback cannot retract the carried batch"); + + // eframe hands the hidden RawInput to the next visible pass, appending new host input. + let mut visible = hidden.clone(); + visible.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + let host = egui::Event::Text("host".into()); + visible.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut visible); + assert_eq!(visible.events, vec![host.clone()], "the click queued for the old document is dropped"); + assert!(app.synthetic.is_empty()); + ctx.run_ui(visible, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(app.synthetic.is_empty(), "nothing replays after the visible pass"); +} + +/// eframe may run logic-only for a *visible* root when a redraw deadline is overdue. The +/// previous pass's InputState is still live, so retraction must not drain an identical event +/// from it; the batch belongs to the carried RawInput and is removed at the next hook. +#[test] +fn visible_logic_pass_does_not_retract_from_the_previous_input_state() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let pointer = egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); + + // Live InputState from the previous full pass, with an identical event at the same index. + let mut previous = egui::RawInput { time: Some(1.0), ..Default::default() }; + previous.events.push(pointer.clone()); + ctx.run_ui(previous, |_ui| {}).drop_without_applying_deltas(); + + app.queue_input(pointer.clone()); + let mut raw = egui::RawInput { time: Some(2.0), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events, vec![pointer.clone()]); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the visible logic pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2); + assert!(app.synthetic_pending, "the carried batch is still pending"); + assert_eq!(ctx.input(|i| i.events.clone()), vec![pointer.clone()], "the previous pass's events are untouched"); + + let mut next = raw.clone(); + next.time = Some(3.0); + let host = egui::Event::Text("host".into()); + next.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut next); + assert_eq!(next.events, vec![host.clone()], "the stale batch is removed from the carried RawInput"); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty()); +} + +/// A batch injected in a logic-only pass is carried into the full pass with a refreshed +/// `RawInput::time`. That full pass must consume it on the old target: a control request +/// drained by the same logic waits until the next pass instead of changing the document +/// after egui has already derived the carried pointer state. +#[test] +fn carried_batch_is_consumed_before_the_full_pass_applies_a_control_change() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut hidden = egui::RawInput { time: Some(1.0), ..Default::default() }; + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.synthetic_pending, "the logic-only pass keeps the carried batch"); + + // eframe refreshes the carried input's time before calling the hook for the full pass. + let mut full = hidden.clone(); + full.time = Some(2.0); + app.raw_input_hook(&ctx, &mut full); + assert!(app.synthetic_pending, "the carried batch is not injected twice"); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + ctx.run_ui(full, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert_eq!(app.session.documents().len(), 1, "the full pass's UI sees its captured document"); + assert!(!app.synthetic_pending, "the full pass consumed the carried batch"); + + // The next pass has no synthetic input, so logic applies the deferred request. + let mut next = egui::RawInput { time: Some(3.0), ..Default::default() }; + next.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut next); + ctx.run_ui(next, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the full pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2, "the deferred control request ran on the next pass"); +} + +/// `visible() == false` does not prove a logic-only pass: a screenshot or +/// `RequestPaintWhileHidden` makes eframe run the full UI. The synthetic batch must be +/// delivered there, not postponed until the viewport is visible. +#[test] +fn hidden_full_pass_receives_synthetic_ui_input() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + app.queue_input(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + let ctx = egui::Context::default(); + let mut hidden = egui::RawInput { time: Some(4.0), ..Default::default() }; + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + assert_eq!(hidden.events.len(), 1, "a forced hidden full pass still gets the event"); + assert!(app.synthetic_pending); + + ctx.run_ui(hidden, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(app.dialog.is_none(), "the synthetic Escape reached the UI in the hidden full pass"); + assert!(!app.synthetic_pending, "the full pass acknowledged the batch"); + assert!(app.synthetic.is_empty()); +} + +/// egui 0.37 carries a clipboard image as `Event::PasteImage`; it inserts once, without +/// asking the host clipboard service for the same image again. +#[test] +fn paste_image_event_inserts_one_picture() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)); + harness.event(egui::Event::PasteImage(std::sync::Arc::new(image))); + harness.run_steps(2); + let after = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + assert_eq!(after, before + 1, "one image is inserted"); +} + +/// A minimized/occluded show keeps playing from logic alone: the clock advances on wall +/// time, an After transition advances the slide without a draw, and a visible pass resumes +/// from the show state instead of inventing a paused-show policy. +#[test] +fn hidden_viewport_advances_show_and_presenter_clocks_from_logic() { + /// Nested helper in the hidden-show test: build root `RawInput` with the given time and + /// visibility flags. + fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + raw + } + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 0, "after": 1000})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); + app.start_show(0, false); + let ctx = egui::Context::default(); + + // A real visible UI pass starts the show and its clocks. There is no renderer here, so + // each frame's texture uploads are cleared explicitly (egui 0.37 panics on drop). + ctx.run_ui(raw_at(10.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let start = app.show.as_ref().expect("show started").clock_snapshot(); + assert_eq!(start.step_start, 10.0); + assert_eq!(start.show_start, 10.0); + + // Hidden logic starts at the last visible egui time, then advances on the monotonic + // instant. Two seconds later the slide's After: 1 s transition has advanced the show. + let t0 = web_time::Instant::now(); + app.show.as_mut().expect("show started").observe_visible_frame(10.0, t0); + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0)); + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(2))); + let hidden = app.show.as_ref().expect("hidden show"); + assert_eq!(hidden.state.slide, 1, "the After transition advances without a draw"); + assert!((hidden.clock_snapshot().slide_start - 12.0).abs() < 1e-9); + assert!((hidden.presenter_elapsed(12.0) - 2.0).abs() < 1e-9, "presenter time keeps running"); + + // A later hidden tick does not replay the transition or stack time. + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(3))); + assert_eq!(app.show.as_ref().unwrap().state.slide, 1); + assert!((app.show.as_ref().unwrap().clock_snapshot().slide_start - 12.0).abs() < 1e-9); + + // A visible pass re-anchors to egui's real time and keeps the state it reached hidden. + ctx.run_ui(raw_at(20.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let resumed = app.show.as_ref().expect("show resumed"); + assert_eq!(resumed.state.slide, 1); + assert!((resumed.presenter_elapsed(20.0) - 10.0).abs() < 1e-9); + assert!((resumed.clock_snapshot().slide_start - 12.0).abs() < 1e-9); +} + +/// A visible window can receive an overdue logic-only pass before the next paint. That pass +/// still carries the previous full frame's `InputState::time`; treating it as a visible frame +/// re-anchors the hidden clock to the later monotonic instant and loses elapsed show time. +/// Re-anchoring belongs to `show::ui`, which only runs on a real visible pass. +#[test] +fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { + /// Nested helper in the overdue-logic test: build root `RawInput` with the given time and + /// visibility flags. + fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + raw + } + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 0, "after": 2500})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); + app.start_show(0, false); + let ctx = egui::Context::default(); + + // The full visible pass is the only pass that anchors the show: egui time 10. + ctx.run_ui(raw_at(10.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let visible_anchor = web_time::Instant::now(); + + // An overdue visible logic-only pass does not refresh InputState::time. It must not move + // the hidden anchor from the full visible frame to this later instant. + let _ = ctx.run_logic(&raw_at(12.0, false, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(2))); + assert_eq!(ctx.input(|i| i.time), 10.0, "run_logic retains the last full pass's egui time"); + assert_eq!(app.show.as_ref().unwrap().state.slide, 0); + + // Three seconds after the full visible frame is past the slide's 2.5 s After delay. If the + // logic-only pass had re-anchored at +2 s, this hidden tick would still be only +1 s and + // the slide would not advance. + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); + assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); +}