From 47abe15bf58bf26b54d3276a7b2da5bd879fef16 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:09 +0000 Subject: [PATCH] refactor(ui-egui): move placing.rs tests to placing/tests.rs --- crates/ui-egui/src/placing.rs | 426 +--------------------------- crates/ui-egui/src/placing/tests.rs | 423 +++++++++++++++++++++++++++ 2 files changed, 424 insertions(+), 425 deletions(-) create mode 100644 crates/ui-egui/src/placing/tests.rs diff --git a/crates/ui-egui/src/placing.rs b/crates/ui-egui/src/placing.rs index 7b309a6db..0c94c2735 100644 --- a/crates/ui-egui/src/placing.rs +++ b/crates/ui-egui/src/placing.rs @@ -210,428 +210,4 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result) { } #[cfg(test)] -mod tests { - use image::ImageFormat::{Bmp, Png, Tiff}; - use vectorcraft_doc::{Document, NodeId}; - use vectorcraft_engine::Session; - - use super::*; - use crate::Services; - use crate::place::{DropAt, DropTarget}; - - /// An app reading files from disk, with a 400 × 300 pt document, that reads the files the user - /// places off the UI thread when `background`. - fn app(background: bool) -> VectorcraftApp { - let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; - let mut app = VectorcraftApp::new(Session::new(), services); - app.placing.off_the_ui_thread = background; - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app - } - - /// `bytes` written to a fresh file named `name` in a folder of `test`'s own → its path. - fn saved(test: &str, name: &str, bytes: &[u8]) -> String { - let dir = std::env::temp_dir().join(format!("vc-placing-{test}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(name); - std::fs::write(&path, bytes).unwrap(); - path.to_string_lossy().into_owned() - } - - /// A `w` × `h` picture of one color in `format`. - fn picture(format: image::ImageFormat, w: u32, h: u32) -> Vec { - let mut out = vec![]; - image::RgbImage::from_pixel(w, h, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); - out - } - - /// A flat `w` × `h` Photoshop document of one color (raw RGB, no layers). - fn psd(w: u32, h: u32) -> Vec { - let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); - f.extend(h.to_be_bytes()); - f.extend(w.to_be_bytes()); - f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - for v in [40u8, 40, 200] { - f.extend(std::iter::repeat_n(v, (w * h) as usize)); - } - f - } - - /// A one-page PDF with a rectangle on it. - fn pdf() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); - let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); - vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() - } - - /// Poll each frame until every file read for Place has been placed (at most 60 s). - fn placed(app: &mut VectorcraftApp) { - let t0 = std::time::Instant::now(); - while !app.placing.pending.is_empty() && t0.elapsed().as_secs() < 60 { - poll(app); - std::thread::yield_now(); - } - assert!(app.placing.pending.is_empty(), "read within 60 s"); - } - - /// What a place changes: the active document (without the second it was made in), its - /// selection, its undo steps and the last journal entry. - fn state(app: &VectorcraftApp) -> (Document, Vec, usize, Option<(String, Value)>) { - let st = app.session.doc().unwrap(); - let mut doc = (*st.doc).clone(); - doc.metadata.created = None; - (doc, st.selection.objects.clone(), st.history.undo.len(), app.session.journal.last().cloned()) - } - - /// The names of the active document's top-level objects, bottom first. - fn names(app: &VectorcraftApp) -> Vec { - let st = app.session.doc().unwrap(); - st.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect() - } - - /// A TIFF, a Photoshop document, a BMP, a PDF page and a PDF-compatible `.ai` page (both placed - /// linked) read off the UI thread are placed as when they are read at once: the same object - /// with its bounds and link, the same selection, one undo step and the same journal entry. Until - /// then the document is unchanged, and the status bar and the busy cursor show the file is being - /// placed. - #[test] - fn a_file_read_off_the_ui_thread_places_as_one_read_at_once() { - let page = pdf(); - let files = [ - ("photo.tif", picture(Tiff, 300, 200)), - ("photo.psd", psd(300, 200)), - ("photo.bmp", picture(Bmp, 300, 200)), - ("page.pdf", page.clone()), - ("page.ai", page), - ]; - for (name, bytes) in files { - let p = json!({"path": saved("same", name, &bytes), "at": [200, 150]}); - let mut at_once = app(false); - at_once.run("file.place", p.clone()).unwrap(); - let mut app = app(true); - let before = state(&app); - let r = start(&mut app, &p, false).unwrap(); - assert_eq!(r, json!({"background": true, "status": format!("Placing {name}…")})); - assert_eq!(app.ui.status, format!("Placing {name}…")); - assert!(crate::opening::long_operation_busy(&app), "{name}"); - assert!(state(&app) == before, "{name}: nothing is placed until the file is read"); - placed(&mut app); - assert!(state(&app) == state(&at_once), "{name}: placed as at once"); - assert!(app.ui.status.is_empty(), "{name}: {}", app.ui.status); - assert!(!crate::opening::long_operation_busy(&app), "{name}"); - } - } - - /// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a - /// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread; - /// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies - /// they have always had. - #[test] - fn what_the_user_places_is_read_off_the_ui_thread_and_commands_read_at_once() { - let mut app = app(true); - let (a, b) = (saved("routes", "a.tif", &picture(Tiff, 60, 40)), saved("routes", "b.bmp", &picture(Bmp, 60, 40))); - let r = app.run("file.place", json!({"path": a})).unwrap(); - assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}"); - let r = app.run("file.place.queue", json!({"paths": [b]})).unwrap(); - assert!(r["count"] == 1 && app.placing.pending.is_empty(), "{r}"); - assert_eq!(app.place.thumbnails(), ["b.bmp"]); - app.select_tool("selection"); - // The Place dialog's OK with one file. - app.run("file.place", json!({"paths": [a]})).unwrap(); - assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); - assert_eq!(app.placing.pending.len(), 1); - placed(&mut app); - assert_eq!(names(&app), ["a.tif", "a.tif"]); - // With several: the place cursor is loaded once they are read. - app.run("file.place", json!({"paths": [a, b]})).unwrap(); - assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); - assert_eq!(app.ui.status, "Placing a.tif, b.bmp…"); - assert_eq!(app.session.tool_id(), "selection"); - let (inspect, _) = crate::control::ControlRequest::new("ui.inspect", json!({})); - let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &inspect) else { panic!("not done") }; - assert_eq!(r["result"]["placing"], json!(["a.tif, b.bmp"]), "ui.inspect lists the files being read"); - placed(&mut app); - assert_eq!((app.session.tool_id(), app.session.tool_options()["count"].as_u64()), ("place", Some(2))); - assert_eq!(app.place.thumbnails(), ["a.tif", "b.bmp"]); - app.select_tool("selection"); - // A drop and bytes placed without a path. - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); - crate::place::drop_files(&mut app, vec![(drop, ("b.bmp".into(), Some(b.clone()), vec![]))]); - crate::io::place_bytes(&mut app, "c.png", &picture(Png, 10, 10)).unwrap(); - assert_eq!(app.placing.pending.len(), 2); - placed(&mut app); - assert_eq!(names(&app), ["a.tif", "a.tif", "b.bmp", "c.png"]); - } - - /// A click and a drag of the place cursor read their files off the UI thread, and place them as - /// a click and a drag place them when they are read at once. - #[test] - fn the_place_cursors_clicks_read_their_files_off_the_ui_thread() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let paths = [saved("cursor", "a.tif", &picture(Tiff, 60, 40)), saved("cursor", "b.bmp", &picture(Bmp, 60, 40))]; - let view = vectorcraft_engine::ViewInfo::default(); - let press = |app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)| { - for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { - crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); - } - }; - let (mut at_once, mut app) = (app(false), app(true)); - for app in [&mut at_once, &mut app] { - app.run("file.place.queue", json!({"paths": paths})).unwrap(); - press(app, (10.0, 10.0), (10.0, 10.0)); - } - assert_eq!(names(&at_once), ["a.tif"]); - assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "nothing is placed until the file is read"); - assert_eq!(app.ui.status, "Placing a.tif…"); - placed(&mut app); - assert!(state(&app) == state(&at_once), "the click places as at once"); - // A drag places the last file at the dragged size, and the previous tool comes back. - for app in [&mut at_once, &mut app] { - press(app, (100.0, 100.0), (180.0, 110.0)); - } - placed(&mut app); - assert!(state(&app) == state(&at_once), "the drag places as at once"); - assert_eq!(names(&app), ["a.tif", "b.bmp"]); - assert_eq!(app.session.tool_id(), at_once.session.tool_id()); - } - - /// The Place dialog's line about each file is read off the UI thread: the lines are empty when - /// the dialog opens, and then show what the lines read at once show. - #[test] - fn the_place_dialogs_lines_are_read_off_the_ui_thread() { - let p = json!({"paths": [saved("lines", "a.tif", &picture(Tiff, 60, 40)), saved("lines", "notes.xyz", b"hello")]}); - let mut at_once = app(false); - at_once.run("file.place", p.clone()).unwrap(); - let want = at_once.ui.dialog.as_ref().unwrap().fields["__info"].clone(); - assert!(want[0].as_str().is_some_and(|l| l.starts_with("60 × 40 px")), "{want}"); - let mut app = app(true); - app.run("file.place", p).unwrap(); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], json!(["", ""])); - let t0 = std::time::Instant::now(); - loop { - let Some(mut d) = app.ui.dialog.take() else { panic!("the dialog stays open") }; - let reading = crate::place::fill_lines(&mut app, &mut d); - app.ui.dialog = Some(d); - if !reading || t0.elapsed().as_secs() > 60 { - break; - } - std::thread::yield_now(); - } - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], want); - } - - /// Several files dropped together are placed in the order they were dropped, a large one first - /// included, as when they are read at once. - #[test] - fn files_dropped_together_are_placed_in_the_order_dropped() { - let large = saved("order", "large.tif", &picture(Tiff, 1500, 1500)); - let small = [saved("order", "small1.png", &picture(Png, 8, 8)), saved("order", "small2.png", &picture(Png, 8, 8))]; - let mut app = app(true); - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); - let files = [&large, &small[0], &small[1]].map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))); - crate::place::drop_files(&mut app, files.into()); - assert_eq!(app.placing.pending.len(), 3); - placed(&mut app); - assert_eq!(names(&app), ["large.tif", "small1.png", "small2.png"]); - } - - /// Files dropped together are read one at a time, as when they are read at once, so a batch of - /// large pictures is not decoded all at once. - #[test] - fn files_dropped_together_are_read_one_at_a_time() { - let files: Vec = (0..4).map(|i| saved("one-at-a-time", &format!("large{i}.tif"), &picture(Tiff, 1500, 1500))).collect(); - let mut app = app(true); - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); - let dropped = files.iter().map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))).collect(); - crate::place::drop_files(&mut app, dropped); - placed(&mut app); - assert_eq!(names(&app).len(), 4); - assert_eq!(app.placing.reading[1].load(std::sync::atomic::Ordering::SeqCst), 1, "the most reads at once"); - } - - /// A file that can't be read shows the message it shows when read at once, dropped or placed - /// from the dialog, and nothing is placed. - #[test] - fn a_file_that_cant_be_read_off_the_ui_thread_reports_as_at_once() { - let gone = saved("unread", "gone.tif", b""); - std::fs::remove_file(&gone).unwrap(); - let unknown = saved("unread", "notes.xyz", b"hello"); - for path in [gone, unknown] { - let p = json!({"path": path}); - let mut at_once = app(false); - crate::place::run_for_user(&mut at_once, &p).unwrap_err(); - let mut app = app(true); - let before = state(&app); - crate::place::run_for_user(&mut app, &p).unwrap(); - placed(&mut app); - assert_eq!(app.ui.status, at_once.ui.status); - assert!(state(&app) == before, "{path}: nothing placed"); - // Dropped on the canvas. - let name = vectorcraft_engine::cmd::fileio::file_name(&path); - for app in [&mut at_once, &mut app] { - let doc = app.session.doc().unwrap().uid; - let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); - crate::place::drop_files(app, vec![(drop, (name.clone(), Some(path.clone()), vec![]))]); - placed(app); - } - assert_eq!(app.ui.status, at_once.ui.status); - assert!(app.ui.status.starts_with(&format!("Couldn't place {name}: ")), "{}", app.ui.status); - } - } - - /// A file whose document closed before it was read is not placed, and the status bar says why. - #[test] - fn a_file_whose_document_closed_meanwhile_is_not_placed() { - let path = saved("closed", "photo.tif", &picture(Tiff, 60, 40)); - for dropped in [false, true] { - let mut app = app(true); - app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - start(&mut app, &json!({"path": path}), dropped).unwrap(); - app.session.execute("file.close", &json!({})).unwrap(); - let left = state(&app); - placed(&mut app); - let why = if dropped { "dropped on" } else { "placed in" }; - assert_eq!(app.ui.status, format!("Couldn't place photo.tif: the document it was {why} was closed")); - assert!(state(&app) == left, "the other document is unchanged"); - } - } - - /// Replace swaps the object selected when Place started; when that object was deleted before the - /// file was read, nothing is placed and the status bar says why. - #[test] - fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() { - let path = saved("replace", "photo.tif", &picture(Tiff, 60, 40)); - let mut app = app(true); - app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); - crate::place::run_for_user(&mut app, &json!({"path": path, "replace": true})).unwrap(); - app.run("edit.clear", json!({})).unwrap(); - let cleared = state(&app); - placed(&mut app); - assert!(app.ui.status.contains("the object to replace was changed or deleted"), "{}", app.ui.status); - assert!(state(&app) == cleared, "nothing placed"); - } - - /// A file read while a dialog is open waits for the dialog to close, as a document that opens - /// does (#1694): the dialog's OK acts on the active document. - #[test] - fn a_file_read_while_a_dialog_is_open_waits_for_it_to_close() { - let path = saved("dialog", "photo.tif", &picture(Tiff, 60, 40)); - let mut app = app(true); - start(&mut app, &json!({"path": path}), false).unwrap(); - app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"}))); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "the file waits for the dialog"); - app.ui.dialog = None; - placed(&mut app); - assert_eq!(names(&app), ["photo.tif"]); - } - - /// A file read while an object is being dragged, in its document or in another one, waits for - /// the drag to end: the drag is not canceled, and the placed art stays. - #[test] - fn a_file_read_during_a_drag_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let path = saved("drag", "photo.tif", &picture(Tiff, 60, 40)); - for other_document in [false, true] { - let mut app = app(true); - start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); - if other_document { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - } - let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); - let dragged = app.session.active_index(); - app.select_tool("selection"); - let view = vectorcraft_engine::ViewInfo::default(); - for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] { - app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap(); - } - assert!(app.session.in_interaction()); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.placing.pending.len() == 1 && app.session.in_interaction(), "other document: {other_document}"); - // The drag moves on before it ends. - for kind in [PointerKind::Drag, PointerKind::Up] { - app.session.pointer(&PointerEvent::new(kind, 60.0, 20.0), view).unwrap(); - } - placed(&mut app); - let moved = app.session.documents()[dragged.unwrap()].doc.node(NodeId(square)).and_then(|n| n.geometric_bounds()); - assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "other document: {other_document}"); - assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); - } - } - - /// A file read for one document while the place cursor is dragged in another waits for the drag - /// to end, which the place cursor keeps to itself: the drag still places its file. - #[test] - fn a_file_read_during_a_place_cursor_drag_waits_for_it_to_end() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let path = saved("cursor-drag", "photo.tif", &picture(Tiff, 60, 40)); - let cursor = [saved("cursor-drag", "a.png", &picture(Png, 8, 8)), saved("cursor-drag", "b.png", &picture(Png, 8, 8))]; - let mut app = app(true); - start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - app.run("file.place.queue", json!({"paths": cursor})).unwrap(); - let view = vectorcraft_engine::ViewInfo::default(); - for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] { - crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view); - } - assert!(app.session.tool_busy() && !app.session.in_interaction()); - let t0 = std::time::Instant::now(); - while t0.elapsed().as_millis() < 400 { - poll(&mut app); - std::thread::yield_now(); - } - assert!(app.placing.pending.len() == 1 && app.session.tool_busy(), "the file waits for the drag"); - crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view); - placed(&mut app); - let placed_in = |i: usize| -> Vec<(String, Option)> { - let st = &app.session.documents()[i]; - st.doc - .layers - .iter() - .flat_map(|l| l.children().into_iter().flatten()) - .map(|n| (n.name.clone().unwrap_or_default(), n.geometric_bounds())) - .collect() - }; - assert_eq!(placed_in(0).iter().map(|(n, _)| n.as_str()).collect::>(), ["photo.tif"]); - assert_eq!(placed_in(1), [("a.png".to_string(), Some(vectorcraft_geom::Rect::new(20.0, 20.0, 60.0, 60.0)))], "the drag placed its file"); - } - - /// A file read while a tool waits between its clicks (the Dimension tool after its first point), - /// in the file's document or in another one, is placed without waiting for the next click. In - /// the same document the tool keeps its point; in another one it starts afresh, as when the user - /// switches documents. - #[test] - fn a_file_read_between_a_tools_clicks_is_placed() { - use vectorcraft_tools::{PointerEvent, PointerKind}; - let path = saved("clicks", "photo.tif", &picture(Tiff, 60, 40)); - for other_document in [false, true] { - let mut app = app(true); - start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); - if other_document { - app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); - } - app.select_tool("dimension"); - let view = vectorcraft_engine::ViewInfo::default(); - for kind in [PointerKind::Down, PointerKind::Up] { - crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 20.0, 20.0), view); - } - assert!(app.session.tool_busy() && !app.session.in_interaction(), "other document: {other_document}"); - placed(&mut app); - assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); - assert_eq!(app.session.tool_busy(), !other_document, "other document: {other_document}"); - } - } -} +mod tests; diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs new file mode 100644 index 000000000..0627b37bc --- /dev/null +++ b/crates/ui-egui/src/placing/tests.rs @@ -0,0 +1,423 @@ +use image::ImageFormat::{Bmp, Png, Tiff}; +use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_engine::Session; + +use super::*; +use crate::Services; +use crate::place::{DropAt, DropTarget}; + +/// An app reading files from disk, with a 400 × 300 pt document, that reads the files the user +/// places off the UI thread when `background`. +fn app(background: bool) -> VectorcraftApp { + let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.placing.off_the_ui_thread = background; + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +/// `bytes` written to a fresh file named `name` in a folder of `test`'s own → its path. +fn saved(test: &str, name: &str, bytes: &[u8]) -> String { + let dir = std::env::temp_dir().join(format!("vc-placing-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() +} + +/// A `w` × `h` picture of one color in `format`. +fn picture(format: image::ImageFormat, w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbImage::from_pixel(w, h, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out +} + +/// A flat `w` × `h` Photoshop document of one color (raw RGB, no layers). +fn psd(w: u32, h: u32) -> Vec { + let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03".to_vec(); + f.extend(h.to_be_bytes()); + f.extend(w.to_be_bytes()); + f.extend([0, 8, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + for v in [40u8, 40, 200] { + f.extend(std::iter::repeat_n(v, (w * h) as usize)); + } + f +} + +/// A one-page PDF with a rectangle on it. +fn pdf() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// Poll each frame until every file read for Place has been placed (at most 60 s). +fn placed(app: &mut VectorcraftApp) { + let t0 = std::time::Instant::now(); + while !app.placing.pending.is_empty() && t0.elapsed().as_secs() < 60 { + poll(app); + std::thread::yield_now(); + } + assert!(app.placing.pending.is_empty(), "read within 60 s"); +} + +/// What a place changes: the active document (without the second it was made in), its +/// selection, its undo steps and the last journal entry. +fn state(app: &VectorcraftApp) -> (Document, Vec, usize, Option<(String, Value)>) { + let st = app.session.doc().unwrap(); + let mut doc = (*st.doc).clone(); + doc.metadata.created = None; + (doc, st.selection.objects.clone(), st.history.undo.len(), app.session.journal.last().cloned()) +} + +/// The names of the active document's top-level objects, bottom first. +fn names(app: &VectorcraftApp) -> Vec { + let st = app.session.doc().unwrap(); + st.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect() +} + +/// A TIFF, a Photoshop document, a BMP, a PDF page and a PDF-compatible `.ai` page (both placed +/// linked) read off the UI thread are placed as when they are read at once: the same object +/// with its bounds and link, the same selection, one undo step and the same journal entry. Until +/// then the document is unchanged, and the status bar and the busy cursor show the file is being +/// placed. +#[test] +fn a_file_read_off_the_ui_thread_places_as_one_read_at_once() { + let page = pdf(); + let files = [ + ("photo.tif", picture(Tiff, 300, 200)), + ("photo.psd", psd(300, 200)), + ("photo.bmp", picture(Bmp, 300, 200)), + ("page.pdf", page.clone()), + ("page.ai", page), + ]; + for (name, bytes) in files { + let p = json!({"path": saved("same", name, &bytes), "at": [200, 150]}); + let mut at_once = app(false); + at_once.run("file.place", p.clone()).unwrap(); + let mut app = app(true); + let before = state(&app); + let r = start(&mut app, &p, false).unwrap(); + assert_eq!(r, json!({"background": true, "status": format!("Placing {name}…")})); + assert_eq!(app.ui.status, format!("Placing {name}…")); + assert!(crate::opening::long_operation_busy(&app), "{name}"); + assert!(state(&app) == before, "{name}: nothing is placed until the file is read"); + placed(&mut app); + assert!(state(&app) == state(&at_once), "{name}: placed as at once"); + assert!(app.ui.status.is_empty(), "{name}: {}", app.ui.status); + assert!(!crate::opening::long_operation_busy(&app), "{name}"); + } +} + +/// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a +/// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread; +/// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies +/// they have always had. +#[test] +fn what_the_user_places_is_read_off_the_ui_thread_and_commands_read_at_once() { + let mut app = app(true); + let (a, b) = (saved("routes", "a.tif", &picture(Tiff, 60, 40)), saved("routes", "b.bmp", &picture(Bmp, 60, 40))); + let r = app.run("file.place", json!({"path": a})).unwrap(); + assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}"); + let r = app.run("file.place.queue", json!({"paths": [b]})).unwrap(); + assert!(r["count"] == 1 && app.placing.pending.is_empty(), "{r}"); + assert_eq!(app.place.thumbnails(), ["b.bmp"]); + app.select_tool("selection"); + // The Place dialog's OK with one file. + app.run("file.place", json!({"paths": [a]})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); + assert_eq!(app.placing.pending.len(), 1); + placed(&mut app); + assert_eq!(names(&app), ["a.tif", "a.tif"]); + // With several: the place cursor is loaded once they are read. + app.run("file.place", json!({"paths": [a, b]})).unwrap(); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["background"], true); + assert_eq!(app.ui.status, "Placing a.tif, b.bmp…"); + assert_eq!(app.session.tool_id(), "selection"); + let (inspect, _) = crate::control::ControlRequest::new("ui.inspect", json!({})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &inspect) else { panic!("not done") }; + assert_eq!(r["result"]["placing"], json!(["a.tif, b.bmp"]), "ui.inspect lists the files being read"); + placed(&mut app); + assert_eq!((app.session.tool_id(), app.session.tool_options()["count"].as_u64()), ("place", Some(2))); + assert_eq!(app.place.thumbnails(), ["a.tif", "b.bmp"]); + app.select_tool("selection"); + // A drop and bytes placed without a path. + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + crate::place::drop_files(&mut app, vec![(drop, ("b.bmp".into(), Some(b.clone()), vec![]))]); + crate::io::place_bytes(&mut app, "c.png", &picture(Png, 10, 10)).unwrap(); + assert_eq!(app.placing.pending.len(), 2); + placed(&mut app); + assert_eq!(names(&app), ["a.tif", "a.tif", "b.bmp", "c.png"]); +} + +/// A click and a drag of the place cursor read their files off the UI thread, and place them as +/// a click and a drag place them when they are read at once. +#[test] +fn the_place_cursors_clicks_read_their_files_off_the_ui_thread() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let paths = [saved("cursor", "a.tif", &picture(Tiff, 60, 40)), saved("cursor", "b.bmp", &picture(Bmp, 60, 40))]; + let view = vectorcraft_engine::ViewInfo::default(); + let press = |app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)| { + for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); + } + }; + let (mut at_once, mut app) = (app(false), app(true)); + for app in [&mut at_once, &mut app] { + app.run("file.place.queue", json!({"paths": paths})).unwrap(); + press(app, (10.0, 10.0), (10.0, 10.0)); + } + assert_eq!(names(&at_once), ["a.tif"]); + assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "nothing is placed until the file is read"); + assert_eq!(app.ui.status, "Placing a.tif…"); + placed(&mut app); + assert!(state(&app) == state(&at_once), "the click places as at once"); + // A drag places the last file at the dragged size, and the previous tool comes back. + for app in [&mut at_once, &mut app] { + press(app, (100.0, 100.0), (180.0, 110.0)); + } + placed(&mut app); + assert!(state(&app) == state(&at_once), "the drag places as at once"); + assert_eq!(names(&app), ["a.tif", "b.bmp"]); + assert_eq!(app.session.tool_id(), at_once.session.tool_id()); +} + +/// The Place dialog's line about each file is read off the UI thread: the lines are empty when +/// the dialog opens, and then show what the lines read at once show. +#[test] +fn the_place_dialogs_lines_are_read_off_the_ui_thread() { + let p = json!({"paths": [saved("lines", "a.tif", &picture(Tiff, 60, 40)), saved("lines", "notes.xyz", b"hello")]}); + let mut at_once = app(false); + at_once.run("file.place", p.clone()).unwrap(); + let want = at_once.ui.dialog.as_ref().unwrap().fields["__info"].clone(); + assert!(want[0].as_str().is_some_and(|l| l.starts_with("60 × 40 px")), "{want}"); + let mut app = app(true); + app.run("file.place", p).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], json!(["", ""])); + let t0 = std::time::Instant::now(); + loop { + let Some(mut d) = app.ui.dialog.take() else { panic!("the dialog stays open") }; + let reading = crate::place::fill_lines(&mut app, &mut d); + app.ui.dialog = Some(d); + if !reading || t0.elapsed().as_secs() > 60 { + break; + } + std::thread::yield_now(); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__info"], want); +} + +/// Several files dropped together are placed in the order they were dropped, a large one first +/// included, as when they are read at once. +#[test] +fn files_dropped_together_are_placed_in_the_order_dropped() { + let large = saved("order", "large.tif", &picture(Tiff, 1500, 1500)); + let small = [saved("order", "small1.png", &picture(Png, 8, 8)), saved("order", "small2.png", &picture(Png, 8, 8))]; + let mut app = app(true); + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); + let files = [&large, &small[0], &small[1]].map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))); + crate::place::drop_files(&mut app, files.into()); + assert_eq!(app.placing.pending.len(), 3); + placed(&mut app); + assert_eq!(names(&app), ["large.tif", "small1.png", "small2.png"]); +} + +/// Files dropped together are read one at a time, as when they are read at once, so a batch of +/// large pictures is not decoded all at once. +#[test] +fn files_dropped_together_are_read_one_at_a_time() { + let files: Vec = (0..4).map(|i| saved("one-at-a-time", &format!("large{i}.tif"), &picture(Tiff, 1500, 1500))).collect(); + let mut app = app(true); + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(200.0, 150.0), embed: false }); + let dropped = files.iter().map(|p| (drop, (vectorcraft_engine::cmd::fileio::file_name(p), Some(p.clone()), vec![]))).collect(); + crate::place::drop_files(&mut app, dropped); + placed(&mut app); + assert_eq!(names(&app).len(), 4); + assert_eq!(app.placing.reading[1].load(std::sync::atomic::Ordering::SeqCst), 1, "the most reads at once"); +} + +/// A file that can't be read shows the message it shows when read at once, dropped or placed +/// from the dialog, and nothing is placed. +#[test] +fn a_file_that_cant_be_read_off_the_ui_thread_reports_as_at_once() { + let gone = saved("unread", "gone.tif", b""); + std::fs::remove_file(&gone).unwrap(); + let unknown = saved("unread", "notes.xyz", b"hello"); + for path in [gone, unknown] { + let p = json!({"path": path}); + let mut at_once = app(false); + crate::place::run_for_user(&mut at_once, &p).unwrap_err(); + let mut app = app(true); + let before = state(&app); + crate::place::run_for_user(&mut app, &p).unwrap(); + placed(&mut app); + assert_eq!(app.ui.status, at_once.ui.status); + assert!(state(&app) == before, "{path}: nothing placed"); + // Dropped on the canvas. + let name = vectorcraft_engine::cmd::fileio::file_name(&path); + for app in [&mut at_once, &mut app] { + let doc = app.session.doc().unwrap().uid; + let drop = DropTarget::Place(DropAt { doc, at: vectorcraft_geom::Point::new(100.0, 100.0), embed: false }); + crate::place::drop_files(app, vec![(drop, (name.clone(), Some(path.clone()), vec![]))]); + placed(app); + } + assert_eq!(app.ui.status, at_once.ui.status); + assert!(app.ui.status.starts_with(&format!("Couldn't place {name}: ")), "{}", app.ui.status); + } +} + +/// A file whose document closed before it was read is not placed, and the status bar says why. +#[test] +fn a_file_whose_document_closed_meanwhile_is_not_placed() { + let path = saved("closed", "photo.tif", &picture(Tiff, 60, 40)); + for dropped in [false, true] { + let mut app = app(true); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + start(&mut app, &json!({"path": path}), dropped).unwrap(); + app.session.execute("file.close", &json!({})).unwrap(); + let left = state(&app); + placed(&mut app); + let why = if dropped { "dropped on" } else { "placed in" }; + assert_eq!(app.ui.status, format!("Couldn't place photo.tif: the document it was {why} was closed")); + assert!(state(&app) == left, "the other document is unchanged"); + } +} + +/// Replace swaps the object selected when Place started; when that object was deleted before the +/// file was read, nothing is placed and the status bar says why. +#[test] +fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() { + let path = saved("replace", "photo.tif", &picture(Tiff, 60, 40)); + let mut app = app(true); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + crate::place::run_for_user(&mut app, &json!({"path": path, "replace": true})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + let cleared = state(&app); + placed(&mut app); + assert!(app.ui.status.contains("the object to replace was changed or deleted"), "{}", app.ui.status); + assert!(state(&app) == cleared, "nothing placed"); +} + +/// A file read while a dialog is open waits for the dialog to close, as a document that opens +/// does (#1694): the dialog's OK acts on the active document. +#[test] +fn a_file_read_while_a_dialog_is_open_waits_for_it_to_close() { + let path = saved("dialog", "photo.tif", &picture(Tiff, 60, 40)); + let mut app = app(true); + start(&mut app, &json!({"path": path}), false).unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"}))); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(names(&app).is_empty() && app.placing.pending.len() == 1, "the file waits for the dialog"); + app.ui.dialog = None; + placed(&mut app); + assert_eq!(names(&app), ["photo.tif"]); +} + +/// A file read while an object is being dragged, in its document or in another one, waits for +/// the drag to end: the drag is not canceled, and the placed art stays. +#[test] +fn a_file_read_during_a_drag_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let path = saved("drag", "photo.tif", &picture(Tiff, 60, 40)); + for other_document in [false, true] { + let mut app = app(true); + start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); + if other_document { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + } + let square = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let dragged = app.session.active_index(); + app.select_tool("selection"); + let view = vectorcraft_engine::ViewInfo::default(); + for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 40.0), (PointerKind::Drag, 60.0)] { + app.session.pointer(&PointerEvent::new(kind, x, 20.0), view).unwrap(); + } + assert!(app.session.in_interaction()); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.placing.pending.len() == 1 && app.session.in_interaction(), "other document: {other_document}"); + // The drag moves on before it ends. + for kind in [PointerKind::Drag, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, 60.0, 20.0), view).unwrap(); + } + placed(&mut app); + let moved = app.session.documents()[dragged.unwrap()].doc.node(NodeId(square)).and_then(|n| n.geometric_bounds()); + assert_eq!(moved.map(|b| (b.x0, b.y0)), Some((50.0, 10.0)), "other document: {other_document}"); + assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); + } +} + +/// A file read for one document while the place cursor is dragged in another waits for the drag +/// to end, which the place cursor keeps to itself: the drag still places its file. +#[test] +fn a_file_read_during_a_place_cursor_drag_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let path = saved("cursor-drag", "photo.tif", &picture(Tiff, 60, 40)); + let cursor = [saved("cursor-drag", "a.png", &picture(Png, 8, 8)), saved("cursor-drag", "b.png", &picture(Png, 8, 8))]; + let mut app = app(true); + start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("file.place.queue", json!({"paths": cursor})).unwrap(); + let view = vectorcraft_engine::ViewInfo::default(); + for (kind, x) in [(PointerKind::Down, 20.0), (PointerKind::Drag, 60.0)] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, 20.0), view); + } + assert!(app.session.tool_busy() && !app.session.in_interaction()); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.placing.pending.len() == 1 && app.session.tool_busy(), "the file waits for the drag"); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Up, 60.0, 20.0), view); + placed(&mut app); + let placed_in = |i: usize| -> Vec<(String, Option)> { + let st = &app.session.documents()[i]; + st.doc + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .map(|n| (n.name.clone().unwrap_or_default(), n.geometric_bounds())) + .collect() + }; + assert_eq!(placed_in(0).iter().map(|(n, _)| n.as_str()).collect::>(), ["photo.tif"]); + assert_eq!(placed_in(1), [("a.png".to_string(), Some(vectorcraft_geom::Rect::new(20.0, 20.0, 60.0, 60.0)))], "the drag placed its file"); +} + +/// A file read while a tool waits between its clicks (the Dimension tool after its first point), +/// in the file's document or in another one, is placed without waiting for the next click. In +/// the same document the tool keeps its point; in another one it starts afresh, as when the user +/// switches documents. +#[test] +fn a_file_read_between_a_tools_clicks_is_placed() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let path = saved("clicks", "photo.tif", &picture(Tiff, 60, 40)); + for other_document in [false, true] { + let mut app = app(true); + start(&mut app, &json!({"path": path, "at": [300, 200]}), false).unwrap(); + if other_document { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + } + app.select_tool("dimension"); + let view = vectorcraft_engine::ViewInfo::default(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 20.0, 20.0), view); + } + assert!(app.session.tool_busy() && !app.session.in_interaction(), "other document: {other_document}"); + placed(&mut app); + assert_eq!(names(&app), ["photo.tif"], "other document: {other_document}"); + assert_eq!(app.session.tool_busy(), !other_document, "other document: {other_document}"); + } +}