Add headless UI tests with kittest
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(¬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<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;
|
||||
|
||||
@@ -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(¬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<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 |
Reference in New Issue
Block a user