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 0000000..a3cf844 Binary files /dev/null and b/tests/snapshots/editor-window.png differ