vectorcraft/crates/testkit/src/fixtures.rs

256 lines
11 KiB
Rust
Raw Permalink 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.

//! Sessions and documents in known states.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_engine::Session;
use vectorcraft_geom::{PathData, Rect, shapes};
/// Execute a command and panic with context on error.
#[track_caller]
pub fn exec(s: &mut Session, id: &str, params: Value) -> Value {
match s.execute(id, &params) {
Ok(v) => v,
Err(e) => panic!("`{id}` {params} failed: {e}"),
}
}
/// The `id` field of a creation command's result.
#[track_caller]
pub fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")))
}
/// The created date (Unix seconds) of every fixture document: File Info dates come from the clock
/// otherwise, and documents made a second apart would not compare equal.
pub const CREATED: i64 = 1_767_225_600;
/// A session with one new `w`×`h` document, dated [`CREATED`].
pub fn session_with(w: f64, h: f64) -> Session {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h, "created": CREATED}));
s
}
/// A session with one new 400×300 document.
pub fn session() -> Session {
session_with(400.0, 300.0)
}
pub fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&exec(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})))
}
pub fn ellipse(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&exec(s, "shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h})))
}
pub fn star(s: &mut Session, cx: f64, cy: f64, r1: f64, r2: f64) -> NodeId {
id_of(&exec(s, "shape.star", json!({"cx": cx, "cy": cy, "radius1": r1, "radius2": r2, "points": 5})))
}
pub fn select(s: &mut Session, ids: &[NodeId]) {
exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}));
}
/// The three standard fixture states for sweeping commands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fixture {
/// A new document with nothing in it.
Empty,
/// One rectangle, selected.
Single,
/// Several overlapping objects of different kinds (paths, live shapes, text, a group), three selected.
Multi,
}
impl Fixture {
pub const ALL: [Fixture; 3] = [Fixture::Empty, Fixture::Single, Fixture::Multi];
pub fn session(self) -> Session {
let mut s = session();
match self {
Fixture::Empty => {}
Fixture::Single => {
rect(&mut s, 50.0, 50.0, 100.0, 60.0);
}
Fixture::Multi => {
let a = rect(&mut s, 40.0, 40.0, 120.0, 80.0);
let b = ellipse(&mut s, 100.0, 60.0, 90.0, 90.0);
let c = star(&mut s, 200.0, 150.0, 50.0, 25.0);
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 250, "text": "Hi", "size": 24})));
let p = id_of(&exec(
&mut s,
"path.create",
json!({"anchors": [{"x": 250, "y": 30}, {"x": 330, "y": 60, "in": [300, 20], "out": [360, 100]}, {"x": 300, "y": 140}], "closed": false}),
));
let g1 = rect(&mut s, 300.0, 200.0, 40.0, 40.0);
let g2 = ellipse(&mut s, 320.0, 220.0, 40.0, 40.0);
select(&mut s, &[g1, g2]);
exec(&mut s, "object.group", json!({}));
let _ = (t, p);
select(&mut s, &[a, b, c]);
}
}
s
}
}
/// A document touching most node kinds and appearance features, built through commands (so its
/// history is non-trivial). Selection is left empty.
pub fn rich_session() -> Session {
let mut s = session_with(500.0, 400.0);
let a = rect(&mut s, 20.0, 20.0, 150.0, 100.0);
exec(
&mut s,
"paint.setFill",
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "angle": 30}}),
);
let b = ellipse(&mut s, 100.0, 60.0, 120.0, 120.0);
exec(&mut s, "transparency.set", json!({"opacity": 60, "blend": "Multiply"}));
exec(&mut s, "stroke.set", json!({"weight": 4, "dash": [6, 3], "cap": "round"}));
let c = star(&mut s, 300.0, 100.0, 60.0, 30.0);
exec(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 3, "detail": 5, "seed": 7}}));
let l = id_of(&exec(&mut s, "shape.line", json!({"x1": 20, "y1": 300, "x2": 200, "y2": 350})));
exec(&mut s, "stroke.set", json!({"weight": 3, "endArrow": "Arrow"}));
let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30})));
// A non-default paragraph composer, so format round trips cover it.
exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"}));
// Area type with non-default Area Type Options (vertical alignment, inset).
let area = id_of(&exec(
&mut s,
"text.create",
json!({"x": 250, "y": 340, "text": "Centred area type", "size": 12, "area": {"width": 120, "height": 50}}),
));
select(&mut s, &[area]);
exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2}));
let _ = id_of(&exec(
&mut s,
"text.create",
json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.",
"area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}),
));
// Area type whose paragraphs differ (alignment, spacing, tabs).
let para = id_of(&exec(
&mut s,
"text.create",
json!({"x": 20, "y": 360, "text": "Heading\nBody one\tx\nBody two", "size": 9, "area": {"width": 160, "height": 36}}),
));
exec(&mut s, "text.setStyle", json!({"id": para, "justify": "center", "start": 0, "end": 0}));
exec(&mut s, "text.setFormat", json!({"ids": [para], "spaceBefore": 3, "firstLineIndent": 6, "start": 9, "end": 9}));
exec(&mut s, "text.tabs.set", json!({"ids": [para], "stops": [{"position": 80, "align": "right"}], "start": 9, "end": 9}));
exec(&mut s, "layer.new", json!({"name": "Top"}));
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);
select(&mut s, &[d, e]);
exec(&mut s, "object.compoundPath.make", json!({}));
let clip = ellipse(&mut s, 30.0, 150.0, 80.0, 80.0);
let inner = rect(&mut s, 10.0, 170.0, 140.0, 30.0);
exec(&mut s, "paint.setFill", json!({"color": "#22aa44"}));
select(&mut s, &[inner, clip]);
exec(&mut s, "object.clippingMask.make", json!({}));
// Type with an inline graphic: a symbol (a small circle, drawn here) set in a line of text.
let dot = ellipse(&mut s, 440.0, 40.0, 12.0, 12.0);
select(&mut s, &[dot]);
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
// so format round trips cover the definitions, the bindings and the rows.
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
let _ = (a, b, c, l);
exec(&mut s, "select.none", json!({}));
s
}
/// Build documents directly (without the engine), e.g. for render tests.
pub struct DocBuilder {
pub doc: Document,
pub layer: NodeId,
}
impl DocBuilder {
pub fn new(w: f64, h: f64) -> Self {
let doc = Document::new(w, h);
let layer = doc.layers[0].id;
Self { doc, layer }
}
pub fn alloc(&mut self) -> NodeId {
self.doc.alloc_id()
}
/// A path node (not inserted).
pub fn path_node(&mut self, path: PathData, appearance: Appearance) -> Node {
let id = self.alloc();
Node::path(id, path, appearance)
}
/// A filled, unstroked rectangle node (not inserted).
pub fn rect_node(&mut self, r: Rect, fill: Color) -> Node {
self.path_node(shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0))
}
/// Append `node` at the top of the current layer.
pub fn add(&mut self, node: Node) -> NodeId {
let l = self.layer;
self.doc.insert(Some(l), usize::MAX, node).expect("insert")
}
/// Add a filled rectangle; `f` may tweak the node first.
pub fn rect(&mut self, r: Rect, fill: Color, f: impl FnOnce(&mut Node)) -> NodeId {
let mut n = self.rect_node(r, fill);
f(&mut n);
self.add(n)
}
/// Add any path with an appearance; `f` may tweak the node first.
pub fn path(&mut self, path: PathData, appearance: Appearance, f: impl FnOnce(&mut Node)) -> NodeId {
let mut n = self.path_node(path, appearance);
f(&mut n);
self.add(n)
}
/// Add a group of `children` (bottom first). With `clip`, the first child is the clipping path.
pub fn group(&mut self, mut children: Vec<Node>, clip: bool) -> NodeId {
if clip && let Some(NodeKind::Path { clipping, .. }) = children.first_mut().map(|n| &mut n.kind) {
*clipping = true;
}
let id = self.alloc();
let mut g = Node::group(id, children.into_iter().map(Arc::new).collect());
if let NodeKind::Group { clip: c, .. } = &mut g.kind {
*c = clip;
}
self.add(g)
}
/// Add a new layer and make it current.
pub fn layer(&mut self, name: &str) -> NodeId {
self.layer = self.doc.add_layer(Some(name));
self.layer
}
pub fn build(self) -> Document {
self.doc
}
}
/// All non-layer nodes of a document in paint order.
pub fn art_nodes(doc: &Document) -> Vec<&Node> {
let mut v = Vec::new();
doc.walk(|n| {
if !n.is_layer() {
v.push(n)
}
});
v
}
/// Ids of the top-level art (direct children of layers), bottom first.
pub fn top_level_ids(doc: &Document) -> Vec<NodeId> {
doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect()
}
/// Every node id (layers included), paint order.
pub fn all_ids(doc: &Document) -> Vec<NodeId> {
let mut v = Vec::new();
doc.walk(|n| v.push(n.id));
v
}