mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
The sweep compares a curve with another segment over a y-span shorter than its tolerance by bounding boxes only, so an edge rising by less than that tolerance (e.g. a rectangle edge cut through a circle's cap, whose computed intersection end sits ~1e-12 off the horizontal) was mis-ordered and the result lost a corner: xor/difference/area disagreed by up to 10%. Every sweep input now snaps on-curve y values within the tolerance to one level (handles follow and are levelled with their anchor), so such edges become exact horizontals, which the sweep handles exactly. - tests/regressions.rs: the six minimal failures found by the properties, the root cause in isolation, and the self-crossing "star" polygon. - The star-polygon strategy rejected no angular step of half a turn or more, so it sometimes drew self-crossing polygons (the shoelace failure). - Property suites run from a fixed seed (PROPTEST_CASES / PROPTEST_RNG_SEED still override), so CI cannot fail at random. Co-Authored-By: Claude Opus 5.5 <[email protected]>
524 lines
22 KiB
Rust
524 lines
22 KiB
Rust
//! Proptest strategies: geometry, engine command sequences, junk parameters.
|
|
|
|
use proptest::prelude::*;
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_engine::{Session, command_specs};
|
|
use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath};
|
|
|
|
use crate::fixtures::{art_nodes, top_level_ids};
|
|
|
|
// ---------------------------------------------------------------- geometry
|
|
|
|
pub fn arb_point(lo: f64, hi: f64) -> impl Strategy<Value = Point> {
|
|
(lo..hi, lo..hi).prop_map(|(x, y)| Point::new(x, y))
|
|
}
|
|
|
|
/// A non-degenerate rectangle inside `[0, 200]²`.
|
|
pub fn arb_rect() -> impl Strategy<Value = Rect> {
|
|
(0.0..150.0f64, 0.0..150.0f64, 2.0..80.0f64, 2.0..80.0f64).prop_map(|(x, y, w, h)| Rect::new(x, y, x + w, y + h))
|
|
}
|
|
|
|
/// A simple (non self-intersecting) star-shaped polygon: points at increasing angles around a
|
|
/// centre with random radii. Every angular step stays below half a turn: a wider one puts the
|
|
/// centre outside and can make the polygon cross itself.
|
|
pub fn arb_star_polygon() -> impl Strategy<Value = Vec<Point>> {
|
|
let steps = prop::collection::vec((0.2f64..1.0, 5.0f64..40.0), 3..12).prop_filter("an angular step of half a turn or more", |v| {
|
|
let total: f64 = v.iter().map(|(a, _)| a).sum();
|
|
v.iter().all(|(a, _)| 2.0 * a < total)
|
|
});
|
|
(arb_point(40.0, 160.0), steps).prop_map(|(c, v)| {
|
|
let total: f64 = v.iter().map(|(a, _)| a).sum();
|
|
let mut ang = 0.0;
|
|
v.iter()
|
|
.map(|&(a, r)| {
|
|
ang += a / total * std::f64::consts::TAU;
|
|
Point::new(c.x + r * ang.cos(), c.y + r * ang.sin())
|
|
})
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
/// A convex polygon (regular polygon with jittered radius kept convex by using a single radius per
|
|
/// polygon and jittered angles only).
|
|
pub fn arb_convex_polygon() -> impl Strategy<Value = Vec<Point>> {
|
|
(arb_point(40.0, 160.0), 5.0f64..40.0, prop::collection::vec(0.3f64..1.0, 3..10)).prop_map(|(c, r, v)| {
|
|
let total: f64 = v.iter().sum();
|
|
let mut ang = 0.0;
|
|
v.iter()
|
|
.map(|a| {
|
|
ang += a / total * std::f64::consts::TAU;
|
|
Point::new(c.x + r * ang.cos(), c.y + r * ang.sin())
|
|
})
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
pub fn polygon_path(pts: &[Point]) -> PathData {
|
|
PathData::single(SubPath::polyline(pts, true))
|
|
}
|
|
|
|
/// An axis-aligned ellipse as a path.
|
|
pub fn arb_ellipse() -> impl Strategy<Value = PathData> {
|
|
arb_rect().prop_map(vectorcraft_geom::shapes::ellipse)
|
|
}
|
|
|
|
/// Rectangles, ellipses and star-shaped polygons.
|
|
pub fn arb_closed_shape() -> impl Strategy<Value = PathData> {
|
|
prop_oneof![arb_rect().prop_map(vectorcraft_geom::shapes::rectangle), arb_ellipse(), arb_star_polygon().prop_map(|p| polygon_path(&p)),]
|
|
}
|
|
|
|
/// A random cubic Bézier path (open or closed, possibly several subpaths, arbitrary handles).
|
|
pub fn arb_path_data() -> impl Strategy<Value = PathData> {
|
|
let anchor = (arb_point(0.0, 200.0), arb_point(-30.0, 30.0), arb_point(-30.0, 30.0), any::<bool>())
|
|
.prop_map(|(p, i, o, corner)| if corner { Anchor::corner(p) } else { Anchor::with_handles(p, p + i.to_vec2(), p + o.to_vec2()) });
|
|
let sub = (prop::collection::vec(anchor, 2..8), any::<bool>()).prop_map(|(a, closed)| SubPath::new(a, closed));
|
|
prop::collection::vec(sub, 1..3).prop_map(PathData::new)
|
|
}
|
|
|
|
/// A smooth open curve through points on a gently varying function (for simplify tests): densely
|
|
/// sampled polyline-like cubic chain with C1 handles.
|
|
pub fn arb_smooth_curve() -> impl Strategy<Value = PathData> {
|
|
(prop::collection::vec(-20.0f64..20.0, 4..9), 10.0f64..30.0).prop_map(|(ys, step)| {
|
|
// Catmull-Rom through (i*step, y_i), converted to cubic handles.
|
|
let pts: Vec<Point> = ys.iter().enumerate().map(|(i, y)| Point::new(i as f64 * step, 100.0 + y)).collect();
|
|
let n = pts.len();
|
|
let tangent = |i: usize| {
|
|
let a = pts[i.saturating_sub(1)];
|
|
let b = pts[(i + 1).min(n - 1)];
|
|
(b - a) / 6.0
|
|
};
|
|
let anchors = (0..n).map(|i| Anchor::with_handles(pts[i], pts[i] - tangent(i), pts[i] + tangent(i))).collect();
|
|
PathData::single(SubPath::new(anchors, false))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------- engine ops
|
|
|
|
/// One abstract editing step. Object references are indices resolved against the live document
|
|
/// when the op is applied (so generated sequences stay meaningful as ids change).
|
|
#[derive(Clone, Debug)]
|
|
pub enum Op {
|
|
Rect(f64, f64, f64, f64),
|
|
Ellipse(f64, f64, f64, f64),
|
|
Polygon(f64, f64, f64, u32),
|
|
Star(f64, f64, f64, f64),
|
|
Line(f64, f64, f64, f64),
|
|
Path(Vec<(f64, f64)>, bool),
|
|
Text(f64, f64),
|
|
SelectSet(Vec<usize>),
|
|
SelectAll,
|
|
SelectNone,
|
|
SelectInverse,
|
|
Move(f64, f64, bool),
|
|
Rotate(f64, bool),
|
|
Scale(f64, f64),
|
|
Reflect(bool),
|
|
Shear(f64),
|
|
TransformAgain,
|
|
Nudge(i8, i8),
|
|
Group,
|
|
Ungroup,
|
|
Arrange(u8),
|
|
Pathfinder(u8),
|
|
Fill(u8),
|
|
Gradient(f64),
|
|
Stroke(u8, f64, bool),
|
|
Transparency(f64, u8),
|
|
Align(u8),
|
|
Distribute,
|
|
LayerNew,
|
|
LayerDelete,
|
|
LayerCurrent(usize),
|
|
Collect,
|
|
Copy,
|
|
Cut,
|
|
Paste(u8),
|
|
Duplicate,
|
|
Clear,
|
|
Undo,
|
|
Redo,
|
|
Clip,
|
|
ClipRelease,
|
|
Compound,
|
|
CompoundRelease,
|
|
Lock,
|
|
UnlockAll,
|
|
Hide,
|
|
ShowAll,
|
|
Offset(f64),
|
|
Simplify,
|
|
OutlineStroke,
|
|
ExpandShape,
|
|
Effect(u8),
|
|
ExpandAppearance,
|
|
Batch(Vec<Op>),
|
|
}
|
|
|
|
pub const PATHFINDER: [&str; 10] = [
|
|
"object.pathfinder.unite",
|
|
"object.pathfinder.minusFront",
|
|
"object.pathfinder.intersect",
|
|
"object.pathfinder.exclude",
|
|
"object.pathfinder.divide",
|
|
"object.pathfinder.trim",
|
|
"object.pathfinder.merge",
|
|
"object.pathfinder.crop",
|
|
"object.pathfinder.outline",
|
|
"object.pathfinder.minusBack",
|
|
];
|
|
const ARRANGE: [&str; 5] = [
|
|
"object.arrange.bringToFront",
|
|
"object.arrange.bringForward",
|
|
"object.arrange.sendBackward",
|
|
"object.arrange.sendToBack",
|
|
"object.arrange.sendToCurrentLayer",
|
|
];
|
|
const COLORS: [&str; 6] = ["#ff0000", "#00ff00", "#0000ff", "#222222", "#ffffff", "#ffcc00"];
|
|
const BLENDS: [&str; 4] = ["Normal", "Multiply", "Screen", "Difference"];
|
|
const PASTE: [&str; 4] = ["edit.paste", "edit.pasteInFront", "edit.pasteInBack", "edit.pasteInPlace"];
|
|
|
|
fn coord() -> impl Strategy<Value = f64> {
|
|
-20.0..380.0f64
|
|
}
|
|
fn size() -> impl Strategy<Value = f64> {
|
|
1.0..150.0f64
|
|
}
|
|
|
|
fn arb_leaf_op() -> impl Strategy<Value = Op> {
|
|
prop_oneof![
|
|
// creation (weighted up so documents have content)
|
|
4 => (coord(), coord(), size(), size()).prop_map(|(a, b, c, d)| Op::Rect(a, b, c, d)),
|
|
3 => (coord(), coord(), size(), size()).prop_map(|(a, b, c, d)| Op::Ellipse(a, b, c, d)),
|
|
1 => (coord(), coord(), 5.0..80.0f64, 3u32..9).prop_map(|(a, b, c, d)| Op::Polygon(a, b, c, d)),
|
|
1 => (coord(), coord(), 10.0..80.0f64, 3.0..40.0f64).prop_map(|(a, b, c, d)| Op::Star(a, b, c, d)),
|
|
1 => (coord(), coord(), coord(), coord()).prop_map(|(a, b, c, d)| Op::Line(a, b, c, d)),
|
|
1 => (prop::collection::vec((coord(), coord()), 2..6), any::<bool>()).prop_map(|(v, c)| Op::Path(v, c)),
|
|
1 => (coord(), coord()).prop_map(|(a, b)| Op::Text(a, b)),
|
|
// selection
|
|
5 => prop::collection::vec(0usize..64, 1..4).prop_map(Op::SelectSet),
|
|
1 => Just(Op::SelectAll),
|
|
1 => Just(Op::SelectNone),
|
|
1 => Just(Op::SelectInverse),
|
|
// transforms
|
|
2 => (-50.0..50.0f64, -50.0..50.0f64, any::<bool>()).prop_map(|(a, b, c)| Op::Move(a, b, c)),
|
|
1 => (-180.0..180.0f64, any::<bool>()).prop_map(|(a, c)| Op::Rotate(a, c)),
|
|
1 => (20.0..300.0f64, 20.0..300.0f64).prop_map(|(a, b)| Op::Scale(a, b)),
|
|
1 => any::<bool>().prop_map(Op::Reflect),
|
|
1 => (-45.0..45.0f64).prop_map(Op::Shear),
|
|
1 => Just(Op::TransformAgain),
|
|
1 => (-1i8..=1, -1i8..=1).prop_map(|(a, b)| Op::Nudge(a, b)),
|
|
// structure
|
|
2 => Just(Op::Group),
|
|
2 => Just(Op::Ungroup),
|
|
2 => (0u8..5).prop_map(Op::Arrange),
|
|
3 => (0u8..10).prop_map(Op::Pathfinder),
|
|
// paint
|
|
2 => (0u8..6).prop_map(Op::Fill),
|
|
1 => (0.0..360.0f64).prop_map(Op::Gradient),
|
|
1 => (0u8..6, 0.0..10.0f64, any::<bool>()).prop_map(|(a, b, c)| Op::Stroke(a, b, c)),
|
|
1 => (0.0..100.0f64, 0u8..4).prop_map(|(a, b)| Op::Transparency(a, b)),
|
|
1 => (0u8..6).prop_map(Op::Align),
|
|
1 => Just(Op::Distribute),
|
|
// layers
|
|
1 => Just(Op::LayerNew),
|
|
1 => Just(Op::LayerDelete),
|
|
1 => (0usize..4).prop_map(Op::LayerCurrent),
|
|
1 => Just(Op::Collect),
|
|
// clipboard
|
|
1 => Just(Op::Copy),
|
|
1 => Just(Op::Cut),
|
|
2 => (0u8..4).prop_map(Op::Paste),
|
|
1 => Just(Op::Duplicate),
|
|
1 => Just(Op::Clear),
|
|
// history
|
|
3 => Just(Op::Undo),
|
|
2 => Just(Op::Redo),
|
|
// object menu
|
|
1 => Just(Op::Clip),
|
|
1 => Just(Op::ClipRelease),
|
|
1 => Just(Op::Compound),
|
|
1 => Just(Op::CompoundRelease),
|
|
1 => Just(Op::Lock),
|
|
1 => Just(Op::UnlockAll),
|
|
1 => Just(Op::Hide),
|
|
1 => Just(Op::ShowAll),
|
|
1 => (-10.0..10.0f64).prop_map(Op::Offset),
|
|
1 => Just(Op::Simplify),
|
|
1 => Just(Op::OutlineStroke),
|
|
1 => Just(Op::ExpandShape),
|
|
1 => (0u8..4).prop_map(Op::Effect),
|
|
1 => Just(Op::ExpandAppearance),
|
|
]
|
|
}
|
|
|
|
/// Any op, including (non-nested) batches.
|
|
pub fn arb_op() -> impl Strategy<Value = Op> {
|
|
prop_oneof![
|
|
30 => arb_leaf_op(),
|
|
1 => prop::collection::vec(arb_leaf_op().prop_filter("no history ops in batches", |o| !matches!(o, Op::Undo | Op::Redo)), 1..5).prop_map(Op::Batch),
|
|
]
|
|
}
|
|
|
|
/// A sequence of `len` ops.
|
|
pub fn arb_ops(len: std::ops::Range<usize>) -> impl Strategy<Value = Vec<Op>> {
|
|
prop::collection::vec(arb_op(), len)
|
|
}
|
|
|
|
fn pick(ids: &[vectorcraft_doc::NodeId], i: usize) -> Option<u64> {
|
|
if ids.is_empty() { None } else { Some(ids[i % ids.len()].0) }
|
|
}
|
|
|
|
impl Op {
|
|
/// The command id and params this op maps to in the current session state.
|
|
pub fn command(&self, s: &Session) -> (String, Value) {
|
|
let doc = s.active().map(|d| d.doc.clone());
|
|
// Children of compound paths are excluded: grouping one of them nests a group inside the
|
|
// compound (known bug, see crates/engine/tests/known_bugs.rs).
|
|
let art: Vec<_> = doc
|
|
.as_ref()
|
|
.map(|d| {
|
|
art_nodes(d)
|
|
.iter()
|
|
.filter(|n| {
|
|
!d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, vectorcraft_doc::NodeKind::Compound { .. }))
|
|
})
|
|
.map(|n| n.id)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let layers: Vec<_> = doc.as_ref().map(|d| d.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default();
|
|
let c = |id: &str, p: Value| (id.to_string(), p);
|
|
match self {
|
|
Op::Rect(x, y, w, h) => c("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})),
|
|
Op::Ellipse(x, y, w, h) => c("shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h})),
|
|
Op::Polygon(x, y, r, n) => c("shape.polygon", json!({"cx": x, "cy": y, "radius": r, "sides": n})),
|
|
Op::Star(x, y, r1, r2) => c("shape.star", json!({"cx": x, "cy": y, "radius1": r1, "radius2": r2})),
|
|
Op::Line(a, b, cc, d) => c("shape.line", json!({"x1": a, "y1": b, "x2": cc, "y2": d})),
|
|
Op::Path(pts, closed) => {
|
|
c("path.create", json!({"anchors": pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect::<Vec<_>>(), "closed": closed}))
|
|
}
|
|
Op::Text(x, y) => c("text.create", json!({"x": x, "y": y, "text": "Ab", "size": 18})),
|
|
Op::SelectSet(ix) => c("select.set", json!({"ids": ix.iter().filter_map(|i| pick(&art, *i)).collect::<Vec<_>>()})),
|
|
Op::SelectAll => c("select.all", json!({})),
|
|
Op::SelectNone => c("select.none", json!({})),
|
|
Op::SelectInverse => c("select.inverse", json!({})),
|
|
Op::Move(dx, dy, copy) => c("object.move", json!({"dx": dx, "dy": dy, "copy": copy})),
|
|
Op::Rotate(a, copy) => c("object.rotate", json!({"angle": a, "copy": copy})),
|
|
Op::Scale(sx, sy) => c("object.scale", json!({"sx": sx, "sy": sy})),
|
|
Op::Reflect(v) => c("object.reflect", json!({"axis": if *v { "vertical" } else { "horizontal" }})),
|
|
Op::Shear(a) => c("object.shear", json!({"angle": a})),
|
|
Op::TransformAgain => c("object.transformAgain", json!({})),
|
|
Op::Nudge(x, y) => c("object.nudge", json!({"dx": x, "dy": y})),
|
|
Op::Group => c("object.group", json!({})),
|
|
Op::Ungroup => c("object.ungroup", json!({})),
|
|
Op::Arrange(i) => {
|
|
let i = *i as usize % ARRANGE.len();
|
|
// Known bug (crates/engine/tests/known_bugs.rs): Bring to Front/Forward panics when
|
|
// the selection spans several parents. Avoid generating that case.
|
|
let multi_parent = doc.as_ref().zip(s.active()).is_some_and(|(d, st)| {
|
|
let mut parents: Vec<_> = st.selection.objects.iter().map(|id| d.parent_of(*id)).collect();
|
|
parents.dedup();
|
|
parents.len() > 1
|
|
});
|
|
if i < 2 && multi_parent { c("document.inspect", json!({})) } else { c(ARRANGE[i], json!({})) }
|
|
}
|
|
Op::Pathfinder(i) => c(PATHFINDER[*i as usize % PATHFINDER.len()], json!({})),
|
|
Op::Fill(i) => c("paint.setFill", json!({"color": COLORS[*i as usize % COLORS.len()]})),
|
|
Op::Gradient(a) => c(
|
|
"paint.setFill",
|
|
json!({"gradient": {"kind": if *a > 180.0 { "radial" } else { "linear" }, "angle": a, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
|
|
),
|
|
Op::Stroke(i, w, dash) => {
|
|
let mut p = json!({"weight": w});
|
|
if *dash {
|
|
p["dash"] = json!([4, 2]);
|
|
}
|
|
let _ = s;
|
|
// Also set the colour through setStroke in a separate step would need two commands;
|
|
// use the colour index to pick an arrowhead instead to cover more of stroke.set.
|
|
if i % 2 == 0 {
|
|
p["endArrow"] = json!("Triangle");
|
|
}
|
|
c("stroke.set", p)
|
|
}
|
|
Op::Transparency(o, b) => c("transparency.set", json!({"opacity": o, "blend": BLENDS[*b as usize % BLENDS.len()]})),
|
|
Op::Align(i) => {
|
|
let p = match i % 6 {
|
|
0 => json!({"horizontal": "left"}),
|
|
1 => json!({"horizontal": "center"}),
|
|
2 => json!({"horizontal": "right"}),
|
|
3 => json!({"vertical": "top"}),
|
|
4 => json!({"vertical": "center", "to": "artboard"}),
|
|
_ => json!({"vertical": "bottom", "to": "key"}),
|
|
};
|
|
c("object.align", p)
|
|
}
|
|
Op::Distribute => c("object.distribute", json!({"horizontal": "center"})),
|
|
Op::LayerNew => c("layer.new", json!({})),
|
|
Op::LayerDelete => c("layer.delete", json!({})),
|
|
Op::LayerCurrent(i) => c("layer.setCurrent", json!({"id": if layers.is_empty() { 0 } else { layers[i % layers.len()] }})),
|
|
Op::Collect => c("layer.collectInNew", json!({})),
|
|
Op::Copy => c("edit.copy", json!({})),
|
|
Op::Cut => c("edit.cut", json!({})),
|
|
Op::Paste(i) => c(PASTE[*i as usize % PASTE.len()], json!({})),
|
|
Op::Duplicate => c("edit.duplicate", json!({"dx": 5, "dy": 5})),
|
|
Op::Clear => c("edit.clear", json!({})),
|
|
Op::Undo => c("edit.undo", json!({})),
|
|
Op::Redo => c("edit.redo", json!({})),
|
|
Op::Clip => c("object.clippingMask.make", json!({})),
|
|
Op::ClipRelease => c("object.clippingMask.release", json!({})),
|
|
Op::Compound => c("object.compoundPath.make", json!({})),
|
|
Op::CompoundRelease => c("object.compoundPath.release", json!({})),
|
|
Op::Lock => c("object.lock", json!({})),
|
|
Op::UnlockAll => c("object.unlockAll", json!({})),
|
|
Op::Hide => c("object.hide", json!({})),
|
|
Op::ShowAll => c("object.showAll", json!({})),
|
|
Op::Offset(d) => c("object.path.offsetPath", json!({"offset": d})),
|
|
Op::Simplify => c("object.path.simplify", json!({"tolerance": 2})),
|
|
Op::OutlineStroke => c("object.path.outlineStroke", json!({})),
|
|
Op::ExpandShape => c("object.expandShape", json!({})),
|
|
Op::Effect(i) => {
|
|
let (id, p) = match i % 4 {
|
|
0 => ("distort.roughen", json!({"size": 3, "detail": 4, "seed": 1})),
|
|
1 => ("stylize.dropShadow", json!({})),
|
|
2 => ("distort.zigZag", json!({"size": 3, "ridges": 2})),
|
|
_ => ("stylize.roundCorners", json!({"radius": 5})),
|
|
};
|
|
c("effect.apply", json!({"effect": id, "params": p}))
|
|
}
|
|
Op::ExpandAppearance => c("effect.expandAppearance", json!({})),
|
|
Op::Batch(ops) => {
|
|
// Batches are resolved against the state *before* the batch; index-based
|
|
// references may therefore point at objects later steps removed (then that step
|
|
// errors and the whole batch rolls back — also worth exercising).
|
|
let cmds: Vec<Value> = ops
|
|
.iter()
|
|
// See the Arrange note above: selection inside a batch isn't known up front.
|
|
.map(|o| if let Op::Arrange(i) = o { Op::Arrange(2 + i % 3) } else { o.clone() })
|
|
.map(|o| {
|
|
let (id, p) = o.command(s);
|
|
json!({"command": id, "params": p})
|
|
})
|
|
.collect();
|
|
c("command.batch", json!({"label": "Gen batch", "commands": cmds}))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply this op to the session.
|
|
pub fn apply(&self, s: &mut Session) -> vectorcraft_engine::Result<Value> {
|
|
let (id, p) = self.command(s);
|
|
s.execute(&id, &p)
|
|
}
|
|
}
|
|
|
|
/// Keep the unused-import lint quiet when only some helpers are used.
|
|
#[doc(hidden)]
|
|
pub fn _top_level(s: &Session) -> Vec<vectorcraft_doc::NodeId> {
|
|
s.active().map(|d| top_level_ids(&d.doc)).unwrap_or_default()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- junk params
|
|
|
|
/// Hostile JSON values: wrong types, negative/huge/non-finite-as-null numbers, deep/empty containers.
|
|
pub fn junk_values() -> Vec<Value> {
|
|
vec![
|
|
Value::Null,
|
|
json!(true),
|
|
json!(-1),
|
|
json!(0),
|
|
json!(-1e9),
|
|
json!(1e12),
|
|
json!(1e308),
|
|
json!(-1e308),
|
|
json!(f64::MIN_POSITIVE),
|
|
json!(u64::MAX),
|
|
json!(i64::MIN),
|
|
json!(0.5),
|
|
json!(""),
|
|
json!("junk"),
|
|
json!("#zzzzzz"),
|
|
json!([]),
|
|
json!([1]),
|
|
json!([f64::MAX, -f64::MAX]),
|
|
json!([[1, 2], [3]]),
|
|
json!({}),
|
|
json!({"x": "y"}),
|
|
json!([[[[[]]]]]),
|
|
]
|
|
}
|
|
|
|
/// Every parameter key mentioned in any command's params doc (plus a few common ones).
|
|
pub fn all_param_keys() -> Vec<String> {
|
|
let mut keys: std::collections::BTreeSet<String> =
|
|
["ids", "id", "index", "x", "y", "width", "height", "points", "anchors", "params", "effect", "commands"]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
for spec in command_specs() {
|
|
keys.extend(param_keys(spec.params));
|
|
}
|
|
keys.into_iter().collect()
|
|
}
|
|
|
|
/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`).
|
|
pub fn param_keys(doc: &str) -> Vec<String> {
|
|
let doc = doc.split('→').next().unwrap_or("");
|
|
let chars: Vec<char> = doc.chars().collect();
|
|
let mut out = vec![];
|
|
let mut i = 0;
|
|
while i < chars.len() {
|
|
if chars[i].is_ascii_lowercase() {
|
|
let start = i;
|
|
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
|
|
i += 1;
|
|
}
|
|
// Previous non-space char must open a field; next char must close/type it.
|
|
let before = chars[..start].iter().rev().find(|c| **c != ' ').copied();
|
|
let after = chars.get(i).copied();
|
|
if matches!(before, Some('{') | Some(',') | Some('|'))
|
|
&& matches!(after, Some('?') | Some(':') | Some(',') | Some('}') | Some('=') | Some('|'))
|
|
{
|
|
out.push(chars[start..i].iter().collect());
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
out.sort();
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// Parameter-name keys that name filesystem paths: fuzzing these with arbitrary strings could
|
|
/// write files anywhere, so callers substitute a temp path.
|
|
pub fn is_path_key(k: &str) -> bool {
|
|
k == "path"
|
|
}
|
|
|
|
/// A deterministic list of junk param objects for `doc` (the command's params doc): each
|
|
/// documented key set to each junk value, all keys junk at once, wrong top-level types.
|
|
pub fn junk_params(doc: &str, safe_path: &str) -> Vec<Value> {
|
|
let keys = param_keys(doc);
|
|
let junk = junk_values();
|
|
let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})];
|
|
for k in &keys {
|
|
for (i, v) in junk.iter().enumerate() {
|
|
let v = if is_path_key(k) && v.is_string() { json!(safe_path) } else { v.clone() };
|
|
// Sparse: one key at a time with every junk value, plus a few combos.
|
|
let mut o = serde_json::Map::new();
|
|
o.insert(k.clone(), v.clone());
|
|
out.push(Value::Object(o));
|
|
if i % 5 == 0 {
|
|
let mut all = serde_json::Map::new();
|
|
for k2 in &keys {
|
|
let v2 = if is_path_key(k2) { json!(safe_path) } else { v.clone() };
|
|
all.insert(k2.clone(), v2);
|
|
}
|
|
out.push(Value::Object(all));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|