mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 05:55:32 +00:00
Plug-ins: the approval dialog takes OK only once its question was shown for half a second
376 lines
20 KiB
Rust
376 lines
20 KiB
Rust
//! Heavy plug-in filters in the app ([`crate::plugin_runs`]): installing one asks first, its dialog
|
||
//! has no Preview, and it runs off the UI thread, its result applied as one undo step once it is
|
||
//! done, unless the document changed meanwhile. Cancel stops it; its progress window shows it. A
|
||
//! light filter that reports its progress runs there too.
|
||
|
||
use std::time::{Duration, Instant};
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_plugins::{registry, wat as tpl};
|
||
|
||
use crate::{VectorcraftApp, dialogs, plugin_runs, theme};
|
||
|
||
/// An app with a new 200 × 200 pt document.
|
||
fn app() -> VectorcraftApp {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.allow_plugin_grants_from_requests();
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
app
|
||
}
|
||
|
||
/// A heavy test filter `id` (named "Test `id`") with `params` that counts `steps` down, then adds
|
||
/// a triangle.
|
||
fn heavy(id: &str, steps: u64, params: &str) -> Vec<u8> {
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy","params":{params}}}"#);
|
||
let output = r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[30,0]},{"p":[15,20]}]}]}}]}"#;
|
||
wat::parse_str(tpl::spinning(&manifest, steps, output)).unwrap()
|
||
}
|
||
|
||
/// Installs [`heavy`] filter `id` as the user would once they agreed.
|
||
fn install(app: &mut VectorcraftApp, id: &str, steps: u64, params: &str) {
|
||
let data = vectorcraft_format::base64_encode(&heavy(id, steps, params));
|
||
app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true})).unwrap();
|
||
}
|
||
|
||
/// Applies the runs' results as the frames would, until none is left (at most 30 s).
|
||
fn wait(app: &mut VectorcraftApp) {
|
||
let t0 = Instant::now();
|
||
while !app.plugin_runs.running.is_empty() && t0.elapsed() < Duration::from_secs(30) {
|
||
plugin_runs::poll(app);
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
assert!(app.plugin_runs.running.is_empty(), "still running");
|
||
}
|
||
|
||
/// One headless frame of the dialog layer.
|
||
fn frame(app: &mut VectorcraftApp) {
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// Draws the progress window at `time` seconds → the texts it drew.
|
||
fn progress(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> String {
|
||
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| plugin_runs::show(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
let mut texts = String::new();
|
||
for shape in out.shapes {
|
||
if let egui::epaint::Shape::Text(t) = shape.shape {
|
||
texts.push_str(t.galley.text());
|
||
texts.push('\n');
|
||
}
|
||
}
|
||
texts
|
||
}
|
||
|
||
/// Install Plug-in asks before it installs a filter that asks for a heavy budget, and OK installs
|
||
/// it; File › Open of the `.wasm` asks too, and Cancel installs nothing.
|
||
#[test]
|
||
fn installing_a_heavy_filter_asks_first() {
|
||
let mut app = app();
|
||
let id = "test.ui.heavy.install";
|
||
let bytes = heavy(id, 1, "{}");
|
||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "route.wasm"})).unwrap();
|
||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||
let pending = app.pending_plugin_install.as_ref().unwrap();
|
||
assert_eq!(pending.message, format!("Install “Test {id}”, which asks for a heavy budget?"));
|
||
assert!(pending.detail.contains("up to 5 minutes and do 20 times the work"), "{}", pending.detail);
|
||
assert!(registry::get(id).is_none(), "not before the user agrees");
|
||
super::plugins::show_approval(&mut app);
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert_eq!(registry::get(id).map(|p| p.manifest().name.clone()), Some(format!("Test {id}")));
|
||
assert_eq!(app.ui.status, format!("Installed plug-in Test {id}"));
|
||
// File › Open of the .wasm asks too, and Cancel installs nothing.
|
||
registry::remove(id);
|
||
crate::io::open_bytes(&mut app, "route.wasm", &bytes, None).unwrap();
|
||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND));
|
||
dialogs::cancel(&mut app);
|
||
assert!(registry::get(id).is_none());
|
||
}
|
||
|
||
/// The dialog has no Preview; OK closes it and the filter runs in the background, the status bar
|
||
/// saying so, and its result is one undo step named after it, selected.
|
||
#[test]
|
||
fn a_heavy_filter_runs_off_the_ui_thread_and_applies_as_one_undo_step() {
|
||
let mut app = app();
|
||
let id = "test.ui.heavy.run";
|
||
install(&mut app, id, 1000, r#"{"size":{"type":"number","min":1,"max":9,"default":3}}"#);
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
assert!(app.ui.dialog.as_ref().unwrap().fields.get("preview").is_none(), "no Preview: a run may take minutes");
|
||
frame(&mut app);
|
||
assert!(!app.session.in_interaction(), "nothing previewed");
|
||
let undo = app.session.doc().unwrap().history.undo.len();
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
assert_eq!(app.plugin_runs.running.len(), 1);
|
||
assert_eq!(app.ui.status, format!("Running “Test {id}”…"));
|
||
wait(&mut app);
|
||
let st = app.session.doc().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||
assert_eq!(st.history.undo.last().unwrap().label, format!("Test {id}"));
|
||
assert_eq!(st.selection.objects.len(), 1, "the triangle it added, selected");
|
||
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
|
||
}
|
||
|
||
/// A document edited while the filter runs doesn't take its result: nothing changes, and the status
|
||
/// bar says to run it again.
|
||
#[test]
|
||
fn a_heavy_filter_s_result_needs_the_document_as_it_was() {
|
||
let mut app = app();
|
||
let id = "test.ui.heavy.stale";
|
||
install(&mut app, id, 1, "{}");
|
||
// Without parameters it runs at once, in the background.
|
||
assert_eq!(app.run("plugin.dialog", json!({"id": id})).unwrap()["background"], true);
|
||
// An edit made while it runs: its result no longer fits.
|
||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
|
||
let doc = app.session.doc().unwrap().doc.clone();
|
||
wait(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().doc, doc, "nothing changed");
|
||
assert_eq!(app.ui.status, format!("“Test {id}” finished, but the document or the plug-in changed meanwhile: run it again"));
|
||
}
|
||
|
||
/// The progress window opens after the delay, headed by the run; Cancel stops the run, which
|
||
/// changes nothing, and the window closes.
|
||
#[test]
|
||
fn cancel_stops_a_heavy_filter_shown_in_its_progress_window() {
|
||
let mut app = app();
|
||
let id = "test.ui.heavy.cancel";
|
||
// It would run for minutes.
|
||
install(&mut app, id, 1 << 40, "{}");
|
||
let doc = app.session.doc().unwrap().doc.clone();
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
assert!(!progress(&mut app, &ctx, 0.0).contains("Running"), "not before the delay");
|
||
progress(&mut app, &ctx, 1.0); // a new window measures itself unseen first
|
||
let shown = progress(&mut app, &ctx, 1.1);
|
||
let heading = format!("Running “Test {id}”…");
|
||
assert!(shown.contains(&heading) && shown.contains("The plug-in is computing…") && shown.contains("Cancel"), "{shown}");
|
||
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1);
|
||
assert!(!progress(&mut app, &ctx, 2.0).contains(&heading), "a cancelled run isn't shown");
|
||
wait(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
|
||
}
|
||
|
||
/// Continue in Background closes the progress window while the run goes on; Cancel still stops it.
|
||
#[test]
|
||
fn continue_in_background_hides_the_progress_window() {
|
||
let mut app = app();
|
||
let id = "test.ui.heavy.hide";
|
||
install(&mut app, id, 1 << 40, "{}");
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
progress(&mut app, &ctx, 0.0);
|
||
progress(&mut app, &ctx, 1.0);
|
||
let heading = format!("Running “Test {id}”…");
|
||
assert!(progress(&mut app, &ctx, 1.1).contains(&heading));
|
||
assert_eq!(app.run("plugin.hideRuns", json!({})).unwrap(), json!({"hidden": 1}));
|
||
assert!(!progress(&mut app, &ctx, 2.0).contains(&heading));
|
||
assert!(app.plugin_runs.running.first().is_some_and(|r| r.hidden), "still running");
|
||
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap(), json!({"cancelled": 1}));
|
||
wait(&mut app);
|
||
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
|
||
}
|
||
|
||
/// A heavy filter of plug-in API level 2 that reports its progress gets a bar in its progress window,
|
||
/// with the share it reported, clamped; Cancel makes its `vc_cancelled` return 1 and it stops,
|
||
/// changing nothing.
|
||
#[test]
|
||
fn a_filter_that_reports_progress_shows_a_bar_and_sees_cancel() {
|
||
let mut app = app();
|
||
let id = "test.ui.heavy.progress";
|
||
let manifest =
|
||
format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","budget":"heavy","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
|
||
let first = "(call $vc_progress (i64.const 5000) (i64.const 1000)) (call $vc_progress (i64.const 250) (i64.const 1000))
|
||
(block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))";
|
||
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::reporting(&manifest, first, r#"{"objects":[]}"#)).unwrap());
|
||
app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true, "grant": ["doc.read", "doc.write", "progress"]})).unwrap();
|
||
let doc = app.session.doc().unwrap().doc.clone();
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
// Waits for the run's thread to report the share (a handshake: the thread never stops before
|
||
// it is cancelled, and a run that ended leaves the loop).
|
||
loop {
|
||
plugin_runs::poll(&mut app);
|
||
match app.plugin_runs.running.first() {
|
||
Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(),
|
||
_ => break,
|
||
}
|
||
}
|
||
assert_eq!(app.plugin_runs.running.first().map(|r| r.progress.counts()), Some((250, 1000)), "the second report, after the first was clamped");
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
progress(&mut app, &ctx, 0.0);
|
||
progress(&mut app, &ctx, 1.0);
|
||
let shown = progress(&mut app, &ctx, 1.1);
|
||
assert!(shown.contains("The plug-in is computing…") && shown.contains("25 %"), "{shown}");
|
||
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1);
|
||
while !app.plugin_runs.running.is_empty() {
|
||
plugin_runs::poll(&mut app);
|
||
std::thread::yield_now();
|
||
}
|
||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
|
||
}
|
||
|
||
/// The triangle the light test filters return.
|
||
const TRIANGLE: &str = r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[30,0]},{"p":[15,20]}]}]}}]}"#;
|
||
|
||
/// Installs a light filter `id` of plug-in API level 2 with `params` that returns [`TRIANGLE`],
|
||
/// granted as the user would grant it. With `progress` it lists the `progress` permission, and its
|
||
/// `vc_run` first runs `first`, which may report its progress.
|
||
fn install_light(app: &mut VectorcraftApp, id: &str, progress: bool, params: &str, first: &str) {
|
||
let may: &[&str] = if progress { &["doc.read", "doc.write", "progress"] } else { &["doc.read", "doc.write"] };
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{},"params":{params}}}"#, json!(may));
|
||
// Without `progress` the module may not import the host functions.
|
||
let src = if progress { tpl::reporting(&manifest, first, TRIANGLE) } else { tpl::returning(&manifest, TRIANGLE) };
|
||
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
|
||
app.run("plugin.install", json!({"dataBase64": data, "grant": may})).unwrap();
|
||
}
|
||
|
||
/// Applies the runs' results as the frames would, until none is left (each run ends: it returns,
|
||
/// or its budget stops it).
|
||
fn finish_runs(app: &mut VectorcraftApp) {
|
||
while !app.plugin_runs.running.is_empty() {
|
||
plugin_runs::poll(app);
|
||
std::thread::yield_now();
|
||
}
|
||
}
|
||
|
||
/// A light filter that may report its progress runs off the UI thread when it runs at once, as a
|
||
/// heavy one does: the status bar says so, and its result is one undo step named after it.
|
||
#[test]
|
||
fn a_light_filter_that_reports_progress_runs_off_the_ui_thread() {
|
||
let mut app = app();
|
||
let id = "test.ui.light.progress.run";
|
||
install_light(&mut app, id, true, "{}", "(call $vc_progress (i64.const 1) (i64.const 2))");
|
||
let undo = app.session.doc().unwrap().history.undo.len();
|
||
let r = app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
assert_eq!(r["background"], true, "{r}");
|
||
assert_eq!(app.ui.status, format!("Running “Test {id}”…"));
|
||
finish_runs(&mut app);
|
||
let st = app.session.doc().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||
assert_eq!(st.history.undo.last().unwrap().label, format!("Test {id}"));
|
||
assert_eq!(st.selection.objects.len(), 1, "the triangle it added, selected");
|
||
}
|
||
|
||
/// A light filter that reports its progress shows it in the progress window, and Cancel there
|
||
/// makes its `vc_cancelled` return 1: it stops, changing nothing.
|
||
#[test]
|
||
fn a_light_filter_s_progress_window_shows_its_share_and_cancel_stops_it() {
|
||
let mut app = app();
|
||
let id = "test.ui.light.progress.cancel";
|
||
let first = "(call $vc_progress (i64.const 250) (i64.const 1000)) (block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))";
|
||
install_light(&mut app, id, true, "{}", first);
|
||
let doc = app.session.doc().unwrap().doc.clone();
|
||
assert_eq!(app.run("plugin.dialog", json!({"id": id})).unwrap()["background"], true);
|
||
// Waits for the run's thread to report the share (a handshake: the thread never stops before
|
||
// it is cancelled or its budget runs out, and a run that ended leaves the loop).
|
||
loop {
|
||
plugin_runs::poll(&mut app);
|
||
match app.plugin_runs.running.first() {
|
||
Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(),
|
||
_ => break,
|
||
}
|
||
}
|
||
let ctx = egui::Context::default();
|
||
theme::install_fonts(&ctx);
|
||
progress(&mut app, &ctx, 0.0);
|
||
progress(&mut app, &ctx, 1.0);
|
||
let shown = progress(&mut app, &ctx, 1.1);
|
||
assert!(shown.contains(&format!("Running “Test {id}”…")) && shown.contains("25 %") && shown.contains("Cancel"), "{shown}");
|
||
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1);
|
||
finish_runs(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
|
||
}
|
||
|
||
/// In the dialog of a light filter that reports its progress, OK with Preview off runs it in the
|
||
/// background, also right after Preview was turned off; with Preview on, OK keeps the previewed
|
||
/// run, as for any light filter.
|
||
#[test]
|
||
fn ok_runs_a_light_progress_filter_in_the_background_without_a_preview() {
|
||
let mut app = app();
|
||
let id = "test.ui.light.progress.dialog";
|
||
install_light(&mut app, id, true, r#"{"size":{"type":"number","min":1,"max":9,"default":3}}"#, "");
|
||
let undo = app.session.doc().unwrap().history.undo.len();
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||
frame(&mut app);
|
||
assert!(!app.session.in_interaction(), "nothing previewed");
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
assert_eq!(app.plugin_runs.running.len(), 1, "running in the background");
|
||
finish_runs(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||
// Preview on: OK keeps the previewed run, and nothing runs in the background.
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("preview"), Some(&json!(true)));
|
||
frame(&mut app);
|
||
assert!(app.session.in_interaction(), "previewed");
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.plugin_runs.running.is_empty());
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2);
|
||
// Preview turned off after a preview and OK before the next frame: the preview is rolled back
|
||
// and the run goes to the background.
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
frame(&mut app);
|
||
assert!(app.session.in_interaction(), "previewed");
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert!(!app.session.in_interaction(), "the preview was rolled back");
|
||
assert_eq!(app.plugin_runs.running.len(), 1, "running in the background");
|
||
finish_runs(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 3);
|
||
}
|
||
|
||
/// In the dialog of a light filter that reports its progress, a failed preview is not kept: OK
|
||
/// rolls it back and runs the filter in the background instead of again on the UI thread.
|
||
#[test]
|
||
fn ok_after_a_failed_preview_runs_a_light_progress_filter_in_the_background() {
|
||
let mut app = app();
|
||
let id = "test.ui.light.progress.fails";
|
||
install_light(&mut app, id, true, r#"{"size":{"type":"number","min":1,"max":9,"default":3}}"#, "(unreachable)");
|
||
let doc = app.session.doc().unwrap().doc.clone();
|
||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
frame(&mut app);
|
||
assert!(app.session.in_interaction(), "the preview's interaction");
|
||
assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key("__refused"), "the preview failed");
|
||
dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
assert!(!app.session.in_interaction(), "the failed preview was rolled back");
|
||
assert_eq!(app.plugin_runs.running.len(), 1, "running in the background");
|
||
finish_runs(&mut app);
|
||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||
}
|
||
|
||
/// A light filter without the `progress` permission still runs at once, on the UI thread.
|
||
#[test]
|
||
fn a_light_filter_that_does_not_report_progress_runs_at_once() {
|
||
let mut app = app();
|
||
let id = "test.ui.light.plain";
|
||
install_light(&mut app, id, false, "{}", "");
|
||
let r = app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||
assert!(r.get("background").is_none(), "{r}");
|
||
assert!(app.plugin_runs.running.is_empty());
|
||
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the triangle, added at once");
|
||
}
|
||
|
||
/// `CRAFT_UI_HEAVY_FILTER_WASM=<folder>` writes a heavy test filter to install in the app
|
||
/// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so
|
||
/// its progress window stays up for a picture.
|
||
#[test]
|
||
fn heavy_test_filter_for_pictures() {
|
||
let Some(dir) = std::env::var_os("CRAFT_UI_HEAVY_FILTER_WASM") else { return };
|
||
let manifest = r#"{"id":"org.example.order","name":"Order Paths for Plotting (test plug-in)","version":"1","kind":"filter",
|
||
"budget":"heavy","description":"Orders the selected paths so the pen travels as little as possible between them.",
|
||
"params":{"passes":{"type":"int","min":1,"max":100,"default":20,"label":"Improvement passes"},
|
||
"start":{"type":"choice","options":["top left","nearest"],"default":"top left","label":"Start at"}}}"#;
|
||
let src = tpl::spinning(manifest, 1 << 40, r#"{"objects":[]}"#);
|
||
std::fs::write(std::path::PathBuf::from(dir).join("order-paths-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
}
|