mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
With Align to Artboard chosen, the Align panel's six Distribute Objects
buttons and its two Distribute Spacing buttons acted on the selection.
object.distribute and object.distributeSpacing took no to or artboard
param. Distribute Objects kept the outermost objects where they were
and did nothing with two objects, and Distribute Spacing kept the first
object's left or top edge and the last object's right or bottom edge
where they were.
Both commands now take to ("selection", "artboard" or "key") and
artboard, and the panel sends its Align To choice as to. When to is
"artboard", the first object's left or top edge goes to the artboard's
left or top edge, and the last object's right or bottom edge goes to
the artboard's right or bottom edge. Distribute Objects spaces the
other objects evenly by its button's line, and two objects are enough.
Distribute Spacing without a spacing value makes the gaps between the
objects equal. Both commands use the same artboard as object.align.
The app passes the active artboard to both commands, and recorded
actions leave it out, so an action plays on the artboard that is
active when it plays. With "selection" or "key", or without to, both
commands work as before. Distribute Spacing with a spacing value still
keeps the key object in place with each of the three to values. With
anchor points selected, both commands still space the points among
themselves and do not use to.
Docs: docs/mcp.md, docs/target-app-parity.md.
701 lines
40 KiB
Rust
701 lines
40 KiB
Rust
//! Every-command sweep: each registered command, in three fixture states, with `{}` and with fuzzed
|
||
//! junk params, must return Ok or Err (never panic), never leave an interaction open, and never
|
||
//! corrupt the tree.
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_engine::{Session, command_specs};
|
||
use vectorcraft_testkit::catch_quiet;
|
||
use vectorcraft_testkit::fixtures::Fixture;
|
||
use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
|
||
use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys};
|
||
|
||
/// Commands that replace/close the active document or touch the filesystem are fine to call, but a
|
||
/// few need their paths redirected into a temp dir (handled by `junk_params`).
|
||
fn safe_path() -> String {
|
||
vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
|
||
}
|
||
|
||
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
|
||
/// `EngineError::Internal`.
|
||
fn no_panic(r: Result<Value, vectorcraft_engine::EngineError>) -> Result<Value, vectorcraft_engine::EngineError> {
|
||
if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r {
|
||
panic!("{cmd} panicked: {msg}");
|
||
}
|
||
r
|
||
}
|
||
|
||
/// Run one call; returns a failure description or None.
|
||
fn probe(fx: Fixture, id: &str, params: &Value) -> Option<String> {
|
||
let mut s = fx.session();
|
||
let r = catch_quiet(|| no_panic(s.execute(id, params)));
|
||
match r {
|
||
Err(msg) => Some(format!("PANIC {id} {params} [{fx:?}]: {msg}")),
|
||
Ok(_) => {
|
||
if s.in_interaction() {
|
||
return Some(format!("{id} {params} [{fx:?}]: left an interaction open"));
|
||
}
|
||
check_session(&s).err().map(|e| format!("{id} {params} [{fx:?}]: {e}"))
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn registry_is_well_formed() {
|
||
let specs = command_specs();
|
||
assert!(specs.len() > 150, "only {} commands", specs.len());
|
||
let mut ids = std::collections::HashSet::new();
|
||
for c in specs {
|
||
assert!(ids.insert(c.id), "duplicate command id {}", c.id);
|
||
assert!(!c.label.is_empty(), "{} has no label", c.id);
|
||
assert!(c.params.starts_with('{') || c.params.starts_with("same as"), "{}: params doc `{}`", c.id, c.params);
|
||
assert!(c.id.contains('.'), "{}: ids are namespaced", c.id);
|
||
}
|
||
// Every command reports enablement without a document.
|
||
let s = Session::new();
|
||
for info in s.commands() {
|
||
assert_eq!(info.enabled, info.disabled_reason.is_none(), "{}", info.id);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_command_with_empty_params_in_every_fixture() {
|
||
let mut failures = vec![];
|
||
for fx in Fixture::ALL {
|
||
for c in command_specs() {
|
||
if let Some(f) = probe(fx, c.id, &json!({})) {
|
||
failures.push(f);
|
||
}
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn every_command_without_a_document() {
|
||
let mut failures = vec![];
|
||
for c in command_specs() {
|
||
let mut s = Session::new();
|
||
match catch_quiet(|| no_panic(s.execute(c.id, &json!({})))) {
|
||
Err(p) => failures.push(format!("PANIC {} with no document: {p}", c.id)),
|
||
// Anything that needs a document must say so via enablement, not fail later.
|
||
Ok(Err(vectorcraft_engine::EngineError::NoDocument)) => failures.push(format!("{}: enabled without a document but needs one", c.id)),
|
||
Ok(_) => {}
|
||
}
|
||
if s.in_interaction() {
|
||
failures.push(format!("{}: interaction left open", c.id));
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||
}
|
||
|
||
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
|
||
/// order (#681), with huge and negative spacing too.
|
||
#[test]
|
||
fn artboard_rearrange_huge_columns() {
|
||
for fx in Fixture::ALL {
|
||
for layout in ["gridByRow", "gridByColumn", "row", "column"] {
|
||
for order in ["leftToRight", "rightToLeft"] {
|
||
for (cols, spacing) in [(json!(u64::MAX), json!(1e308)), (json!(1e308), json!(-1e308)), (json!(0), json!(0)), (json!(-5), json!(20))]
|
||
{
|
||
let p = json!({ "layout": layout, "order": order, "columns": cols, "spacing": spacing });
|
||
assert_eq!(probe(fx, "artboard.rearrange", &p), None);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bug_huge_transform_makes_document_unloadable() {
|
||
for (id, p) in [
|
||
("object.scale", json!({"sx": 1e308})),
|
||
("object.transformEach", json!({"scaleH": 1e308})),
|
||
("object.distributeSpacing", json!({"spacing": 1e308})),
|
||
] {
|
||
let mut s = Fixture::Multi.session();
|
||
if no_panic(s.execute(id, &p)).is_ok() {
|
||
vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bug_artboard_huge_props_break_svg() {
|
||
for key in ["x", "y", "width", "height"] {
|
||
let mut s = Fixture::Single.session();
|
||
let p = json!({"index": 0, key: 1e308});
|
||
if no_panic(s.execute("artboard.setProps", &p)).is_ok() {
|
||
vectorcraft_testkit::invariants::check_svg_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{p}: {}", &e[..e.len().min(200)]));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Commands too slow to fuzz exhaustively get a reduced junk set (they still run).
|
||
fn heavy(id: &str) -> bool {
|
||
id.starts_with("document.serialize") || id.starts_with("document.export") || id == "document.open"
|
||
}
|
||
|
||
#[test]
|
||
fn every_command_with_junk_params() {
|
||
let path = safe_path();
|
||
let mut failures = vec![];
|
||
let mut calls = 0usize;
|
||
for fx in Fixture::ALL {
|
||
for c in command_specs() {
|
||
let mut cases = junk_params(c.params, &path);
|
||
if heavy(c.id) {
|
||
cases.truncate(12);
|
||
}
|
||
for p in &cases {
|
||
calls += 1;
|
||
if let Some(f) = probe(fx, c.id, p) {
|
||
failures.push(f);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(calls > 5_000, "only {calls} calls");
|
||
failures.sort();
|
||
failures.dedup();
|
||
assert!(
|
||
failures.is_empty(),
|
||
"{} failures (of {calls} calls):\n{}",
|
||
failures.len(),
|
||
failures.iter().take(60).cloned().collect::<Vec<_>>().join("\n")
|
||
);
|
||
}
|
||
|
||
/// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops.
|
||
#[test]
|
||
fn structured_junk() {
|
||
let cases: Vec<(&str, Value)> = vec![
|
||
("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})),
|
||
("select.add", json!({"ids": [[1], {"a": 1}]})),
|
||
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
|
||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
|
||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})),
|
||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 0]]}], "mode": "subtract"})),
|
||
("select.anchorsMany", json!({"items": [], "mode": 7})),
|
||
("select.toggle", json!({"ids": [2, u64::MAX, "x"]})),
|
||
("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})),
|
||
("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})),
|
||
("object.transform", json!({"matrix": [1, 2, 3]})),
|
||
("object.scale", json!({"sx": 0, "sy": 0})),
|
||
("object.scale", json!({"sx": -100, "sy": 1e308})),
|
||
("object.rotate", json!({"angle": 1e308, "origin": [1e308, -1e308]})),
|
||
("object.distort", json!({"corners": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
|
||
("object.distort", json!({"corners": [[0, 0]], "from": [0, 0, 0, 0]})),
|
||
("path.create", json!({"anchors": []})),
|
||
("path.create", json!({"anchors": [{"x": 1e308, "y": -1e308}, {"x": 0, "y": 0, "in": [1e308, 1e308]}], "closed": true})),
|
||
("path.create", json!({"d": "M 0 0 L"})),
|
||
("path.create", json!({"d": "M0 0 C 1e308 1e308 -1e308 -1e308 0 0 Z"})),
|
||
("path.create", json!({"d": "Z Z Z m 1 1 z"})),
|
||
("path.setAnchors", json!({"id": 2, "subpaths": [{"anchors": [], "closed": true}]})),
|
||
("path.setAnchors", json!({"id": 1, "subpaths": [{"anchors": [{"x": 0, "y": 0}], "closed": true}]})),
|
||
("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})),
|
||
("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})),
|
||
("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})),
|
||
("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})),
|
||
("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})),
|
||
("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})),
|
||
("path.freehand", json!({"points": [[0, 0]]})),
|
||
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
|
||
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
|
||
("path.freehand", json!({"points": [[0, 0, -5], [10, 0, 1e308], [20, 0, "x"], [30, 0, null], [40, 0, 0.5]], "style": "brush"})),
|
||
("path.freehand", json!({"points": [[0, 0, 0.2], [0, 0, 0.9], [0, 0, 0.1]]})),
|
||
(
|
||
"path.freehand",
|
||
json!({"points": [{"x": 0, "y": 0, "pressure": 2}, {"x": 1e308, "y": 0, "pressure": -1}], "extend": {"id": 2, "end": "start"}}),
|
||
),
|
||
("brush.freehand", json!({"points": [[0, 0, 0], [50, 50, 1]], "brush": "3 pt. Round"})),
|
||
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "random", "nope"]}})),
|
||
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": "pressure", "variation": [1e308, -1e308, 1e308]}})),
|
||
(
|
||
"brush.options",
|
||
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
|
||
),
|
||
(
|
||
"brush.options",
|
||
json!({"name": "Confetti", "params": {"size": [1e308, -1e308], "spacing": [0, 1e308], "scatter": [-1e308, 1e308], "rotation": [1e308, -1e308], "modes": ["pressure", "random", "fixed", "pressure"]}}),
|
||
),
|
||
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed", "tilt", "fixed", "fixed"], "size": "x"}})),
|
||
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed"], "colorization": {"method": "sparkle"}}})),
|
||
(
|
||
"brush.options",
|
||
json!({"name": "Dots", "params": {"colorization": {"method": "hueShift", "key": {"model": "rgb", "r": 1e308, "g": -1e308, "b": 0}}}}),
|
||
),
|
||
("brush.options", json!({"name": "Arrow", "params": {"width": 1e308, "scale": {"mode": "betweenGuides", "start": 1e308, "end": -1e308}}})),
|
||
("brush.options", json!({"name": "Arrow", "params": {"width": -5, "direction": "diagonal", "flip_along": "yes"}})),
|
||
("brush.options", json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308, "fit": "approximate", "flip_across": true}})),
|
||
("brush.options", json!({"name": "Chain", "params": {"scale": 0, "spacing": 1e308, "fit": "nope", "side": null}})),
|
||
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
|
||
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 1000, "x": 0, "y": 0})),
|
||
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 0, "x": 1e308, "y": -1e308})),
|
||
("path.curvatureEdit", json!({"id": 2, "subpath": 7, "op": "extend", "x": 1e308, "y": -1e308, "from": "smooth"})),
|
||
("path.curvatureEdit", json!({"id": 2, "op": "insert", "segment": 99, "t": -5, "x": 1})),
|
||
("path.curvatureEdit", json!({"id": 2, "op": "close", "end": "start", "from": "corner"})),
|
||
("path.knife", json!({"points": [[0, 0], [1e9, 1e9]]})),
|
||
("path.eraseRegion", json!({"points": [[100, 80]], "size": 0})),
|
||
("path.eraseRegion", json!({"points": [[100, 80], [101, 80]], "size": 1e9})),
|
||
("path.blob", json!({"points": [[0, 0], [0, 0]], "size": -5})),
|
||
("path.smoothRegion", json!({"points": [[100, 80], [120, 90]], "radius": 1e308})),
|
||
("paint.setFill", json!({"gradient": {"stops": []}})),
|
||
("paint.setFill", json!({"gradient": {"stops": [{"offset": 0.5, "color": "#ff0000"}]}})),
|
||
(
|
||
"paint.setFill",
|
||
json!({"gradient": {"kind": "radial", "stops": [{"offset": -5, "color": [1, 0, 0]}, {"offset": 99, "color": {"gray": 50}}], "angle": 1e308}}),
|
||
),
|
||
("paint.setFill", json!({"color": [1e308, -1e308, 2]})),
|
||
("paint.setFill", json!({"color": {"c": 200, "m": -1, "y": 0, "k": 1e9}})),
|
||
("paint.setGradientGeom", json!({"start": [0, 0], "end": [0, 0]})),
|
||
("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})),
|
||
("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})),
|
||
("stroke.set", json!({"dash": [1e-12]})),
|
||
("transparency.set", json!({"opacity": -50})),
|
||
("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})),
|
||
("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})),
|
||
("perspective.move", json!({"from": [f64::MAX, 0], "to": [0, 0], "plane": "left"})),
|
||
("perspective.transform", json!({"matrix": [1e308, 0, 0, 1e308, 0, 0], "plane": "right"})),
|
||
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -1e9, "plane": "left", "copy": true})),
|
||
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 1e300, 0], "plane": "left"})),
|
||
("perspective.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})),
|
||
("perspective.plane.move", json!({"plane": "ground", "offset": 1e308, "objects": "copy"})),
|
||
("perspective.plane.move", json!({"plane": "left", "by": -1e7, "objects": "move"})),
|
||
("perspective.plane.move", json!({"plane": "none", "offset": 0})),
|
||
("perspective.plane.matchObject", json!({"id": u64::MAX})),
|
||
("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})),
|
||
("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})),
|
||
("perspective.editText", json!({"id": u64::MAX})),
|
||
(
|
||
"perspective.draw",
|
||
json!({"command": "shape.rectangle", "params": {"x": 1, "y": 1, "width": 10, "height": 10}, "at": [1e308, -1e308], "plane": "left"}),
|
||
),
|
||
("perspective.draw", json!({"command": "shape.flare", "params": {"cx": 0, "cy": 0}, "plane": "ground"})),
|
||
("object.setProps", json!({"opacity": -1, "blend": 5})),
|
||
("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})),
|
||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||
("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})),
|
||
("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})),
|
||
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
|
||
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
|
||
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
|
||
(
|
||
"effect.apply",
|
||
json!({"effect": "brushStrokes.accentedEdges", "params": {"edgeWidth": 1e308, "edgeBrightness": "NaN", "smoothness": -1e308}}),
|
||
),
|
||
(
|
||
"effect.apply",
|
||
json!({"effect": "brushStrokes.angledStrokes", "params": {"directionBalance": [1], "strokeLength": 1e308, "sharpness": null}}),
|
||
),
|
||
("effect.apply", json!({"effect": "brushStrokes.crosshatch", "params": {"strokeLength": "1e999", "sharpness": 1e308, "strength": -1e308}})),
|
||
("effect.apply", json!({"effect": "brushStrokes.darkStrokes", "params": {"balance": -1e308, "blackIntensity": {}, "whiteIntensity": 1e308}})),
|
||
(
|
||
"effect.apply",
|
||
json!({"effect": "brushStrokes.inkOutlines", "params": {"strokeLength": -1, "darkIntensity": 1e308, "lightIntensity": "x"}}),
|
||
),
|
||
("effect.apply", json!({"effect": "brushStrokes.spatter", "params": {"sprayRadius": 1e308, "smoothness": "NaN"}})),
|
||
(
|
||
"effect.apply",
|
||
json!({"effect": "brushStrokes.sprayedStrokes", "params": {"strokeLength": 1e308, "sprayRadius": -1e308, "strokeDirection": 3}}),
|
||
),
|
||
("effect.apply", json!({"effect": "brushStrokes.sumiE", "params": {"strokeWidth": 0, "strokePressure": 1e308, "contrast": [1]}})),
|
||
("effect.apply", json!({"effect": "pixelate.colorHalftone", "params": {"maxRadius": 1e308, "channel1": "1e999", "channel4": null}})),
|
||
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
|
||
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
|
||
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
|
||
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
|
||
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
|
||
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
|
||
("effect.apply", json!({"effect": "texture.patchwork", "params": {"squareSize": 1e308, "relief": -1e308}})),
|
||
("effect.apply", json!({"effect": "texture.stainedGlass", "params": {"cellSize": 0, "borderThickness": 1e308, "lightIntensity": "x"}})),
|
||
(
|
||
"effect.apply",
|
||
json!({"effect": "texture.texturizer", "params": {"texture": {}, "scaling": -1e308, "relief": 1e308, "lightDirection": 3, "invert": "yes"}}),
|
||
),
|
||
("effect.apply", json!({"effect": "video.deinterlace", "params": {"eliminate": 7, "create": ["x"]}})),
|
||
("effect.apply", json!({"effect": "video.ntscColors", "params": {"junk": 1e308}})),
|
||
("effect.apply", json!({"effect": "distort.diffuseGlow", "params": {"graininess": "x", "glowAmount": 1e308, "clearAmount": -1e308}})),
|
||
("effect.apply", json!({"effect": "distort.glass", "params": {"distortion": 1e308, "smoothness": "NaN", "texture": 3, "scaling": -1}})),
|
||
("effect.apply", json!({"effect": "distort.oceanRipple", "params": {"rippleSize": [1], "rippleMagnitude": 1e308}})),
|
||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||
("effect.remove", json!({"index": 99})),
|
||
("effect.setParams", json!({"index": 0, "params": null})),
|
||
("object.path.offsetPath", json!({"offset": 1e308})),
|
||
("object.path.offsetPath", json!({"offset": -1e308})),
|
||
("object.path.simplify", json!({"tolerance": -1, "cornerAngle": 1e308})),
|
||
(
|
||
"shape.rectangularGrid",
|
||
json!({"x": 0, "y": 0, "width": 1e308, "height": -1e308, "rows": 999, "columns": u64::MAX, "rowSkew": 1e308, "columnSkew": -1e308, "frame": false, "fill": true}),
|
||
),
|
||
("shape.rectangularGrid", json!({"x": 0, "y": 0, "width": 0, "height": 0, "rowSkew": "x", "frame": 3})),
|
||
(
|
||
"shape.polarGrid",
|
||
json!({"x": 0, "y": 0, "width": 1e308, "height": 1e-308, "concentric": 999, "radial": 999, "concentricSkew": -1e308, "radialSkew": 1e308, "compound": true, "fill": true}),
|
||
),
|
||
(
|
||
"shape.polarGrid",
|
||
json!({"x": 0, "y": 0, "width": 10, "height": 10, "concentric": -1, "radial": 0.5, "radialSkew": null, "compound": "yes"}),
|
||
),
|
||
("object.path.splitIntoGrid", json!({"rows": 500, "columns": 1, "gutter": 1e308})),
|
||
("object.path.splitIntoGrid", json!({"rows": 0.4})),
|
||
("object.setBounds", json!({"width": 0, "height": 0})),
|
||
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
|
||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
|
||
("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})),
|
||
("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})),
|
||
("object.distribute", json!({"vertical": "center", "bounds": "geometric"})),
|
||
("object.distribute", json!({"horizontal": "right", "to": "artboard", "artboard": u64::MAX, "bounds": "preview"})),
|
||
("object.distributeSpacing", json!({"axis": "vertical", "to": "artboard", "artboard": -1, "spacing": null})),
|
||
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
|
||
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
|
||
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
|
||
("artboard.new", json!({"width": 0, "height": -1})),
|
||
("artboard.delete", json!({"index": 0})),
|
||
("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})),
|
||
("node.move", json!({"id": 1, "parent": 2, "index": 0})),
|
||
("node.move", json!({"id": 2, "parent": 2, "index": 0})),
|
||
("node.move", json!({"id": 1, "parent": null, "index": 1000})),
|
||
("layer.setProps", json!({"id": 1, "color": 1000})),
|
||
("layer.newSublayer", json!({"parent": 2})),
|
||
("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})),
|
||
("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})),
|
||
("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})),
|
||
("text.setStyle", json!({"justify": "center", "start": u64::MAX, "end": 0})),
|
||
("text.setStyle", json!({"justify": "right", "start": -1, "end": "x"})),
|
||
("text.setFormat", json!({"spaceBefore": 1e308, "leftIndent": -1e308, "start": 3, "end": u64::MAX})),
|
||
("text.setFormat", json!({"hyphenate": true, "underline": true, "start": 1.5, "end": null})),
|
||
("text.setFormat", json!({"ids": [u64::MAX, 2], "firstLineIndent": 5, "start": 0, "end": 0})),
|
||
("text.tabs.set", json!({"stops": [{"position": 10}], "start": u64::MAX, "end": u64::MAX})),
|
||
("text.tabs.get", json!({"start": -5})),
|
||
("paraStyle.apply", json!({"name": "[Normal Paragraph Style]", "id": 2, "start": u64::MAX, "end": 1})),
|
||
("paraStyle.new", json!({"id": 2, "start": u64::MAX})),
|
||
("document.setUnits", json!({"units": "Parsecs"})),
|
||
("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})),
|
||
("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})),
|
||
("document.open", json!({"name": "x.pdf", "dataBase64": "JVBERi0xLjQK"})),
|
||
("document.open", json!({"name": "x.vectorcraft", "dataBase64": "e30="})),
|
||
("document.activate", json!({"index": 99})),
|
||
("file.close", json!({"index": 99})),
|
||
("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})),
|
||
("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})),
|
||
("command.batch", json!({"commands": "nope"})),
|
||
("object.puppetWarp", json!({"pins": [[0, 0]], "moved": [[0, 0]], "angles": [1e308]})),
|
||
("object.puppetWarp", json!({"pins": [[0, 0], [5, 5]], "moved": [[0, 0], [1e9, 1e9]], "angles": [-1e308, "x"]})),
|
||
("object.liquify", json!({"tool": "pucker", "points": [[0, 0, 1e308], [1, 1, -5], [2, 2, "x"]], "usePressure": true})),
|
||
("object.liquify", json!({"tool": "twirl", "points": vec![json!([50, 50, 0.5]); 400], "usePressure": "yes", "simplifyOn": 3})),
|
||
("object.liquify", json!({"tool": "wrinkle", "points": [[0, 0], [1e4, 1e4]], "width": 1e308, "complexity": -1, "affectIn": null})),
|
||
("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})),
|
||
("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})),
|
||
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
|
||
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
|
||
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
|
||
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
|
||
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
|
||
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
|
||
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
|
||
("object.puppetWarp", json!({"rest": true, "ids": [u64::MAX], "pins": [[0, 0]], "moved": [[0, 0]]})),
|
||
("object.puppetWarp.pins", json!({"ids": [0, 1, 2], "expand": 1e308})),
|
||
];
|
||
let mut failures = vec![];
|
||
for fx in Fixture::ALL {
|
||
for (id, p) in &cases {
|
||
if let Some(f) = probe(fx, id, p) {
|
||
failures.push(f);
|
||
}
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||
}
|
||
|
||
/// Area Type Options with junk fit values, on selected area type (the fixtures have none), with
|
||
/// text that overflows: never a panic, the document stays sound, and editing still works.
|
||
#[test]
|
||
fn area_options_fit_junk() {
|
||
let cases = [
|
||
json!({"fit": "shrinkText", "fitMinPercent": 1e308}),
|
||
json!({"fit": "shrinkText", "fitMinPercent": -1e308}),
|
||
json!({"fit": "shrinkText", "fitMinPercent": "x"}),
|
||
json!({"fit": {"shrinkText": {"minPercent": -5}}}),
|
||
json!({"fit": {"shrinkText": {"minPercent": "a"}}}),
|
||
json!({"fit": {"shrinkText": null}}),
|
||
json!({"fit": {"autoHeight": 1}}),
|
||
json!({"fit": {"bogus": {}}}),
|
||
json!({"fit": [1, 2]}),
|
||
json!({"fit": ""}),
|
||
json!({"fit": "AUTO-HEIGHT", "height": 1e308, "width": -1e308}),
|
||
json!({"fit": "autoHeight", "columns": u64::MAX, "gutter": 1e308, "inset": 1e308}),
|
||
json!({"fit": "autoHeight", "rows": 5, "inset": -1}),
|
||
json!({"fit": "shrinkText", "columns": 100, "gutter": 0, "inset": 10000}),
|
||
json!({"fitMinPercent": 50}),
|
||
json!({"fit": "none", "fitMinPercent": 1e-308}),
|
||
];
|
||
let story = "Words that overflow a small frame. ".repeat(40);
|
||
let mut failures = vec![];
|
||
for (size, (w, h)) in [(12.0, (120.0, 40.0)), (0.1, (1.0, 1.0)), (1296.0, (100_000.0, 1.0))] {
|
||
for p in &cases {
|
||
let mut s = Fixture::Multi.session();
|
||
let made = no_panic(s.execute("text.create", &json!({"x": 10, "y": 10, "size": size, "text": story, "area": {"width": w, "height": h}})));
|
||
let Ok(made) = made else { continue };
|
||
let id = made["id"].as_u64().unwrap_or(0);
|
||
let r = catch_quiet(|| no_panic(s.execute("text.areaOptions", p)));
|
||
match r {
|
||
Err(m) => failures.push(format!("PANIC text.areaOptions {p} [{size} pt, {w}×{h}]: {m}")),
|
||
Ok(_) => {
|
||
let typed = catch_quiet(|| no_panic(s.execute("text.editRange", &json!({"id": id, "start": 0, "insert": "More words. "}))));
|
||
if typed.is_err() {
|
||
failures.push(format!("PANIC typing after text.areaOptions {p}"));
|
||
}
|
||
if let Err(e) = check_session(&s) {
|
||
failures.push(format!("text.areaOptions {p} [{size} pt]: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||
}
|
||
|
||
/// A live polygon's Polygon Properties with junk values (the fixtures have no polygon), drawn
|
||
/// plain, scaled unevenly and flattened to nothing: never a panic, and the document stays sound.
|
||
#[test]
|
||
fn polygon_properties_junk() {
|
||
let mut cases: Vec<Value> = vec![json!({"makeSidesEqual": true}), json!({"sides": u64::MAX, "sideLength": 1e-300, "makeSidesEqual": true})];
|
||
for key in ["polygonRadius", "sideLength", "polygonAngle"] {
|
||
for v in junk_values() {
|
||
cases.push(json!({ key: v }));
|
||
}
|
||
}
|
||
let mut failures = vec![];
|
||
for scale in [None, Some((300.0, 20.0)), Some((1e-300, 100.0))] {
|
||
for p in &cases {
|
||
let mut s = Fixture::Multi.session();
|
||
let Ok(_) = no_panic(s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 40, "sides": 7, "rotation": 10}))) else {
|
||
continue;
|
||
};
|
||
if let Some((sx, sy)) = scale {
|
||
let _ = no_panic(s.execute("object.scale", &json!({"sx": sx, "sy": sy})));
|
||
}
|
||
match catch_quiet(|| no_panic(s.execute("object.setLiveShape", p))) {
|
||
Err(m) => failures.push(format!("PANIC object.setLiveShape {p} [{scale:?}]: {m}")),
|
||
Ok(_) => {
|
||
if let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
|
||
failures.push(format!("object.setLiveShape {p} [{scale:?}]: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||
}
|
||
|
||
/// After a successful fuzzed call, the document still round-trips and exports.
|
||
#[test]
|
||
fn fuzzed_calls_keep_documents_serializable() {
|
||
let path = safe_path();
|
||
let mut failures = vec![];
|
||
for c in command_specs() {
|
||
if heavy(c.id) || c.id.starts_with("file.") {
|
||
continue;
|
||
}
|
||
for v in junk_values().iter().take(8) {
|
||
for k in param_keys(c.params) {
|
||
let k = if k == "path" { continue } else { k };
|
||
let mut s = Fixture::Multi.session();
|
||
let p = json!({ k.clone(): v });
|
||
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
|
||
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
|
||
failures.push(format!("{} {p}: {e}", c.id));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let _ = path;
|
||
failures.sort();
|
||
failures.dedup();
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
|
||
}
|
||
|
||
/// Undo after any successful command in the Multi fixture restores the exact document.
|
||
#[test]
|
||
fn every_command_undoes_exactly() {
|
||
let mut failures = vec![];
|
||
for c in command_specs() {
|
||
if matches!(c.id, "edit.undo" | "edit.redo") || c.id.starts_with("file.") || c.id.starts_with("document.") {
|
||
continue;
|
||
}
|
||
let mut s = Fixture::Multi.session();
|
||
let before = doc_json(&s.doc().unwrap().doc);
|
||
let depth = s.doc().unwrap().history.undo.len();
|
||
let Ok(Ok(_)) = catch_quiet(|| s.execute(c.id, &json!({}))) else { continue };
|
||
if s.doc().unwrap().history.undo.len() > depth {
|
||
let _ = no_panic(s.execute("edit.undo", &json!({})));
|
||
let after = doc_json(&s.doc().unwrap().doc);
|
||
if after != before {
|
||
failures.push(format!("{}: {}", c.id, vectorcraft_testkit::invariants::first_diff(&before, &after, "$")));
|
||
}
|
||
} else if doc_json(&s.doc().unwrap().doc) != before && !c.params.contains("View state: not an undo step") {
|
||
failures.push(format!("{}: changed the document without an undo step", c.id));
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||
}
|
||
|
||
/// Data Recovery reads whatever its store holds: damaged copies and details fail cleanly, and junk
|
||
/// params never panic.
|
||
#[test]
|
||
fn recovery_commands_with_a_damaged_store_and_junk_params() {
|
||
use std::sync::Arc;
|
||
use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore};
|
||
let store = Arc::new(MemoryStore::default());
|
||
for (name, bytes) in [
|
||
("1-1/bad-1.vectorcraft", &b"{not json"[..]),
|
||
("1-1/bad-1.json", b"[1, 2"),
|
||
("1-1/gz-1.vectorcraft", &[0x1f, 0x8b, 0, 1, 2][..]),
|
||
("1-1/meta-only.json", br#"{"title": 5, "path": [], "saved": "x"}"#),
|
||
("2-1/heartbeat", b"not a number"),
|
||
("2-1/x.vectorcraft", b"{}"),
|
||
("../escape.vectorcraft", b"{}"),
|
||
("no-area.vectorcraft", b"{}"),
|
||
("/.vectorcraft", b"{}"),
|
||
] {
|
||
store.write(name, bytes).unwrap();
|
||
}
|
||
let path = safe_path();
|
||
for fx in Fixture::ALL {
|
||
for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] {
|
||
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
|
||
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
|
||
let mut s = fx.session();
|
||
s.recovery.set_store(store.clone());
|
||
let r = catch_quiet(|| no_panic(s.execute(id, &p)));
|
||
assert!(r.is_ok(), "PANIC {id} {p} [{fx:?}]");
|
||
check_session(&s).unwrap_or_else(|e| panic!("{id} {p} [{fx:?}]: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Envelope Distort's commands on warp, mesh and top-object envelopes (selected, and with their
|
||
/// content selected in Edit Contents) with `{}` and junk params: never a panic, an open
|
||
/// interaction or a broken tree.
|
||
#[test]
|
||
fn envelope_commands_with_junk_params_on_envelopes() {
|
||
let path = safe_path();
|
||
let makes: [(&str, Value); 3] = [
|
||
("object.envelope.makeWithWarp", json!({"style": "twist", "bend": 80})),
|
||
("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})),
|
||
("object.envelope.makeWithTopObject", json!({})),
|
||
];
|
||
let mut failures = vec![];
|
||
for (make, mp) in &makes {
|
||
for editing in [false, true] {
|
||
let ids: Vec<&str> =
|
||
command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect();
|
||
for id in ids {
|
||
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
|
||
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
|
||
let mut s = Fixture::Multi.session();
|
||
if s.execute(make, mp).is_err() {
|
||
failures.push(format!("{make} failed on the Multi fixture"));
|
||
continue;
|
||
}
|
||
if editing {
|
||
let _ = no_panic(s.execute("object.envelope.editContents", &json!({"editing": true})));
|
||
}
|
||
let r = catch_quiet(|| no_panic(s.execute(id, &p)));
|
||
if r.is_err() {
|
||
failures.push(format!("PANIC {id} {p} [{make}, editing {editing}]"));
|
||
} else if s.in_interaction() {
|
||
failures.push(format!("{id} {p} [{make}]: left an interaction open"));
|
||
} else if let Err(e) = check_session(&s) {
|
||
failures.push(format!("{id} {p} [{make}]: {e}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
failures.sort();
|
||
failures.dedup();
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
|
||
}
|
||
|
||
/// Inline graphics in text (a symbol set in a run of type): text commands with `{}`, junk and
|
||
/// structured junk (bad symbol names, offsets inside or past the graphic's character, malformed
|
||
/// inline runs) never panic, leave an interaction open or break the tree, and the document still
|
||
/// round-trips and exports.
|
||
#[test]
|
||
fn text_commands_with_junk_params_on_inline_graphics() {
|
||
use vectorcraft_testkit::fixtures::{ellipse, exec, id_of, select};
|
||
let path = safe_path();
|
||
let setup = || {
|
||
let mut s = Fixture::Multi.session();
|
||
let e = ellipse(&mut s, 400.0, 300.0, 20.0, 20.0);
|
||
select(&mut s, &[e]);
|
||
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
|
||
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "ab cd", "size": 20})));
|
||
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 2, "symbol": "Dot", "scale": 2}));
|
||
select(&mut s, &[t]);
|
||
(s, t)
|
||
};
|
||
let (_, t) = setup();
|
||
let t = t.0;
|
||
let style = json!({"font_family": "Source Sans 3", "size": 12, "fill": {"type": "none"}});
|
||
let structured = [
|
||
("text.insertInline", json!({"id": t, "at": u64::MAX, "symbol": "Dot", "scale": 100})),
|
||
("text.insertInline", json!({"id": t, "at": 3, "symbol": "Dot", "scale": 1e-300, "shift": -1e5})),
|
||
("text.insertInline", json!({"id": t, "at": 3, "symbol": "\u{0}", "scale": 1})),
|
||
("text.insertInline", json!({"id": t, "at": 3, "symbol": "", "scale": f64::MAX})),
|
||
("text.insertInline", json!({"id": u64::MAX, "symbol": "Dot"})),
|
||
("text.insertInline", json!({"id": t, "at": "3", "symbol": ["Dot"], "scale": "big", "shift": null})),
|
||
("text.editRange", json!({"id": t, "start": 3, "end": 4, "insert": "x"})),
|
||
("text.editRange", json!({"id": t, "start": 4, "end": 3})),
|
||
(
|
||
"text.editRange",
|
||
json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}\u{FFFC}x", "style": style, "inline": {"symbol": "Dot", "scale": 1e308, "baseline_shift": -1e308}}]}),
|
||
),
|
||
("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": "Missing"}}]})),
|
||
("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "", "style": style, "inline": {"symbol": "Dot", "scale": -5}}]})),
|
||
("text.editRange", json!({"id": t, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": 5}}]})),
|
||
("text.setRangeStyle", json!({"id": t, "start": 3, "end": 4, "size": 1e308, "tracking": -1e308})),
|
||
("text.getRange", json!({"id": t, "start": 3, "end": 4})),
|
||
("type.insert", json!({"text": "\u{FFFC}"})),
|
||
("type.changeCase", json!({"case": "upper"})),
|
||
("type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
|
||
("type.convertToAreaType", json!({})),
|
||
("type.createOutlines", json!({})),
|
||
("symbol.delete", json!({"name": "Dot"})),
|
||
("symbol.delete", json!({"name": "Dot", "expandInstances": false})),
|
||
("symbol.update", json!({"name": "Dot"})),
|
||
];
|
||
let mut failures = vec![];
|
||
let mut run = |id: &str, p: &Value| {
|
||
let (mut s, _) = setup();
|
||
match catch_quiet(|| no_panic(s.execute(id, p))) {
|
||
Err(msg) => failures.push(format!("PANIC {id} {p}: {msg}")),
|
||
Ok(r) => {
|
||
if s.in_interaction() {
|
||
failures.push(format!("{id} {p}: left an interaction open"));
|
||
} else if let Err(e) = check_session(&s) {
|
||
failures.push(format!("{id} {p}: {e}"));
|
||
} else if r.is_ok()
|
||
&& let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}")))
|
||
{
|
||
failures.push(format!("{id} {p}: {e}"));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
for (id, p) in &structured {
|
||
run(id, p);
|
||
}
|
||
for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] {
|
||
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
|
||
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
|
||
run(id, &p);
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||
}
|