Files
vectorcraft/crates/ui-egui/src/dialogs/tests_plugin_export.rs
T
Louise FoxandClaude Opus 5.5 24936cd006 Plug-ins: exporters write file formats of their own (G17)
A plug-in could filter objects or reshape them as a live effect, but
nothing it made could leave the app: pen plotters, cutting machines and
laser cutters read formats VectorCraft doesn't write. An exporter is a
third kind of plug-in ("kind": "exporter", with the file "extensions"
it writes), in the same sandbox and with the same ABI v1 exports.

- It adds a write-only format, plugin.<id>. document.formats lists it
  with its plug-in id, and document.export writes it by that id, or by
  a path ending in one of its extensions that no built-in format has;
  "params" carries its parameters, checked against its manifest as a
  filter's are.
- File › Export › Export As lists it after the built-in formats, with
  Use Artboards greyed out, since the plug-in writes the whole
  document. One with parameters asks for them in a dialog generated
  from its schema (pluginExport) once the file is picked.
- The plug-in is given the native file's JSON (format::json_part:
  images of 8 KiB or more without their data) and _context {mode:
  "export", extension, name}. It returns {"dataBase64", "warnings"?}
  or {"error"}. The host writes the file, and nothing when the plug-in
  declines or returns bad data.
- An export gets 10 G instructions plus 4,000 per byte and 300 s.
  Background Export's Cancel stops it between two slices of that
  budget (Error::Cancelled), and a cancelled live-effect run is not
  cached as a failure.
- plugin.run and Object › Plug-ins say where an exporter is instead of
  calling it a live effect.

engine/tests/plugin_export.rs reads back what an exporter is handed
with wat::echo_exporter, an exporter whose file is its input or its
parameters (its base64 encoder is written in WAT). The document it is
given is document.save's JSON, without the large image's data, and its
parameters arrive defaulted, clamped and with the file's name and
extension. It also covers listing and removal, built-in extensions
winning, selectedOnly, declines, bad base64, missing data, warnings,
and Cancel stopping a plug-in that never returns. It is a test binary
of its own: installed plug-ins are process-wide, and the format-list
unit tests check exact lists. dialogs/tests_plugin_export.rs drives
Export As headlessly; plugins/tests/abi.rs covers the budget, the
parameters and the echo exporter; format/tests/native_v4.rs covers
json_part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:17:48 +07:00

117 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Exporter plug-ins in File › Export › Export As: listed after the built-in formats, written
//! whole (Use Artboards is not theirs to apply), and their parameters asked for once the file is
//! picked. Each test installs exporters of its own: plug-ins are process-wide.
use serde_json::{Value, json};
use vectorcraft_plugins::wat::{self as tpl, Echo};
use super::tests_export::{app, frame, kind, names, set};
use super::*;
fn install(app: &mut VectorcraftApp, src: &str) {
let wasm = wat::parse_str(src).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
}
#[test]
fn export_as_lists_exporters_and_writes_their_file_whole() {
let (mut app, written) = app(2);
install(&mut app, &tpl::returning(&tpl::exporter("test.ui.whole", r#"["tuw"]"#, "{}"), r#"{"dataBase64":"SGk=","warnings":["a note"]}"#));
// After the built-in formats, with the other exporters installed.
let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect();
let first = listed.iter().position(|id| id.starts_with("plugin.")).expect("an exporter listed");
let added = listed.get(first..).unwrap_or_default();
assert!(added.iter().all(|id| id.starts_with("plugin.")) && added.contains(&"plugin.test.ui.whole"), "{listed:?}");
// It isn't a filter: Object › Plug-ins' command says where it is.
let e = app.run("plugin.dialog", json!({"id": "test.ui.whole"})).unwrap_err();
assert!(e.contains("is an exporter (File › Export › Export As…)"), "{e}");
app.run("file.exportAs", json!({"format": "plugin.test.ui.whole"})).unwrap();
assert_eq!(kind(&app), Some("exportAs"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "plugin.test.ui.whole");
// Use Artboards is greyed out and doesn't apply: one file of the whole document.
set(&mut app, "useArtboards", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "an exporter without parameters writes at once");
assert_eq!(names(&written), ["/out/Untitled-1.tuw"]);
assert_eq!(written.borrow()[0].1, b"Hi");
assert!(app.ui.status.contains("a note"), "{}", app.ui.status);
}
#[test]
fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() {
let (mut app, written) = app(1);
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
install(&mut app, &tpl::echo_exporter(&tpl::exporter("test.ui.params", r#"["tup"]"#, params), Echo::Params));
// Asked for by its extension, as agents may.
app.run("file.exportAs", json!({"format": "tup"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "plugin.test.ui.params");
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.params".to_string(), Some(&json!(4.0))));
assert!(written.borrow().is_empty(), "nothing written before Export");
frame(&mut app);
set(&mut app, "density", json!(7));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(names(&written), ["/out/Untitled-1.tup"]);
let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap();
assert_eq!(sent, json!({"density": 7.0, "_context": {"mode": "export", "extension": "tup", "name": "Untitled-1"}}));
}
/// `CRAFT_UI_PLUGIN_EXPORT_SHOT=<folder>` saves the pictures of the dialogs to that folder: Export As
/// with an exporter's format chosen, then the exporter's own dialog. The plug-in is a test one with
/// parameters a plotter's exporter could have, saved beside them (`plotter-test-exporter.wasm`) to
/// install in the app.
#[test]
fn export_as_and_exporter_dialog_pictures() {
let Some(dir) = std::env::var_os("CRAFT_UI_PLUGIN_EXPORT_SHOT") else { return };
let dir = std::path::PathBuf::from(dir);
let (mut app, _) = app(1);
let params = r#"{"speed":{"type":"number","min":1,"max":100,"default":40},"passes":{"type":"int","min":1,"max":10,"default":1},
"optimizeTravel":{"type":"bool","default":true},"paper":{"type":"choice","options":["A4","A3","Letter"],"default":"A4"}}"#;
let manifest = r#"{"id":"org.example.plotter","name":"Plotter (test exporter)","version":"1","kind":"exporter","extensions":["plt"],
"description":"Writes the document as pen plotter commands.","params":PARAMS}"#
.replace("PARAMS", params);
let src = tpl::echo_exporter(&manifest, Echo::Params);
std::fs::write(dir.join("plotter-test-exporter.wasm"), wat::parse_str(&src).unwrap()).unwrap();
install(&mut app, &src);
app.run("file.exportAs", json!({"format": "plugin.org.example.plotter"})).unwrap();
let shot = |app: VectorcraftApp, name: &str| -> VectorcraftApp {
let mut ready = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(720.0, 520.0)).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
ready = true;
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(6);
h.render().unwrap().save(dir.join(name)).unwrap();
h.into_state()
};
app = shot(app, "export-as-exporter.png");
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND));
shot(app, "exporter-options.png");
}
/// A plug-in that declines keeps nothing written and says why.
#[test]
fn a_declining_exporter_writes_nothing() {
let (mut app, written) = app(1);
install(&mut app, &tpl::returning(&tpl::exporter("test.ui.declines", r#"["tud"]"#, "{}"), r#"{"error":"Nothing to plot."}"#));
app.run("file.exportAs", json!({"format": "plugin.test.ui.declines"})).unwrap();
let r = confirm(&mut app);
assert!(written.borrow().is_empty());
let said = r.err().unwrap_or_default() + &app.ui.status;
assert!(said.contains("Nothing to plot."), "{said}");
}