vectorcraft/crates/ui-egui/src/tests_background.rs

183 lines
7.5 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 document is saved once the file is written.
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.
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());
}