Files
vectorcraft/crates/ui-egui/src/tests_background.rs
T
dotCooCoo 580aa8daa5 Clipboard: Copy and Cut make the system clipboard's formats on the background thread
Copy and Cut made the system clipboard's formats in the frame that ran
them. The formats include the SVG markup and a PNG of the selection at
1 px per pt, up to 4,096 px a side. In a release build on Windows, Copy
of 50,002 rectangles on a 1600 x 1200 pt page took 1.00 s in its frame.

The thread that writes background saves and exports now makes the
formats from a copy of the clipboard, and the app writes them in a
later frame. The same Copy takes 0.18 s in its frame, and the SVG and
PNG are the same bytes. While that thread has a save, an export, a print or
a recovery copy to finish, Copy and Cut make and write the formats at
once. A later Copy or Cut, or text copied in a field or with the Type
tool, drops formats that are not written yet. Until they are written,
Paste on the canvas takes the internal clipboard, and while a text
field or the Type tool has the keyboard the app waits for them.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00

184 lines
7.6 KiB
Rust

//! Background Save and Background Export: the file is written from a snapshot off the UI thread,
//! and the document counts as saved as of that snapshot.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_engine::Session;
use vectorcraft_geom::{Rect, shapes};
use crate::{Services, VectorcraftApp, background, io};
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Holds the background writer until opened.
#[derive(Clone, Default)]
struct Gate(Arc<AtomicBool>);
impl Gate {
fn open(&self) {
self.0.store(true, Ordering::SeqCst);
}
fn wait(&self) {
while !self.0.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
/// An app whose thread-safe writer records files once `gate` opens.
fn app(gate: Gate) -> (VectorcraftApp, Files) {
let files = Files::default();
let f = files.clone();
let services = Services {
write: Some(Box::new(|_: &str, _: &[u8]| Err("the UI thread must not write here".into()))),
write_shared: Some(Arc::new(move |p: &str, b: &[u8]| {
gate.wait();
f.lock().unwrap().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
(VectorcraftApp::new(Session::new(), services), files)
}
/// A document of `n` paths.
fn paths(n: usize) -> Document {
let mut d = Document::new(1000.0, 1000.0);
let layer = d.layers[0].id;
for i in 0..n {
let id = d.alloc_id();
let (x, y) = ((i % 250) as f64 * 4.0, (i / 250) as f64 * 4.0);
d.insert(Some(layer), i, Node::path(id, shapes::ellipse(Rect::new(x, y, x + 3.0, y + 3.0)), Appearance::default_art())).unwrap();
}
d
}
fn written(files: &Files) -> Vec<String> {
files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect()
}
#[test]
fn a_50k_path_save_returns_at_once_and_the_file_appears_later() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.session.add_document(paths(50_000), None);
let r = app.run("file.save", json!({"path": "/docs/big.vectorcraft"})).unwrap();
assert_eq!((r["background"].as_bool(), r["path"].as_str()), (Some(true), Some("/docs/big.vectorcraft")));
assert!(written(&files).is_empty(), "nothing written yet");
assert_eq!(app.background.jobs.len(), 1);
assert!(app.ui.status.starts_with("Saving big.vectorcraft"));
// The file is the document's once it is written (and its dates are stamped then, #1721: until
// then this document, never edited, is as it was).
assert!(!app.session.doc().unwrap().is_dirty() && app.session.doc().unwrap().path.is_none());
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/docs/big.vectorcraft"]);
let st = app.session.doc().unwrap();
assert!(!st.is_dirty());
assert_eq!(st.path.as_deref(), Some("/docs/big.vectorcraft"));
assert_eq!(app.ui.status, "Saved /docs/big.vectorcraft");
let bytes = files.lock().unwrap()[0].1.clone();
assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), 50_001);
}
#[test]
fn the_dirty_state_follows_the_snapshot() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.run("file.save", json!({"path": "/docs/a.vectorcraft"})).unwrap();
// Edited while the file is written: still modified afterwards.
app.run("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
gate.open();
background::wait_all(&mut app);
assert!(app.session.doc().unwrap().is_dirty(), "the edit came after the snapshot");
let saved = vectorcraft_format::load(&files.lock().unwrap()[0].1).unwrap();
assert_eq!(saved.node_count(), 2, "the file holds the snapshot");
// Undoing back to the snapshot is clean again.
app.run("edit.undo", json!({})).unwrap();
assert!(!app.session.doc().unwrap().is_dirty());
}
#[test]
fn exports_run_in_the_background_and_the_preference_turns_both_off() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let r = io::export(&mut app, Some("svg"), Some("/out/a.svg".into()), &json!({})).unwrap();
assert_eq!((r["background"].as_bool(), r["path"].as_str()), (Some(true), Some("/out/a.svg")));
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/out/a.svg"]);
assert_eq!(app.ui.status, "Exported /out/a.svg");
// Preferences off: at once, through the ordinary writer (which this app refuses).
app.session.execute("prefs.set", &json!({"values": {"backgroundSave": false, "backgroundExport": false}})).unwrap();
assert!(io::export(&mut app, Some("svg"), Some("/out/b.svg".into()), &json!({})).is_err());
assert!(app.run("file.save", json!({"path": "/docs/x.vectorcraft"})).is_err());
assert!(app.background.jobs.is_empty());
}
#[test]
fn quitting_waits_for_background_saves() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("file.save", json!({"path": "/docs/q.vectorcraft"})).unwrap();
let opener = gate.clone();
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
opener.open();
});
// Without waiting, the document would still count as modified and Quit would ask about it.
let r: Value = app.run("app.quit", json!({})).unwrap();
release.join().unwrap();
assert!(r.is_null(), "{r}");
assert_eq!(app.ui.status, "quit");
assert_eq!(written(&files), ["/docs/q.vectorcraft"]);
}
/// The status bar's texts in one headless frame.
pub(super) fn status_bar_texts(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::status_bar(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
#[test]
fn the_status_bar_shows_progress() {
let gate = Gate::default();
let (mut app, _) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("file.save", json!({"path": "/docs/p.vectorcraft"})).unwrap();
io::export(&mut app, Some("svg"), Some("/out/p.svg".into()), &json!({})).unwrap();
let busy = status_bar_texts(&mut app);
assert!(busy.iter().any(|t| t == "Saving p.vectorcraft… (+1)"), "{busy:?}");
gate.open();
background::wait_all(&mut app);
let done = status_bar_texts(&mut app);
assert!(done.iter().any(|t| t == "Exported /out/p.svg") && !done.iter().any(|t| t.contains('…')), "{done:?}");
}
#[test]
fn bad_options_fail_before_going_to_the_background() {
let (mut app, files) = app(Gate::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
for bad in [json!({"path": "/docs/v.vectorcraft", "version": 9}), json!({"path": "/docs/v.vectorcraft", "version": 1, "compress": true})] {
assert!(app.run("file.save", bad.clone()).is_err(), "{bad}");
}
assert!(app.background.jobs.is_empty() && written(&files).is_empty());
}