From 81072b9e0dc900fc790b24296c44e564e7107e78 Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 10 Sep 2026 21:34:12 +0200 Subject: [PATCH] Add headless UI tests with kittest --- .gitignore | 5 + Cargo.lock | 137 +++++ Cargo.toml | 3 + README.md | 35 +- src/app.rs | 15 +- src/app/tests.rs | 93 +++ src/view.rs | 944 +----------------------------- src/view/tests.rs | 933 +++++++++++++++++++++++++++++ tests/snapshots/editor-window.png | Bin 0 -> 82451 bytes 9 files changed, 1213 insertions(+), 952 deletions(-) create mode 100644 src/app/tests.rs create mode 100644 src/view/tests.rs create mode 100644 tests/snapshots/editor-window.png diff --git a/.gitignore b/.gitignore index ea8c4bf..0ff2bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /target + +# Snapshot tests write these beside the golden image when one fails. +tests/snapshots/*.new.png +tests/snapshots/*.diff.png +tests/snapshots/*.old.png diff --git a/Cargo.lock b/Cargo.lock index 58b3d74..64b184d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arboard" version = "3.6.1" @@ -632,6 +638,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.52.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -700,6 +716,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -718,6 +753,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -914,6 +962,27 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.18.0" @@ -1218,6 +1287,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1697,6 +1775,7 @@ version = "0.1.0" dependencies = [ "eframe", "egui_extras", + "egui_kittest", "image", "open", "rfd", @@ -1721,6 +1800,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -1733,6 +1822,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -2722,6 +2817,26 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -2947,6 +3062,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3265,6 +3389,19 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 64282be..f36d8eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ serde = { version = "1.0.229", features = ["derive"] } # and keeps unknown attributes where they were, so saving a file written by # another tool produces a small diff instead of a reshuffle. serde_json = { version = "1.0.151", features = ["preserve_order"] } + +[dev-dependencies] +egui_kittest = { version = "0.36.2", features = ["eframe", "wgpu", "snapshot"] } diff --git a/README.md b/README.md index fc688f3..b8cc7e0 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,39 @@ cargo build --release # target/release/jsoncanvas-desktop[.exe] needed; install `xdg-desktop-portal` and a backend for your desktop if it is not already present. -Run the tests with `cargo test` (the model, geometry, Markdown and editing logic -are covered headless — no display required). +## Tests + +`cargo test` runs everything, headless and without a display. Each module's +tests are a child module of it in a file of its own — `src/view/tests.rs` for +`src/view.rs` — so they still reach private state without the module itself +carrying a thousand lines of test code. The GUI is tested the same way as the +rest: + +* **Widgets** are driven through + [`egui_kittest`](https://crates.io/crates/egui_kittest), which builds the real + `App` on a real `egui::Context` and finds widgets by the label on them: + + ```rust + harness.get_by_label("Close & Fit").click(); + harness.run(); + assert_eq!(harness.state().view.editing(), None); + ``` + +* **Canvas gestures** — clicks, drags and the wheel on cards, which are painted + rather than built from widgets — go through the `pass` helper in `view.rs`, + which runs one frame of `CanvasView::show` against synthetic input at chosen + coordinates. + +* **How it looks** is covered by snapshot tests, which render a frame with + `wgpu` and compare it with the images in `tests/snapshots`. A failure writes + `.diff.png` and `.new.png` beside the expected image; after an + intentional change, re-record with `UPDATE_SNAPSHOTS=1 cargo test`. Rendering + falls back to Mesa's software Vulkan driver, so no GPU is needed. + +Driving the built binary with synthetic input (`xdotool` and friends) is *not* +the way to test this: under a Wayland compositor the X pointer warp is scaled, +`--sync` can block until it is killed, and window captures come back stale, so +the results are slow and misleading. ## Keyboard shortcuts diff --git a/src/app.rs b/src/app.rs index 216e25f..6aa5488 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1246,17 +1246,4 @@ fn welcome_canvas() -> Canvas { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_welcome_canvas_is_valid_and_connected() { - let canvas = welcome_canvas(); - assert_eq!(canvas.nodes.len(), 4); - assert_eq!(canvas.edges.len(), 3); - let mut copy = canvas.clone(); - assert_eq!(copy.drop_dangling_edges(), 0); - // And it round-trips through the file format. - assert_eq!(Canvas::from_json(&canvas.to_json()).unwrap(), canvas); - } -} +mod tests; diff --git a/src/app/tests.rs b/src/app/tests.rs new file mode 100644 index 0000000..c39c496 --- /dev/null +++ b/src/app/tests.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::model::{Node, NodeKind}; +use egui_kittest::{Harness, kittest::Queryable}; + +#[test] +fn the_welcome_canvas_is_valid_and_connected() { + let canvas = welcome_canvas(); + assert_eq!(canvas.nodes.len(), 4); + assert_eq!(canvas.edges.len(), 3); + let mut copy = canvas.clone(); + assert_eq!(copy.drop_dangling_edges(), 0); + // And it round-trips through the file format. + assert_eq!(Canvas::from_json(&canvas.to_json()).unwrap(), canvas); +} + +// -- the whole application, driven the way a user drives it: real widgets, +// found by the label on them and clicked, with no window, compositor or +// pointer in the way. + +/// Whether a harness can draw. Only the tests that look at a frame ask +/// for a renderer, so that the rest have nothing to say about the graphics +/// stack of the machine they run on. +enum Renderer { + None, + Wgpu, +} + +/// The app on a canvas holding one note, with `text` in it. +fn harness_with_a_note(text: &str, renderer: Renderer) -> Harness<'static, App> { + let mut canvas = Canvas::default(); + canvas.nodes.push(Node::new( + "n", + NodeKind::Text { + text: text.to_owned(), + }, + 0, + 0, + 300, + 200, + )); + let builder = Harness::builder().with_size(egui::vec2(1200.0, 900.0)); + let builder = match renderer { + Renderer::None => builder, + Renderer::Wgpu => builder.wgpu(), + }; + let mut harness = builder.build_eframe(|cc| App::new(cc, None)); + harness.state_mut().doc = Document::new(); + harness.state_mut().doc.canvas = canvas; + harness.run(); + harness +} + +/// The shell is real widgets too, found by the label on them. +#[test] +fn the_toolbar_adds_a_note_to_the_canvas() { + let mut harness = harness_with_a_note("the only one", Renderer::None); + assert_eq!(harness.state().doc.canvas.nodes.len(), 1); + + harness.get_by_label("Note").click(); + harness.run(); + + assert_eq!(harness.state().doc.canvas.nodes.len(), 2); +} + +/// The editor window as it is drawn: the text filling it, the ways out +/// along the bottom. Run `UPDATE_SNAPSHOTS=1 cargo test` after changing it +/// on purpose, and look at `tests/snapshots/*.diff.png` when it fails. +#[test] +fn the_editor_window_is_drawn_as_it_should_be() { + let mut harness = harness_with_a_note("## A note\n\nWith text in it.", Renderer::Wgpu); + harness.state_mut().view.edit_node("n"); + harness.run(); + harness.snapshot("editor-window"); +} + +#[test] +fn close_and_fit_sizes_the_card_and_closes_the_editor() { + // More text than a 300x200 card can show. + let mut harness = harness_with_a_note(&"word ".repeat(200), Renderer::None); + harness.state_mut().view.edit_node("n"); + harness.run(); + + harness.get_by_label("Close & Fit").click(); + harness.run(); + + let app = harness.state(); + assert_eq!(app.view.editing(), None, "the editor stayed open"); + assert!( + app.doc.canvas.node("n").unwrap().height > 200, + "the card was not grown to its text" + ); + assert_eq!(app.doc.undo_label(), Some("Fit to text")); +} diff --git a/src/view.rs b/src/view.rs index 80ef01e..bc70222 100644 --- a/src/view.rs +++ b/src/view.rs @@ -366,6 +366,13 @@ impl CanvasView { self.file_edit = None; } + /// The node the editor is open on, if any. For the tests in + /// [`crate::app`], which drive the editor from outside this module. + #[cfg(test)] + pub fn editing(&self) -> Option<&str> { + self.editing.as_deref() + } + /// Opens the editor on a text or file node, or on a group's label. pub fn edit_node(&mut self, id: &str) { self.editing = Some(id.to_owned()); @@ -2450,939 +2457,4 @@ fn count_phrase(parts: &[(usize, &str)]) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::model::Canvas; - - #[test] - fn a_copy_survives_the_system_clipboard() { - let mut canvas = Canvas::default(); - let note = |id: &str, x| Node::new(id, NodeKind::Text { text: id.into() }, x, 0, 250, 60); - canvas.nodes.push(note("a", 0)); - canvas.nodes.push(note("b", 400)); - canvas - .edges - .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); - - let mut view = CanvasView::new(); - view.select_all(&canvas); - let copied = view.copy_selection(&canvas).expect("a selection to copy"); - - let back = Clipboard::from_json(&copied.to_json()).expect("the copy to be recognised"); - assert_eq!(back.nodes, copied.nodes); - assert_eq!(back.edges, copied.edges); - } - - #[test] - fn plain_text_is_not_mistaken_for_a_copy() { - for text in [ - "https://jsoncanvas.org", - "Just a thought", - "{}", - "{\"nodes\": []}", - "[1, 2, 3]", - "", - ] { - assert!( - Clipboard::from_json(text).is_none(), - "{text:?} was read as a copy" - ); - } - } - - /// Runs one egui pass over the canvas with the given input events. - fn pass( - ctx: &egui::Context, - view: &mut CanvasView, - doc: &mut Document, - screen: Rect, - events: Vec, - ) { - let palette = Palette::dark(); - let input = egui::RawInput { - screen_rect: Some(screen), - events, - ..Default::default() - }; - ctx.run_ui(input, |ui| { - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ui, |ui| { - view.show(ui, doc, &palette); - }); - }) - .drop_without_applying_deltas(); - } - - /// Like `pass`, but with a panel on the right the way the app puts the - /// inspector there. Reports whether the widget filling that panel was - /// clicked. - fn pass_beside_a_panel( - ctx: &egui::Context, - view: &mut CanvasView, - doc: &mut Document, - screen: Rect, - events: Vec, - ) -> bool { - let palette = Palette::dark(); - let input = egui::RawInput { - screen_rect: Some(screen), - events, - ..Default::default() - }; - let mut clicked = false; - ctx.run_ui(input, |ui| { - egui::Panel::right("inspector") - .default_size(300.0) - .show(ui, |ui| { - let size = ui.available_size(); - clicked = ui.add_sized(size, egui::Button::new("A control")).clicked(); - }); - egui::CentralPanel::default() - .frame(egui::Frame::NONE) - .show(ui, |ui| { - view.show(ui, doc, &palette); - }); - }) - .drop_without_applying_deltas(); - clicked - } - - fn click(at: Pos2) -> egui::Event { - egui::Event::PointerButton { - pos: at, - button: PointerButton::Primary, - pressed: true, - modifiers: egui::Modifiers::default(), - } - } - - fn release(at: Pos2) -> egui::Event { - egui::Event::PointerButton { - pos: at, - button: PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::default(), - } - } - - /// The three passes a right click takes: egui hit-tests against the - /// rectangles of the previous pass, and a click is a press and a release. - fn right_click( - ctx: &egui::Context, - view: &mut CanvasView, - doc: &mut Document, - screen: Rect, - at: Pos2, - ) { - let button = |pressed| egui::Event::PointerButton { - pos: at, - button: PointerButton::Secondary, - pressed, - modifiers: egui::Modifiers::default(), - }; - pass(ctx, view, doc, screen, vec![egui::Event::PointerMoved(at)]); - pass(ctx, view, doc, screen, vec![button(true)]); - pass(ctx, view, doc, screen, vec![button(false)]); - } - - /// A canvas saved next to `note.md`, with a node pointing at that file. - fn canvas_with_a_file_node( - subpath: Option<&str>, - ) -> (std::path::PathBuf, std::path::PathBuf, Document) { - let dir = - std::env::temp_dir().join(format!("jsoncanvas-edit-{}", crate::model::random_id())); - std::fs::create_dir_all(&dir).unwrap(); - let note = dir.join("note.md"); - std::fs::write(¬e, "# Note\nbody\n\n# Other\nkeep\n").unwrap(); - - let mut doc = Document::new(); - doc.canvas.nodes.push(Node::new( - "f", - NodeKind::File { - file: "note.md".to_owned(), - subpath: subpath.map(str::to_owned), - }, - 0, - 0, - 400, - 300, - )); - // Saved, so that the relative path in the node has a folder to resolve - // against. - doc.save_as(&dir.join("board.canvas")).unwrap(); - (dir, note, doc) - } - - /// Fitting a card means measuring contents the card itself crops, and - /// fitting several of them is one thing done, not one per card. - #[test] - fn cards_are_measured_and_fitted_to_their_text_in_one_step() { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - // Far more text than a 100x100 card can hold. - doc.canvas.node_mut("a").unwrap().kind = NodeKind::Text { - text: "word ".repeat(120), - }; - let mut view = CanvasView::new(); - view.edit_node("a"); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - - let height = view - .body_heights - .get("a") - .copied() - .expect("the card was never measured"); - assert!( - height > 100.0, - "the text measured {height}, which the card already fits" - ); - - // Both cards at once, the way the menu fits a whole selection. - view.selection.set_node("a"); - view.selection.nodes.push("b".to_owned()); - assert_eq!(view.fit_selection(&mut doc), None); - let grown = doc.canvas.node("a").unwrap().height; - assert!( - grown as f32 >= height, - "the card came out {grown} tall for {height} of text" - ); - - // And the two of them are one step to undo, not two. - assert_eq!(doc.undo(), Some("Fit to text".to_owned())); - assert_eq!(doc.canvas.node("a").unwrap().height, 100); - assert_eq!(doc.canvas.node("b").unwrap().height, 100); - assert!(!doc.can_undo(), "fitting left more than one step behind"); - } - - /// A double click on a card opens the editor wherever it lands — the - /// text of a card is not there to be selected, and egui's selectable - /// labels would take the gesture for a word. - #[test] - fn a_double_click_on_a_card_s_text_opens_the_editor() { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - doc.canvas.node_mut("a").unwrap().kind = NodeKind::Text { - text: "some words to land on".to_owned(), - }; - let mut view = CanvasView::new(); - - // The first line of the card's text, just inside the padding. - let at = pos2(30.0, 20.0); - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::PointerMoved(at)], - ); - for _ in 0..2 { - pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); - pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); - } - - assert_eq!(view.editing.as_deref(), Some("a")); - } - - /// The editor's ways out sit in a strip along the bottom of the window, - /// with the text taking everything above them. - #[test] - fn the_editor_keeps_its_actions_in_a_strip_at_the_bottom() { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.edit_node("a"); - // Twice: the window is laid out against the rectangles of the pass - // before it. - pass(&ctx, &mut view, &mut doc, screen, vec![]); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - - let window = ctx - .memory(|m| m.area_rect(Id::new("node-editor"))) - .expect("the editor window was never laid out"); - let actions = egui::containers::panel::PanelState::load(&ctx, Id::new("editor-actions")) - .expect("the editor's actions were never laid out") - .outer_rect; - assert!( - actions.height() < window.height() / 3.0, - "the actions took {} of a {} window", - actions.height(), - window.height() - ); - assert!( - actions.bottom() >= window.bottom() - 8.0, - "the actions sit at {}, not at the bottom of the window ({})", - actions.bottom(), - window.bottom() - ); - - // And the plain way out is where a user would aim for it: the far end - // of that strip. - let at = pos2(actions.right() - 30.0, actions.center().y); - pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); - pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); - assert_eq!(view.editing, None, "the button at {at:?} did not close it"); - } - - /// Every node takes an edit request; the ones with no text in them say - /// where their fields are instead of doing nothing. - #[test] - fn an_edit_request_on_a_node_with_no_text_points_at_the_inspector() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - doc.canvas.nodes.push(Node::new( - "l", - NodeKind::Link { - url: "https://example.com".to_owned(), - label: None, - }, - 0, - 300, - 200, - 60, - )); - let mut view = CanvasView::new(); - - assert_eq!(view.begin_edit(&doc, "a"), EditTarget::Editor); - assert_eq!(view.editing.as_deref(), Some("a")); - - let EditTarget::Inspector(reason) = view.begin_edit(&doc, "l") else { - panic!("the editor took a link node, which has nothing to type into"); - }; - assert!(reason.contains("inspector"), "unhelpful reason: {reason:?}"); - // And the editor it could not take the node into is left as it was. - assert_eq!(view.editing.as_deref(), Some("a")); - } - - /// The editor is a window with its own ways out, so the canvas no longer - /// dismisses it: a card can be moved and resized while it is open. - #[test] - fn clicking_the_canvas_leaves_the_editor_open() { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.edit_node("a"); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - - // Empty canvas, well clear of both nodes and of the window. - let at = pos2(800.0, 700.0); - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::PointerMoved(at)], - ); - pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); - pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); - - assert!( - view.selection.is_empty(), - "the click never reached the background" - ); - assert_eq!( - view.editing.as_deref(), - Some("a"), - "the click closed the editor" - ); - } - - #[test] - fn a_text_file_node_is_edited_in_the_editor_and_written_back() { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let (dir, note, mut doc) = canvas_with_a_file_node(None); - let mut view = CanvasView::new(); - - assert!(view.edit_file_contents(&doc, "f")); - // The first pass opens the editor and takes the keyboard; the second - // types into it. - pass(&ctx, &mut view, &mut doc, screen, vec![]); - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::Text("!".to_owned())], - ); - - // The edit is held until the canvas is saved, so the file has not - // moved yet. - assert!(view.files.is_dirty(), "the edit was not recorded"); - assert!(view.files.is_edited(¬e)); - assert_eq!( - std::fs::read_to_string(¬e).unwrap(), - "# Note\nbody\n\n# Other\nkeep\n" - ); - // Nor is the canvas itself touched by an edit to one of its files. - assert!(!doc.is_dirty()); - - assert_eq!(view.files.save_all(), (1, Vec::new())); - let written = std::fs::read_to_string(¬e).unwrap(); - assert!( - written.contains('!'), - "typing did not reach the file: {written:?}" - ); - assert!(!view.files.is_dirty()); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn editing_a_section_leaves_the_rest_of_the_file_alone() { - let ctx = egui::Context::default(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let (dir, note, mut doc) = canvas_with_a_file_node(Some("#Note")); - let mut view = CanvasView::new(); - - assert!(view.edit_file_contents(&doc, "f")); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - // The editor holds the section, not the whole file. - let edit = view.file_edit.as_ref().expect("an open file editor"); - assert_eq!(edit.buffer, "# Note\nbody\n"); - assert_eq!(edit.section, Some(0..2)); - - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::Text("!".to_owned())], - ); - view.files.save_all(); - let written = std::fs::read_to_string(¬e).unwrap(); - assert!( - written.contains('!'), - "typing did not reach the file: {written:?}" - ); - assert!( - written.ends_with("\n# Other\nkeep\n"), - "the rest of the file was rewritten: {written:?}" - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn only_a_whole_plain_text_file_is_editable() { - let (dir, note, mut doc) = canvas_with_a_file_node(None); - let mut view = CanvasView::new(); - let point_at = |doc: &mut Document, file: &str, subpath: Option<&str>| { - doc.canvas.node_mut("f").unwrap().kind = NodeKind::File { - file: file.to_owned(), - subpath: subpath.map(str::to_owned), - }; - }; - - // An image, a file that is not there, and one that is not text. - std::fs::write(dir.join("blob.txt"), [0xff, 0xfe, 0x00]).unwrap(); - for (file, subpath) in [ - ("picture.png", None), - ("gone.md", None), - ("blob.txt", None), - // A section the file does not hold cannot be put back into it. - ("note.md", Some("#Missing")), - ] { - point_at(&mut doc, file, subpath); - assert!( - !view.edit_file_contents(&doc, "f"), - "{file} {subpath:?} was offered for editing" - ); - assert!(view.file_edit.is_none()); - } - - point_at(&mut doc, "note.md", Some("#Other")); - assert!(view.edit_file_contents(&doc, "f")); - assert!(!view.files.is_edited(¬e), "looking at a file changed it"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn framing_waits_for_the_real_viewport() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - let mut view = CanvasView::new(); - - // Asked for before the first frame, when the viewport is still unknown. - view.zoom_to_fit(&doc.canvas); - assert_eq!(view.pending_fit, Some(PendingFit::Origin)); - - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - assert_eq!(view.pending_fit, None); - // The origin, not the top left corner, sits in the middle. - assert!( - view.centre_in_canvas().distance(Pos2::ZERO) < 1.0, - "empty canvas is centred on {:?}", - view.centre_in_canvas() - ); - - // Same for a canvas with content: the nodes end up in the middle. - let mut view = CanvasView::new(); - doc.canvas = canvas_with_two_nodes(); - view.zoom_to_fit(&doc.canvas); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - let bounds = bounds_of(doc.canvas.nodes.iter().map(node_rect)).unwrap(); - assert!( - view.centre_in_canvas().distance(bounds.center()) < 1.0, - "content is centred on {:?}, expected {:?}", - view.centre_in_canvas(), - bounds.center() - ); - } - - #[test] - fn the_background_still_responds_far_from_where_the_view_started() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.selection.set_node("a"); - // Panned well away from the canvas origin. - view.to_global = TSTransform::from_translation(vec2(5000.0, 4000.0)); - - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let at = pos2(600.0, 450.0); - // Three passes: egui hit-tests against the rectangles of the previous - // one, and a click is a press followed by a release. - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::PointerMoved(at)], - ); - pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); - pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); - - // Clicking empty canvas clears the selection. - assert!( - view.selection.is_empty(), - "the click never reached the background" - ); - } - - /// The bare wheel zooms wherever it is turned, cards included, and keeps - /// what is under the pointer under it. - #[test] - fn the_wheel_zooms_over_a_node() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - // Over node "a", which the identity transform puts at 0..100. - let at = pos2(50.0, 50.0); - let under_pointer = view.to_global.inverse() * at; - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::PointerMoved(at)], - ); - // egui spreads a wheel event over the frames after it, so the zoom - // arrives over several passes. - for _ in 0..10 { - pass( - &ctx, - &mut view, - &mut doc, - screen, - vec![egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, 50.0), - modifiers: egui::Modifiers::default(), - phase: egui::TouchPhase::Move, - }], - ); - } - - assert!( - view.zoom() > 1.0, - "the wheel did not zoom in: {}", - view.zoom() - ); - let moved = (view.to_global.inverse() * at).distance(under_pointer); - assert!(moved < 1.0, "the canvas slid {moved} under the pointer"); - } - - #[test] - fn a_click_in_the_inspector_stays_in_the_inspector() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.selection.set_node("a"); - - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let at = pos2(1050.0, 450.0); - let mut hit = false; - for events in [ - vec![egui::Event::PointerMoved(at)], - vec![click(at)], - vec![release(at)], - ] { - hit |= pass_beside_a_panel(&ctx, &mut view, &mut doc, screen, events); - } - - assert!(hit, "the widget in the panel never saw the click"); - assert!( - view.selection.has_node("a"), - "the canvas took a click that belonged to the panel" - ); - } - - fn canvas_with_two_nodes() -> Canvas { - let mut canvas = Canvas::default(); - canvas.nodes.push(Node::new( - "a", - NodeKind::Text { text: "a".into() }, - 0, - 0, - 100, - 100, - )); - canvas.nodes.push(Node::new( - "b", - NodeKind::Text { text: "b".into() }, - 300, - 0, - 100, - 100, - )); - canvas - } - - #[test] - fn selection_tracks_nodes_and_edges() { - let mut selection = Selection::default(); - selection.set_node("a"); - assert_eq!(selection.only_node(), Some("a")); - selection.toggle_node("b"); - assert_eq!(selection.len(), 2); - assert_eq!(selection.only_node(), None); - selection.toggle_node("b"); - assert!(selection.has_node("a")); - selection.set_edge("e"); - assert!(selection.nodes.is_empty()); - assert_eq!(selection.only_edge(), Some("e")); - } - - #[test] - fn selection_drops_ids_that_no_longer_exist() { - let canvas = canvas_with_two_nodes(); - let mut selection = Selection { - nodes: vec!["a".into(), "gone".into()], - edges: vec!["nope".into()], - }; - selection.prune(&canvas); - assert_eq!(selection.nodes, vec!["a".to_owned()]); - assert!(selection.edges.is_empty()); - } - - #[test] - fn handles_resize_from_the_right_corner() { - let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)); - let grown = Handle::SouthEast.resize(rect, vec2(20.0, 10.0)); - assert_eq!(grown.min, rect.min); - assert_eq!(grown.max, pos2(120.0, 110.0)); - - let moved = Handle::NorthWest.resize(rect, vec2(20.0, 10.0)); - assert_eq!(moved.min, pos2(20.0, 10.0)); - assert_eq!(moved.max, rect.max); - - let side = Handle::East.resize(rect, vec2(20.0, 999.0)); - assert_eq!(side.height(), rect.height()); - } - - #[test] - fn nearest_side_picks_the_closest_anchor() { - let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)); - assert_eq!(nearest_side(rect, pos2(50.0, -30.0)), Side::Top); - assert_eq!(nearest_side(rect, pos2(140.0, 50.0)), Side::Right); - assert_eq!(nearest_side(rect, pos2(50.0, 130.0)), Side::Bottom); - assert_eq!(nearest_side(rect, pos2(-40.0, 50.0)), Side::Left); - } - - #[test] - fn dangling_edges_have_no_curve() { - let canvas = canvas_with_two_nodes(); - let good = Edge::new("e", "a", Side::Right, "b", Side::Left); - assert!(curve_of(&canvas, &good).is_some()); - let bad = Edge::new("e", "a", Side::Right, "ghost", Side::Left); - assert!(curve_of(&canvas, &bad).is_none()); - } - - #[test] - fn hit_testing_finds_nodes_and_edges() { - let view = CanvasView::new(); - let mut canvas = canvas_with_two_nodes(); - canvas - .edges - .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); - assert_eq!( - view.node_at(&canvas, pos2(50.0, 50.0)).as_deref(), - Some("a") - ); - assert_eq!(view.node_at(&canvas, pos2(-5.0, 50.0)), None); - // The edge runs from (100,50) to (300,50). - assert_eq!( - view.edge_at(&canvas, pos2(200.0, 50.0)).as_deref(), - Some("e") - ); - assert_eq!(view.edge_at(&canvas, pos2(200.0, 200.0)), None); - } - - #[test] - fn groups_are_only_hit_when_nothing_covers_them() { - let mut canvas = canvas_with_two_nodes(); - canvas.nodes.insert( - 0, - Node::new( - "g", - NodeKind::Group { - label: None, - background: None, - background_style: None, - }, - -50, - -50, - 500, - 300, - ), - ); - let view = CanvasView::new(); - assert_eq!( - view.node_at(&canvas, pos2(50.0, 50.0)).as_deref(), - Some("a") - ); - assert_eq!( - view.node_at(&canvas, pos2(200.0, 200.0)).as_deref(), - Some("g") - ); - } - - #[test] - fn moving_a_group_takes_its_members_along() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - doc.canvas.nodes.insert( - 0, - Node::new( - "g", - NodeKind::Group { - label: None, - background: None, - background_style: None, - }, - -50, - -50, - 600, - 300, - ), - ); - let mut view = CanvasView::new(); - view.select_node("g"); - view.nudge(&mut doc, vec2(10.0, 0.0)); - assert_eq!(doc.canvas.node("g").unwrap().x, -40); - assert_eq!(doc.canvas.node("a").unwrap().x, 10); - assert_eq!(doc.canvas.node("b").unwrap().x, 310); - } - - #[test] - fn copy_and_paste_creates_new_ids_and_keeps_inner_edges() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - doc.canvas - .edges - .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); - let mut view = CanvasView::new(); - view.select_all(&doc.canvas); - - let clipboard = view.copy_selection(&doc.canvas).unwrap(); - assert_eq!(clipboard.nodes.len(), 2); - assert_eq!(clipboard.edges.len(), 1); - - view.paste(&mut doc, &clipboard, vec2(20.0, 20.0)); - assert_eq!(doc.canvas.nodes.len(), 4); - assert_eq!(doc.canvas.edges.len(), 2); - let ids: std::collections::HashSet<&str> = - doc.canvas.nodes.iter().map(|n| n.id.as_str()).collect(); - assert_eq!(ids.len(), 4, "pasted nodes need fresh ids"); - // The pasted edge joins the pasted nodes, not the originals. - let pasted = doc.canvas.edges.last().unwrap(); - assert!(view.selection.has_node(&pasted.from_node)); - assert!(view.selection.has_node(&pasted.to_node)); - } - - #[test] - fn deleting_a_node_removes_its_edges_and_reports_it() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - doc.canvas - .edges - .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); - let mut view = CanvasView::new(); - view.select_node("a"); - let message = view.delete_selection(&mut doc).unwrap(); - assert_eq!(doc.canvas.nodes.len(), 1); - assert!(doc.canvas.edges.is_empty()); - assert!(message.contains("1 node"), "{message}"); - assert!(view.selection.is_empty()); - } - - #[test] - fn z_order_commands_reorder_the_file() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.select_node("a"); - view.raise_selection(&mut doc, true); - assert_eq!(doc.canvas.nodes.last().unwrap().id, "a"); - view.raise_selection(&mut doc, false); - assert_eq!(doc.canvas.nodes.first().unwrap().id, "a"); - } - - #[test] - fn inserting_a_group_puts_it_behind_the_others() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.insert_node( - &mut doc, - NodeKind::Group { - label: Some("G".into()), - background: None, - background_style: None, - }, - pos2(0.0, 0.0), - ); - assert!(doc.canvas.nodes[0].is_group()); - } - - #[test] - fn a_right_click_on_a_node_selects_it() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - - // Node "a" covers the canvas square (0,0)–(100,100). - right_click(&ctx, &mut view, &mut doc, screen, pos2(50.0, 50.0)); - assert_eq!(view.selection.nodes, vec!["a".to_owned()]); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - assert!(open_menu_rect(&ctx).is_some(), "the node menu never opened"); - - // A right click on a node that is already selected leaves the rest of - // the selection alone, so the menu can act on all of it. - view.selection.nodes = vec!["a".to_owned(), "b".to_owned()]; - right_click(&ctx, &mut view, &mut doc, screen, pos2(50.0, 50.0)); - assert_eq!(view.selection.nodes.len(), 2); - } - - /// The rectangle of the open menu, in screen coordinates. A menu is an - /// `Area` of its own, so it turns up as a foreground layer. - fn open_menu_rect(ctx: &egui::Context) -> Option { - ctx.memory(|m| { - m.areas() - .visible_layer_ids() - .into_iter() - .filter(|layer| layer.order == egui::Order::Foreground) - .find_map(|layer| m.area_rect(layer.id)) - }) - } - - /// The canvas is drawn in a layer carrying the pan/zoom transform. A menu - /// opened from it must not be dragged along by that transform: it belongs - /// at the pointer, at its usual size, whatever the view is zoomed to. - #[test] - fn the_menu_opens_at_the_pointer_and_not_in_canvas_space() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.to_global = TSTransform::new(vec2(120.0, 60.0), 2.0); - - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - let at = pos2(700.0, 500.0); - pass(&ctx, &mut view, &mut doc, screen, vec![]); - assert_eq!(open_menu_rect(&ctx), None, "a menu was up before the click"); - - right_click(&ctx, &mut view, &mut doc, screen, at); - // One more pass: the menu is laid out the frame after it opens. - pass(&ctx, &mut view, &mut doc, screen, vec![]); - - let rect = open_menu_rect(&ctx).expect("the context menu never opened"); - assert!( - (rect.min - at).length() < 40.0, - "the menu opened at {:?} instead of near {at:?}", - rect.min - ); - // Zoomed to 2x, a menu caught by the transform would be twice as wide. - assert!( - rect.width() < 300.0 && rect.height() < 300.0, - "the menu was scaled with the canvas: {rect:?}" - ); - } - - #[test] - fn a_right_click_on_the_background_is_remembered_in_canvas_units() { - let ctx = egui::Context::default(); - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.to_global = TSTransform::from_translation(vec2(100.0, 50.0)); - - let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); - right_click(&ctx, &mut view, &mut doc, screen, pos2(600.0, 450.0)); - - // A node added from the menu has to land under the click, not under - // the pointer wherever it ended up in the menu. - assert_eq!(view.menu_at, Some(pos2(500.0, 400.0))); - } - - #[test] - fn the_colour_command_covers_the_whole_selection() { - let mut doc = Document::new(); - doc.canvas = canvas_with_two_nodes(); - let mut view = CanvasView::new(); - view.select_all(&doc.canvas); - - view.set_selection_color(&mut doc, Some(Color::Preset(4))); - assert!( - doc.canvas - .nodes - .iter() - .all(|n| n.color == Some(Color::Preset(4))), - "some nodes kept their old colour" - ); - - view.set_selection_color(&mut doc, None); - assert!(doc.canvas.nodes.iter().all(|n| n.color.is_none())); - // Both steps are undoable on their own. - doc.undo(); - assert_eq!(doc.canvas.nodes[0].color, Some(Color::Preset(4))); - } - - #[test] - fn counts_read_naturally() { - assert_eq!(count_phrase(&[(1, "node"), (0, "edge")]), "1 node"); - assert_eq!( - count_phrase(&[(2, "node"), (3, "edge")]), - "2 nodes and 3 edges" - ); - assert_eq!(count_phrase(&[(0, "node")]), "nothing"); - } -} +mod tests; diff --git a/src/view/tests.rs b/src/view/tests.rs new file mode 100644 index 0000000..c29d29a --- /dev/null +++ b/src/view/tests.rs @@ -0,0 +1,933 @@ +use super::*; +use crate::model::Canvas; + +#[test] +fn a_copy_survives_the_system_clipboard() { + let mut canvas = Canvas::default(); + let note = |id: &str, x| Node::new(id, NodeKind::Text { text: id.into() }, x, 0, 250, 60); + canvas.nodes.push(note("a", 0)); + canvas.nodes.push(note("b", 400)); + canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + + let mut view = CanvasView::new(); + view.select_all(&canvas); + let copied = view.copy_selection(&canvas).expect("a selection to copy"); + + let back = Clipboard::from_json(&copied.to_json()).expect("the copy to be recognised"); + assert_eq!(back.nodes, copied.nodes); + assert_eq!(back.edges, copied.edges); +} + +#[test] +fn plain_text_is_not_mistaken_for_a_copy() { + for text in [ + "https://jsoncanvas.org", + "Just a thought", + "{}", + "{\"nodes\": []}", + "[1, 2, 3]", + "", + ] { + assert!( + Clipboard::from_json(text).is_none(), + "{text:?} was read as a copy" + ); + } +} + +/// Runs one egui pass over the canvas with the given input events. +fn pass( + ctx: &egui::Context, + view: &mut CanvasView, + doc: &mut Document, + screen: Rect, + events: Vec, +) { + let palette = Palette::dark(); + let input = egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }; + ctx.run_ui(input, |ui| { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui, |ui| { + view.show(ui, doc, &palette); + }); + }) + .drop_without_applying_deltas(); +} + +/// Like `pass`, but with a panel on the right the way the app puts the +/// inspector there. Reports whether the widget filling that panel was +/// clicked. +fn pass_beside_a_panel( + ctx: &egui::Context, + view: &mut CanvasView, + doc: &mut Document, + screen: Rect, + events: Vec, +) -> bool { + let palette = Palette::dark(); + let input = egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }; + let mut clicked = false; + ctx.run_ui(input, |ui| { + egui::Panel::right("inspector") + .default_size(300.0) + .show(ui, |ui| { + let size = ui.available_size(); + clicked = ui.add_sized(size, egui::Button::new("A control")).clicked(); + }); + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui, |ui| { + view.show(ui, doc, &palette); + }); + }) + .drop_without_applying_deltas(); + clicked +} + +fn click(at: Pos2) -> egui::Event { + egui::Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + } +} + +fn release(at: Pos2) -> egui::Event { + egui::Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + } +} + +/// The three passes a right click takes: egui hit-tests against the +/// rectangles of the previous pass, and a click is a press and a release. +fn right_click( + ctx: &egui::Context, + view: &mut CanvasView, + doc: &mut Document, + screen: Rect, + at: Pos2, +) { + let button = |pressed| egui::Event::PointerButton { + pos: at, + button: PointerButton::Secondary, + pressed, + modifiers: egui::Modifiers::default(), + }; + pass(ctx, view, doc, screen, vec![egui::Event::PointerMoved(at)]); + pass(ctx, view, doc, screen, vec![button(true)]); + pass(ctx, view, doc, screen, vec![button(false)]); +} + +/// A canvas saved next to `note.md`, with a node pointing at that file. +fn canvas_with_a_file_node( + subpath: Option<&str>, +) -> (std::path::PathBuf, std::path::PathBuf, Document) { + let dir = std::env::temp_dir().join(format!("jsoncanvas-edit-{}", crate::model::random_id())); + std::fs::create_dir_all(&dir).unwrap(); + let note = dir.join("note.md"); + std::fs::write(¬e, "# Note\nbody\n\n# Other\nkeep\n").unwrap(); + + let mut doc = Document::new(); + doc.canvas.nodes.push(Node::new( + "f", + NodeKind::File { + file: "note.md".to_owned(), + subpath: subpath.map(str::to_owned), + }, + 0, + 0, + 400, + 300, + )); + // Saved, so that the relative path in the node has a folder to resolve + // against. + doc.save_as(&dir.join("board.canvas")).unwrap(); + (dir, note, doc) +} + +/// Fitting a card means measuring contents the card itself crops, and +/// fitting several of them is one thing done, not one per card. +#[test] +fn cards_are_measured_and_fitted_to_their_text_in_one_step() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + // Far more text than a 100x100 card can hold. + doc.canvas.node_mut("a").unwrap().kind = NodeKind::Text { + text: "word ".repeat(120), + }; + let mut view = CanvasView::new(); + view.edit_node("a"); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + + let height = view + .body_heights + .get("a") + .copied() + .expect("the card was never measured"); + assert!( + height > 100.0, + "the text measured {height}, which the card already fits" + ); + + // Both cards at once, the way the menu fits a whole selection. + view.selection.set_node("a"); + view.selection.nodes.push("b".to_owned()); + assert_eq!(view.fit_selection(&mut doc), None); + let grown = doc.canvas.node("a").unwrap().height; + assert!( + grown as f32 >= height, + "the card came out {grown} tall for {height} of text" + ); + + // And the two of them are one step to undo, not two. + assert_eq!(doc.undo(), Some("Fit to text".to_owned())); + assert_eq!(doc.canvas.node("a").unwrap().height, 100); + assert_eq!(doc.canvas.node("b").unwrap().height, 100); + assert!(!doc.can_undo(), "fitting left more than one step behind"); +} + +/// A double click on a card opens the editor wherever it lands — the +/// text of a card is not there to be selected, and egui's selectable +/// labels would take the gesture for a word. +#[test] +fn a_double_click_on_a_card_s_text_opens_the_editor() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas.node_mut("a").unwrap().kind = NodeKind::Text { + text: "some words to land on".to_owned(), + }; + let mut view = CanvasView::new(); + + // The first line of the card's text, just inside the padding. + let at = pos2(30.0, 20.0); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + for _ in 0..2 { + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + } + + assert_eq!(view.editing.as_deref(), Some("a")); +} + +/// The editor's ways out sit in a strip along the bottom of the window, +/// with the text taking everything above them. +#[test] +fn the_editor_keeps_its_actions_in_a_strip_at_the_bottom() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.edit_node("a"); + // Twice: the window is laid out against the rectangles of the pass + // before it. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + + let window = ctx + .memory(|m| m.area_rect(Id::new("node-editor"))) + .expect("the editor window was never laid out"); + let actions = egui::containers::panel::PanelState::load(&ctx, Id::new("editor-actions")) + .expect("the editor's actions were never laid out") + .outer_rect; + assert!( + actions.height() < window.height() / 3.0, + "the actions took {} of a {} window", + actions.height(), + window.height() + ); + assert!( + actions.bottom() >= window.bottom() - 8.0, + "the actions sit at {}, not at the bottom of the window ({})", + actions.bottom(), + window.bottom() + ); + + // And the plain way out is where a user would aim for it: the far end + // of that strip. + let at = pos2(actions.right() - 30.0, actions.center().y); + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + assert_eq!(view.editing, None, "the button at {at:?} did not close it"); +} + +/// Every node takes an edit request; the ones with no text in them say +/// where their fields are instead of doing nothing. +#[test] +fn an_edit_request_on_a_node_with_no_text_points_at_the_inspector() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas.nodes.push(Node::new( + "l", + NodeKind::Link { + url: "https://example.com".to_owned(), + label: None, + }, + 0, + 300, + 200, + 60, + )); + let mut view = CanvasView::new(); + + assert_eq!(view.begin_edit(&doc, "a"), EditTarget::Editor); + assert_eq!(view.editing.as_deref(), Some("a")); + + let EditTarget::Inspector(reason) = view.begin_edit(&doc, "l") else { + panic!("the editor took a link node, which has nothing to type into"); + }; + assert!(reason.contains("inspector"), "unhelpful reason: {reason:?}"); + // And the editor it could not take the node into is left as it was. + assert_eq!(view.editing.as_deref(), Some("a")); +} + +/// The editor is a window with its own ways out, so the canvas no longer +/// dismisses it: a card can be moved and resized while it is open. +#[test] +fn clicking_the_canvas_leaves_the_editor_open() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.edit_node("a"); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + + // Empty canvas, well clear of both nodes and of the window. + let at = pos2(800.0, 700.0); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + + assert!( + view.selection.is_empty(), + "the click never reached the background" + ); + assert_eq!( + view.editing.as_deref(), + Some("a"), + "the click closed the editor" + ); +} + +#[test] +fn a_text_file_node_is_edited_in_the_editor_and_written_back() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let (dir, note, mut doc) = canvas_with_a_file_node(None); + let mut view = CanvasView::new(); + + assert!(view.edit_file_contents(&doc, "f")); + // The first pass opens the editor and takes the keyboard; the second + // types into it. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::Text("!".to_owned())], + ); + + // The edit is held until the canvas is saved, so the file has not + // moved yet. + assert!(view.files.is_dirty(), "the edit was not recorded"); + assert!(view.files.is_edited(¬e)); + assert_eq!( + std::fs::read_to_string(¬e).unwrap(), + "# Note\nbody\n\n# Other\nkeep\n" + ); + // Nor is the canvas itself touched by an edit to one of its files. + assert!(!doc.is_dirty()); + + assert_eq!(view.files.save_all(), (1, Vec::new())); + let written = std::fs::read_to_string(¬e).unwrap(); + assert!( + written.contains('!'), + "typing did not reach the file: {written:?}" + ); + assert!(!view.files.is_dirty()); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn editing_a_section_leaves_the_rest_of_the_file_alone() { + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let (dir, note, mut doc) = canvas_with_a_file_node(Some("#Note")); + let mut view = CanvasView::new(); + + assert!(view.edit_file_contents(&doc, "f")); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + // The editor holds the section, not the whole file. + let edit = view.file_edit.as_ref().expect("an open file editor"); + assert_eq!(edit.buffer, "# Note\nbody\n"); + assert_eq!(edit.section, Some(0..2)); + + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::Text("!".to_owned())], + ); + view.files.save_all(); + let written = std::fs::read_to_string(¬e).unwrap(); + assert!( + written.contains('!'), + "typing did not reach the file: {written:?}" + ); + assert!( + written.ends_with("\n# Other\nkeep\n"), + "the rest of the file was rewritten: {written:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn only_a_whole_plain_text_file_is_editable() { + let (dir, note, mut doc) = canvas_with_a_file_node(None); + let mut view = CanvasView::new(); + let point_at = |doc: &mut Document, file: &str, subpath: Option<&str>| { + doc.canvas.node_mut("f").unwrap().kind = NodeKind::File { + file: file.to_owned(), + subpath: subpath.map(str::to_owned), + }; + }; + + // An image, a file that is not there, and one that is not text. + std::fs::write(dir.join("blob.txt"), [0xff, 0xfe, 0x00]).unwrap(); + for (file, subpath) in [ + ("picture.png", None), + ("gone.md", None), + ("blob.txt", None), + // A section the file does not hold cannot be put back into it. + ("note.md", Some("#Missing")), + ] { + point_at(&mut doc, file, subpath); + assert!( + !view.edit_file_contents(&doc, "f"), + "{file} {subpath:?} was offered for editing" + ); + assert!(view.file_edit.is_none()); + } + + point_at(&mut doc, "note.md", Some("#Other")); + assert!(view.edit_file_contents(&doc, "f")); + assert!(!view.files.is_edited(¬e), "looking at a file changed it"); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn framing_waits_for_the_real_viewport() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + let mut view = CanvasView::new(); + + // Asked for before the first frame, when the viewport is still unknown. + view.zoom_to_fit(&doc.canvas); + assert_eq!(view.pending_fit, Some(PendingFit::Origin)); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + assert_eq!(view.pending_fit, None); + // The origin, not the top left corner, sits in the middle. + assert!( + view.centre_in_canvas().distance(Pos2::ZERO) < 1.0, + "empty canvas is centred on {:?}", + view.centre_in_canvas() + ); + + // Same for a canvas with content: the nodes end up in the middle. + let mut view = CanvasView::new(); + doc.canvas = canvas_with_two_nodes(); + view.zoom_to_fit(&doc.canvas); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + let bounds = bounds_of(doc.canvas.nodes.iter().map(node_rect)).unwrap(); + assert!( + view.centre_in_canvas().distance(bounds.center()) < 1.0, + "content is centred on {:?}, expected {:?}", + view.centre_in_canvas(), + bounds.center() + ); +} + +#[test] +fn the_background_still_responds_far_from_where_the_view_started() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.selection.set_node("a"); + // Panned well away from the canvas origin. + view.to_global = TSTransform::from_translation(vec2(5000.0, 4000.0)); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let at = pos2(600.0, 450.0); + // Three passes: egui hit-tests against the rectangles of the previous + // one, and a click is a press followed by a release. + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + pass(&ctx, &mut view, &mut doc, screen, vec![click(at)]); + pass(&ctx, &mut view, &mut doc, screen, vec![release(at)]); + + // Clicking empty canvas clears the selection. + assert!( + view.selection.is_empty(), + "the click never reached the background" + ); +} + +/// The bare wheel zooms wherever it is turned, cards included, and keeps +/// what is under the pointer under it. +#[test] +fn the_wheel_zooms_over_a_node() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + // Over node "a", which the identity transform puts at 0..100. + let at = pos2(50.0, 50.0); + let under_pointer = view.to_global.inverse() * at; + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + // egui spreads a wheel event over the frames after it, so the zoom + // arrives over several passes. + for _ in 0..10 { + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 50.0), + modifiers: egui::Modifiers::default(), + phase: egui::TouchPhase::Move, + }], + ); + } + + assert!( + view.zoom() > 1.0, + "the wheel did not zoom in: {}", + view.zoom() + ); + let moved = (view.to_global.inverse() * at).distance(under_pointer); + assert!(moved < 1.0, "the canvas slid {moved} under the pointer"); +} + +#[test] +fn a_click_in_the_inspector_stays_in_the_inspector() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.selection.set_node("a"); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let at = pos2(1050.0, 450.0); + let mut hit = false; + for events in [ + vec![egui::Event::PointerMoved(at)], + vec![click(at)], + vec![release(at)], + ] { + hit |= pass_beside_a_panel(&ctx, &mut view, &mut doc, screen, events); + } + + assert!(hit, "the widget in the panel never saw the click"); + assert!( + view.selection.has_node("a"), + "the canvas took a click that belonged to the panel" + ); +} + +fn canvas_with_two_nodes() -> Canvas { + let mut canvas = Canvas::default(); + canvas.nodes.push(Node::new( + "a", + NodeKind::Text { text: "a".into() }, + 0, + 0, + 100, + 100, + )); + canvas.nodes.push(Node::new( + "b", + NodeKind::Text { text: "b".into() }, + 300, + 0, + 100, + 100, + )); + canvas +} + +#[test] +fn selection_tracks_nodes_and_edges() { + let mut selection = Selection::default(); + selection.set_node("a"); + assert_eq!(selection.only_node(), Some("a")); + selection.toggle_node("b"); + assert_eq!(selection.len(), 2); + assert_eq!(selection.only_node(), None); + selection.toggle_node("b"); + assert!(selection.has_node("a")); + selection.set_edge("e"); + assert!(selection.nodes.is_empty()); + assert_eq!(selection.only_edge(), Some("e")); +} + +#[test] +fn selection_drops_ids_that_no_longer_exist() { + let canvas = canvas_with_two_nodes(); + let mut selection = Selection { + nodes: vec!["a".into(), "gone".into()], + edges: vec!["nope".into()], + }; + selection.prune(&canvas); + assert_eq!(selection.nodes, vec!["a".to_owned()]); + assert!(selection.edges.is_empty()); +} + +#[test] +fn handles_resize_from_the_right_corner() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)); + let grown = Handle::SouthEast.resize(rect, vec2(20.0, 10.0)); + assert_eq!(grown.min, rect.min); + assert_eq!(grown.max, pos2(120.0, 110.0)); + + let moved = Handle::NorthWest.resize(rect, vec2(20.0, 10.0)); + assert_eq!(moved.min, pos2(20.0, 10.0)); + assert_eq!(moved.max, rect.max); + + let side = Handle::East.resize(rect, vec2(20.0, 999.0)); + assert_eq!(side.height(), rect.height()); +} + +#[test] +fn nearest_side_picks_the_closest_anchor() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0)); + assert_eq!(nearest_side(rect, pos2(50.0, -30.0)), Side::Top); + assert_eq!(nearest_side(rect, pos2(140.0, 50.0)), Side::Right); + assert_eq!(nearest_side(rect, pos2(50.0, 130.0)), Side::Bottom); + assert_eq!(nearest_side(rect, pos2(-40.0, 50.0)), Side::Left); +} + +#[test] +fn dangling_edges_have_no_curve() { + let canvas = canvas_with_two_nodes(); + let good = Edge::new("e", "a", Side::Right, "b", Side::Left); + assert!(curve_of(&canvas, &good).is_some()); + let bad = Edge::new("e", "a", Side::Right, "ghost", Side::Left); + assert!(curve_of(&canvas, &bad).is_none()); +} + +#[test] +fn hit_testing_finds_nodes_and_edges() { + let view = CanvasView::new(); + let mut canvas = canvas_with_two_nodes(); + canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + assert_eq!( + view.node_at(&canvas, pos2(50.0, 50.0)).as_deref(), + Some("a") + ); + assert_eq!(view.node_at(&canvas, pos2(-5.0, 50.0)), None); + // The edge runs from (100,50) to (300,50). + assert_eq!( + view.edge_at(&canvas, pos2(200.0, 50.0)).as_deref(), + Some("e") + ); + assert_eq!(view.edge_at(&canvas, pos2(200.0, 200.0)), None); +} + +#[test] +fn groups_are_only_hit_when_nothing_covers_them() { + let mut canvas = canvas_with_two_nodes(); + canvas.nodes.insert( + 0, + Node::new( + "g", + NodeKind::Group { + label: None, + background: None, + background_style: None, + }, + -50, + -50, + 500, + 300, + ), + ); + let view = CanvasView::new(); + assert_eq!( + view.node_at(&canvas, pos2(50.0, 50.0)).as_deref(), + Some("a") + ); + assert_eq!( + view.node_at(&canvas, pos2(200.0, 200.0)).as_deref(), + Some("g") + ); +} + +#[test] +fn moving_a_group_takes_its_members_along() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas.nodes.insert( + 0, + Node::new( + "g", + NodeKind::Group { + label: None, + background: None, + background_style: None, + }, + -50, + -50, + 600, + 300, + ), + ); + let mut view = CanvasView::new(); + view.select_node("g"); + view.nudge(&mut doc, vec2(10.0, 0.0)); + assert_eq!(doc.canvas.node("g").unwrap().x, -40); + assert_eq!(doc.canvas.node("a").unwrap().x, 10); + assert_eq!(doc.canvas.node("b").unwrap().x, 310); +} + +#[test] +fn copy_and_paste_creates_new_ids_and_keeps_inner_edges() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + let mut view = CanvasView::new(); + view.select_all(&doc.canvas); + + let clipboard = view.copy_selection(&doc.canvas).unwrap(); + assert_eq!(clipboard.nodes.len(), 2); + assert_eq!(clipboard.edges.len(), 1); + + view.paste(&mut doc, &clipboard, vec2(20.0, 20.0)); + assert_eq!(doc.canvas.nodes.len(), 4); + assert_eq!(doc.canvas.edges.len(), 2); + let ids: std::collections::HashSet<&str> = + doc.canvas.nodes.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids.len(), 4, "pasted nodes need fresh ids"); + // The pasted edge joins the pasted nodes, not the originals. + let pasted = doc.canvas.edges.last().unwrap(); + assert!(view.selection.has_node(&pasted.from_node)); + assert!(view.selection.has_node(&pasted.to_node)); +} + +#[test] +fn deleting_a_node_removes_its_edges_and_reports_it() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + doc.canvas + .edges + .push(Edge::new("e", "a", Side::Right, "b", Side::Left)); + let mut view = CanvasView::new(); + view.select_node("a"); + let message = view.delete_selection(&mut doc).unwrap(); + assert_eq!(doc.canvas.nodes.len(), 1); + assert!(doc.canvas.edges.is_empty()); + assert!(message.contains("1 node"), "{message}"); + assert!(view.selection.is_empty()); +} + +#[test] +fn z_order_commands_reorder_the_file() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.select_node("a"); + view.raise_selection(&mut doc, true); + assert_eq!(doc.canvas.nodes.last().unwrap().id, "a"); + view.raise_selection(&mut doc, false); + assert_eq!(doc.canvas.nodes.first().unwrap().id, "a"); +} + +#[test] +fn inserting_a_group_puts_it_behind_the_others() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.insert_node( + &mut doc, + NodeKind::Group { + label: Some("G".into()), + background: None, + background_style: None, + }, + pos2(0.0, 0.0), + ); + assert!(doc.canvas.nodes[0].is_group()); +} + +#[test] +fn a_right_click_on_a_node_selects_it() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + + // Node "a" covers the canvas square (0,0)–(100,100). + right_click(&ctx, &mut view, &mut doc, screen, pos2(50.0, 50.0)); + assert_eq!(view.selection.nodes, vec!["a".to_owned()]); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + assert!(open_menu_rect(&ctx).is_some(), "the node menu never opened"); + + // A right click on a node that is already selected leaves the rest of + // the selection alone, so the menu can act on all of it. + view.selection.nodes = vec!["a".to_owned(), "b".to_owned()]; + right_click(&ctx, &mut view, &mut doc, screen, pos2(50.0, 50.0)); + assert_eq!(view.selection.nodes.len(), 2); +} + +/// The rectangle of the open menu, in screen coordinates. A menu is an +/// `Area` of its own, so it turns up as a foreground layer. +fn open_menu_rect(ctx: &egui::Context) -> Option { + ctx.memory(|m| { + m.areas() + .visible_layer_ids() + .into_iter() + .filter(|layer| layer.order == egui::Order::Foreground) + .find_map(|layer| m.area_rect(layer.id)) + }) +} + +/// The canvas is drawn in a layer carrying the pan/zoom transform. A menu +/// opened from it must not be dragged along by that transform: it belongs +/// at the pointer, at its usual size, whatever the view is zoomed to. +#[test] +fn the_menu_opens_at_the_pointer_and_not_in_canvas_space() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.to_global = TSTransform::new(vec2(120.0, 60.0), 2.0); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let at = pos2(700.0, 500.0); + pass(&ctx, &mut view, &mut doc, screen, vec![]); + assert_eq!(open_menu_rect(&ctx), None, "a menu was up before the click"); + + right_click(&ctx, &mut view, &mut doc, screen, at); + // One more pass: the menu is laid out the frame after it opens. + pass(&ctx, &mut view, &mut doc, screen, vec![]); + + let rect = open_menu_rect(&ctx).expect("the context menu never opened"); + assert!( + (rect.min - at).length() < 40.0, + "the menu opened at {:?} instead of near {at:?}", + rect.min + ); + // Zoomed to 2x, a menu caught by the transform would be twice as wide. + assert!( + rect.width() < 300.0 && rect.height() < 300.0, + "the menu was scaled with the canvas: {rect:?}" + ); +} + +#[test] +fn a_right_click_on_the_background_is_remembered_in_canvas_units() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.to_global = TSTransform::from_translation(vec2(100.0, 50.0)); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + right_click(&ctx, &mut view, &mut doc, screen, pos2(600.0, 450.0)); + + // A node added from the menu has to land under the click, not under + // the pointer wherever it ended up in the menu. + assert_eq!(view.menu_at, Some(pos2(500.0, 400.0))); +} + +#[test] +fn the_colour_command_covers_the_whole_selection() { + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + view.select_all(&doc.canvas); + + view.set_selection_color(&mut doc, Some(Color::Preset(4))); + assert!( + doc.canvas + .nodes + .iter() + .all(|n| n.color == Some(Color::Preset(4))), + "some nodes kept their old colour" + ); + + view.set_selection_color(&mut doc, None); + assert!(doc.canvas.nodes.iter().all(|n| n.color.is_none())); + // Both steps are undoable on their own. + doc.undo(); + assert_eq!(doc.canvas.nodes[0].color, Some(Color::Preset(4))); +} + +#[test] +fn counts_read_naturally() { + assert_eq!(count_phrase(&[(1, "node"), (0, "edge")]), "1 node"); + assert_eq!( + count_phrase(&[(2, "node"), (3, "edge")]), + "2 nodes and 3 edges" + ); + assert_eq!(count_phrase(&[(0, "node")]), "nothing"); +} diff --git a/tests/snapshots/editor-window.png b/tests/snapshots/editor-window.png new file mode 100644 index 0000000000000000000000000000000000000000..a3cf844f3b0c7a38b408a11be0f6bee48ec1dece GIT binary patch literal 82451 zcmdqK2~<<(+BUoq6=$kev7*G8T8B7PZ~)R)TeQ}4THD%!Le;9RsHjv)BqTWEP>qU8 z6$M+5wLKn0YZXu+C;}ovv<_fF!VC_O2}lA-_J7?EIJD(G-}=t?ukT;$U+WyJkYVrV zxu1KuuIs+{j(>dU-MvfyE*!^opEYyF#~j!GEXUbz>C_QFIk)n96KD7KAG2n>{fXGF zHqHL{urCf}>iCel-PdoPT0493nU#eXTmSdXcQJ?FkB^x!aY&tceZuk$KSfLnycb_A zF5g}CuhVBPJ^tGtwq-*S|Nc{YC7;x(tvBFj^0AxR{H!xgvAatC_6_VqY}NnaYiyf6 zpDuJP-<4Xk%cN<{(^&GPH4(Dc=UI9EqyWRD`Rnd|I&Z?EOHr#)c%37qVR6lv5v^{z7;6bVKMz!2lZCE61Tx9VJdk_`& zT9B@9(3RlOYeQYG(wLOY$H!eE2=F%R1WmBqm zFa9&RB};yDlu|#+5G*nVd)$gneH5)v%<|l`u%GFyWAnJScFz{^gI-Wl>L{Nq&<_?| z%?c~Z(iQdH)j?nwFGwGgQ1s4Hy^~z$B+UwuI`FqwrJ9#|l@0aUgxi=9xv-zO=}v5q zv~+=OwO7S-uPut^M1_4xMRp`xc23#gr_6BVwT=dj?gv5B56Pxo%E>2Roe=34lyXqW z>xHK-WU_7fP*eZmidC!jAC~Hz?|qYm|7D^;DG9s%^DX);)f;S`}L@ z`kn2`KV3a{2&}gSOM-(V9W7~&6&;dRm$r-5kBq%4Kj6G7abrN!$$+wX@`iay=BjA( zc7>@^@zdPm?_#YFVk_y^%-OM)SvI|p-|sDIx?L1ze8{mwRajUkEv^o8QHZY%6*niy zLq9w=HN$MEmS{H%Zqgw0n^rh*9?OW8;A{%tCSgQlPZcsHI;MJ^>fTvp@p1( z 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 literal 0 HcmV?d00001