Files
dotCooCoo 5522059417 Save: the save commands and Export for Screens refuse an unknown option key
document.save, file.saveAs, file.saveCopy and file.saveAsTemplate wrote
the file when given a key that no save format reads, beside the path or
in options, or an unknown key inside a PDF option object such as
marks.useDocumentBleed. document.exportForScreens did the same for an
unknown top-level key, a key in a format row and a key in the settings
of a format.

These commands now refuse such a key and name its path
(options.noSuchOption, formats[1].marks.useDocumentBleed). The svg
object is checked in every save, export and format row, also when the
file written is not SVG. A save still leaves out a key that only another
save format reads, and warns as before. A format row and a format's
settings still take includeArtboardFill and richBlack, which the
encoders read. document.exportPresets.save checks its settings, with the
rows and settings in it, the same way.

In the app, file.save and file.exportForScreens sent as commands are
checked as document.save and document.exportForScreens, and app.save is
checked before anything is written; MCP's app.save, file.save and
file.exportForScreens are checked the same way. The SVG Options dialog
no longer passes its internal fields on to the save.
2026-11-08 08:31:24 -08:00

281 lines
15 KiB
Rust

//! `Session::execute` checks params against the command's description, but a journal replays as
//! it was recorded (#1442).
use serde_json::json;
use vectorcraft_engine::{Session, check_params};
/// The open document's art and undo history, and the session's journal length.
fn state(s: &Session) -> (Option<vectorcraft_doc::Document>, usize, usize) {
let st = s.active();
(st.map(|st| (*st.doc).clone()), st.map_or(0, |st| st.history.undo.len()), s.journal.len())
}
/// `Session::execute` answers a key the command's description doesn't name with a parameter
/// error, in a release build too, and the document, its undo history and the journal stay as
/// they were.
#[test]
fn execute_refuses_a_key_its_command_does_not_name() {
let mut s = Session::new();
let e = s.execute("file.new", &json!({"width": 300, "widht": 1})).unwrap_err().to_string();
assert!(e.contains("widht"), "{e}");
assert!(s.active().is_none());
s.execute("file.new", &json!({})).unwrap();
let before = state(&s);
let e = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})).unwrap_err();
assert!(e.to_string().contains("cornerStyle"), "{e}");
assert!(state(&s) == before);
}
/// A step of a `command.batch` with such a key fails the batch before any step runs, and a step
/// whose `effect.setParams` gives an effect a parameter it doesn't take fails it too, though that
/// effect is only there once the steps before it ran. Nothing the batch did stays.
#[test]
fn execute_refuses_a_batch_step_with_a_key_its_command_does_not_name() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = state(&s);
let rect = json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}});
let bad_step = json!({"commands": [rect, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}]});
let e = s.execute("command.batch", &bad_step).unwrap_err().to_string();
assert!(e.contains("dyy"), "{e}");
assert!(state(&s) == before);
let bad_effect = json!({"commands": [
rect,
{"command": "effect.apply", "params": {"effect": "blur.gaussian"}},
{"command": "effect.setParams", "params": {"index": 0, "params": {"blurr": 3}}},
]});
assert!(check_params("command.batch", &bad_effect).is_ok());
let e = s.execute("command.batch", &bad_effect).unwrap_err().to_string();
assert!(e.contains("blurr"), "{e}");
assert!(state(&s) == before);
}
/// The steps of a `command.batch` step aren't checked, however deep they go: batches can't nest,
/// which running the batch reports.
#[test]
fn the_steps_of_a_nested_batch_are_not_checked() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut batch = json!({"commands": [{"command": "object.move", "params": {"dyy": 5}}]});
assert!(check_params("command.batch", &batch).is_err());
for _ in 0..3 {
batch = json!({"commands": [{"command": "command.batch", "params": batch}]});
}
assert!(check_params("command.batch", &batch).is_ok());
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
assert!(e.contains("batches cannot nest"), "{e}");
}
/// An `action.batch` with a step that has such a key fails before it opens a source file or runs
/// a step.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn execute_refuses_an_action_batch_step_with_a_key_its_command_does_not_name() {
let dir = std::env::temp_dir().join(format!("vc-param-keys-action-batch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10"/></svg>"#;
std::fs::write(dir.join("a.svg"), svg).unwrap();
let mut s = Session::new();
let batch = json!({
"source": "folder",
"folder": dir.to_string_lossy(),
"commands": [{"command": "select.all", "params": {}}, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}],
});
let e = s.execute("action.batch", &batch).unwrap_err().to_string();
std::fs::remove_dir_all(&dir).ok();
assert!(e.contains("dyy"), "{e}");
assert!(s.documents().is_empty());
assert!(s.journal.is_empty());
}
/// `Session::replay` takes what it is given: the journal of an older version, an action recorded
/// with a key a command no longer reads, still plays, as a step of a `command.batch` too.
#[test]
fn a_journal_with_a_key_no_longer_read_still_replays() {
let old = [
("file.new", json!({"width": 300, "height": 200, "widht": 1})),
("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})),
("command.batch", json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 5, "dyy": 5}}]})),
];
let mut s = Session::new();
for (id, params) in &old {
assert!(check_params(id, params).is_err(), "{id}");
s.replay(id, params).unwrap();
}
assert!(!s.replaying());
let st = s.active().unwrap();
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1));
assert_eq!(s.journal.len(), 3);
assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err());
}
/// `Session::replay` still runs an export with an option that is no longer read, and a preset
/// file that holds one still loads and exports. `Session::execute` refuses the same options.
#[test]
fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}});
assert!(check_params("document.export", &old).is_err());
assert!(s.execute("document.export", &old).is_err());
s.replay("document.export", &old).unwrap();
let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]});
s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap();
check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
}
/// So does a save, or an Export for Screens, with a key that is no longer read, and an Export for
/// Screens preset file that holds one in a row still loads and exports. `Session::execute`
/// refuses the same options.
#[test]
fn a_save_or_screens_step_with_a_key_no_longer_read_still_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let save = json!({"format": "pdf", "noSuchOption": true, "options": {"retired": 1, "marks": {"useDocumentBleed": true}}});
assert!(check_params("file.saveCopy", &save).is_err());
assert!(s.execute("file.saveCopy", &save).is_err());
s.replay("file.saveCopy", &save).unwrap();
let screens = json!({"noSuchOption": true, "formats": [{"format": "png", "retired": 1}], "settings": {"pdf": {"marks": {"retired": true}}}});
assert!(check_params("document.exportForScreens", &screens).is_err());
assert!(s.execute("document.exportForScreens", &screens).is_err());
s.replay("document.exportForScreens", &screens).unwrap();
let file = json!({"format": "vcexportpresets", "presets": [{"name": "Old", "settings": {"formats": [{"format": "png", "noLongerRead": 1}]}}]});
s.execute("document.exportPresets.import", &json!({"data": file.to_string()})).unwrap();
check_params("document.exportForScreens", &json!({"exportPreset": "Old"})).unwrap();
s.execute("document.exportForScreens", &json!({"exportPreset": "Old"})).unwrap();
}
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
#[test]
fn effect_params_no_longer_taken_still_replay_and_open() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let old = [
("effect.apply", json!({"effect": "blur.gaussian", "params": {"blurr": 3, "radius": "big"}})),
("effect.setParams", json!({"index": 0, "params": {"amount": 1e308}})),
];
for (id, params) in &old {
assert!(s.check_params(id, params).is_err(), "{id}");
assert!(s.execute(id, params).is_err(), "{id}");
s.replay(id, params).unwrap();
}
let doc = &s.active().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
let stored = |d: &vectorcraft_doc::Document| d.layers[0].children().unwrap()[0].appearance.effects[0].params.clone();
assert_eq!(stored(&back), stored(doc));
assert_eq!(stored(&back)["blurr"], json!(3));
}
/// An effect's `color` that the effect can't read (a string `Color::from_hex` doesn't read, an
/// `[r, g, b]` without three numbers, an object that isn't a color, `null`) is refused by
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` themselves, not only by the check,
/// and the document and its undo history stay as they were. The forms the effect reads pass. A
/// document saved with an unreadable color still opens with it.
#[test]
fn an_effect_color_it_cannot_draw_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"color": "#ff0000"}})).unwrap();
let state = |s: &Session| {
let st = s.active().unwrap();
(st.doc.clone(), st.history.undo.len())
};
let before = state(&s);
let why = "`color` must be a #rrggbb color";
for bad in [json!("not-a-color"), json!("#ff00"), json!([1, 0]), json!(["#ff0000", 0, 0]), json!({"gray": 50}), json!(null)] {
for (cmd, params) in [
("effect.setParams", json!({"index": 0, "params": {"color": bad}})),
("effect.apply", json!({"effect": "stylize.outerGlow", "params": {"color": bad}})),
("appearance.addEffect", json!({"effect": "adjust.shiftToColor", "params": {"color": bad}})),
] {
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
let checked = s.check_params(cmd, &params).unwrap_err().to_string();
assert!(checked.contains(why), "{cmd} {params}: {checked}");
assert!(state(&s) == before, "{cmd} {params} changed the document or its undo history");
}
}
let stored = |s: &Session| s.active().unwrap().doc.layers[0].children().unwrap()[0].appearance.effects[0].params["color"].clone();
for good in [
json!("#00ff00"),
json!("#0f0"),
json!("00ff00"),
json!("#00ff00ff"),
json!("green"),
json!([0, 1, 0]),
json!({"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}),
json!({"model": "cmyk", "c": 1.0, "m": 0.0, "y": 1.0, "k": 0.0}),
] {
let params = json!({"index": 0, "params": {"color": good}});
s.check_params("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
s.execute("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
assert_eq!(stored(&s), good);
}
let mut doc = (*s.active().unwrap().doc).clone();
doc.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["color"] = json!("not-a-color");
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["color"], json!("not-a-color"));
}
/// A `channel` that Levels or Curves doesn't have (which they drew as RGB) is refused by
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` themselves, not only by the check,
/// and the document and its undo history stay as they were. The channels they read pass. A
/// document saved with another channel still opens with it.
#[test]
fn a_tone_curve_channel_it_does_not_have_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"channel": "red"}})).unwrap();
let state = |s: &Session| {
let st = s.active().unwrap();
(st.doc.clone(), st.history.undo.len())
};
let before = state(&s);
let why = "`channel` must be one of rgb, red, green, blue";
for bad in [json!("alpha"), json!("cyan"), json!(""), json!("reds"), json!(null)] {
for (cmd, params) in [
("effect.setParams", json!({"index": 0, "params": {"channel": bad}})),
("effect.apply", json!({"effect": "adjust.curves", "params": {"channel": bad}})),
("appearance.addEffect", json!({"effect": "adjust.levels", "params": {"channel": bad}})),
] {
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
let checked = s.check_params(cmd, &params).unwrap_err().to_string();
assert!(checked.contains(why), "{cmd} {params}: {checked}");
assert!(state(&s) == before, "{cmd} {params} changed the document or its undo history");
}
}
let stored = |s: &Session| s.active().unwrap().doc.layers[0].children().unwrap()[0].appearance.effects[0].params["channel"].clone();
for good in [json!("rgb"), json!("green"), json!("blue"), json!("Red"), json!("RGB"), json!("r"), json!("G"), json!("b")] {
let params = json!({"index": 0, "params": {"channel": good}});
s.check_params("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
s.execute("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
assert_eq!(stored(&s), good);
}
let mut doc = (*s.active().unwrap().doc).clone();
doc.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["channel"] = json!("alpha");
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["channel"], json!("alpha"));
}
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
/// sent back through the control channel or MCP.
#[test]
fn a_recorded_journal_passes_the_check() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.scale", &json!({"sx": 150})).unwrap();
s.execute("command.batch", &json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 0}}, {"command": "edit.copy"}]})).unwrap();
assert!(s.journal.iter().count() >= 4);
for (id, params) in s.journal.iter() {
check_params(id, params).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
}
}