Add headless UI tests with kittest

This commit is contained in:
2026-09-10 21:34:12 +02:00
parent 446bb24237
commit 81072b9e0d
9 changed files with 1213 additions and 952 deletions
+5
View File
@@ -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
Generated
+137
View File
@@ -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"
+3
View File
@@ -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"] }
+33 -2
View File
@@ -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
`<name>.diff.png` and `<name>.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
+1 -14
View File
@@ -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;
+93
View File
@@ -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"));
}
+8 -936
View File
@@ -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<egui::Event>,
) {
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<egui::Event>,
) -> 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(&note, "# 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(&note));
assert_eq!(
std::fs::read_to_string(&note).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(&note).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(&note).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(&note), "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<Rect> {
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;
+933
View File
@@ -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<egui::Event>,
) {
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<egui::Event>,
) -> 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(&note, "# 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(&note));
assert_eq!(
std::fs::read_to_string(&note).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(&note).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(&note).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(&note), "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<Rect> {
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");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB