Files
figcraft/crates/engine/src/tests.rs
T

630 lines
33 KiB
Rust

use serde_json::json;
use super::*;
use figcraft_doc::NodeKind;
fn sess() -> Session {
Session::new()
}
fn id(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
#[test]
fn create_select_undo_redo() {
let mut s = sess();
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
let rid = id(&r);
assert_eq!(s.selection(), vec![rid]);
assert_eq!(s.doc().unwrap().world_bounds(rid).unwrap().x0, 10.0);
assert!(s.undo().unwrap());
assert!(!s.doc().unwrap().contains(rid));
assert!(s.redo().unwrap());
assert!(s.doc().unwrap().contains(rid));
}
#[test]
fn repeat_preserves_command_and_undo_steps() {
let mut s = sess();
let rid = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 10, "height": 10})).unwrap());
let params = json!({"ids": [rid], "dx": 2, "dy": 3});
s.execute("transform.nudge", &params).unwrap();
let repeated = Some(("transform.nudge".to_string(), params));
for count in 2..=4 {
s.execute("edit.repeat", &json!({})).unwrap();
assert_eq!(s.last_command, repeated);
let bounds = s.doc().unwrap().world_bounds(rid).unwrap();
assert_eq!((bounds.x0, bounds.y0), (10.0 + 2.0 * count as f64, 20.0 + 3.0 * count as f64));
assert_eq!(s.undo_depth(), (count + 1, 0));
}
for count in (0..4).rev() {
assert!(s.undo().unwrap());
let bounds = s.doc().unwrap().world_bounds(rid).unwrap();
assert_eq!((bounds.x0, bounds.y0), (10.0 + 2.0 * count as f64, 20.0 + 3.0 * count as f64));
}
assert_eq!(s.undo_depth(), (1, 4));
assert!(s.undo().unwrap());
assert!(!s.doc().unwrap().contains(rid));
}
#[test]
fn repeat_rejects_missing_command_and_self_reference() {
for last in [None, Some(("edit.repeat".to_string(), json!({})))] {
let mut s = sess();
s.last_command = last.clone();
assert!(matches!(s.execute("edit.repeat", &json!({})), Err(EngineError::Disabled(cmd, _)) if cmd == "edit.repeat"));
assert_eq!(s.last_command, last);
assert_eq!(s.undo_depth(), (0, 0));
assert!(s.journal.is_empty());
}
}
#[test]
fn node_set_and_errors_roll_back() {
let mut s = sess();
let rid = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("node.set", &json!({"id": rid, "props": {"fill": "#ff0000", "cornerRadius": 4, "opacity": 0.5, "name": "Red"}})).unwrap();
let n = s.doc().unwrap().node(rid).unwrap().clone();
assert_eq!(n.name, "Red");
assert_eq!(n.corners.radii, [4.0; 4]);
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF0000");
let undo = s.ds().unwrap().history.undo.len();
assert!(s.execute("node.set", &json!({"id": rid, "props": {"name": "X", "bogus": 1}})).is_err());
assert_eq!(s.doc().unwrap().node(rid).unwrap().name, "Red");
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
assert!(s.execute("no.such", &json!({})).is_err());
}
#[test]
fn failed_batch_restores_all_documents_and_history() {
let mut s = sess();
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("file.new", &json!({"name": "Second"})).unwrap();
s.execute("doc.nextTab", &json!({})).unwrap();
let before_journal = s.journal.clone();
let before_history: Vec<_> = s.docs.iter().map(|d| d.history.undo.len()).collect();
let result = s.execute(
"command.batch",
&json!({
"label": "Broken batch",
"commands": [
{"command": "doc.nextTab"},
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.active, 0, "the active document is part of the transaction");
assert_eq!(s.docs.len(), 2, "a failed batch cannot leave a newly opened tab behind");
assert_eq!(s.doc().unwrap().node(original).unwrap().name, "Rectangle");
assert_eq!(s.docs[1].doc.node(s.docs[1].page).map_or(0, |page| page.children.len()), 0);
assert_eq!(s.docs.iter().map(|d| d.history.undo.len()).collect::<Vec<_>>(), before_history);
assert_eq!(s.journal, before_journal, "failed inner commands are not journaled");
}
#[test]
fn group_ungroup_and_frame() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
let doc = s.doc().unwrap();
assert_eq!(doc.node(g).unwrap().kind.type_name(), "GROUP");
assert_eq!(doc.world_bounds(g).unwrap().width(), 60.0);
s.execute("object.ungroup", &json!({"ids": [g]})).unwrap();
assert!(!s.doc().unwrap().contains(g));
let f = id(&s.execute("object.frameSelection", &json!({"ids": [a, b]})).unwrap());
assert_eq!(s.doc().unwrap().node(a).unwrap().parent, Some(f));
assert_eq!(s.doc().unwrap().world_bounds(a).unwrap().x0, 0.0);
}
#[test]
fn auto_layout_add_hugs() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 20})).unwrap());
let r = s.execute("layout.add", &json!({"ids": [a, b]})).unwrap();
let f = params::id_of(&r["ids"][0]).unwrap();
let doc = s.doc().unwrap();
let fnode = doc.node(f).unwrap();
assert!(fnode.layout.is_some());
assert_eq!(fnode.width, 100.0);
// Change gap: frame hugs.
s.execute("node.set", &json!({"id": f, "props": {"gap": 0}})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 80.0);
// Padding.
s.execute("node.set", &json!({"id": f, "props": {"padding": 10}})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().width, 100.0);
assert_eq!(s.doc().unwrap().node(f).unwrap().height, 40.0);
}
#[test]
fn text_create_autosize_and_edit() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap());
let w0 = s.doc().unwrap().node(t).unwrap().width;
textedit::begin(&mut s, t, Some(5), false).unwrap();
textedit::insert(&mut s, " world").unwrap();
textedit::key(&mut s, "Backspace", false, false, false).unwrap();
textedit::end(&mut s).unwrap();
let n = s.doc().unwrap().node(t).unwrap().clone();
assert_eq!(n.kind.text().unwrap().characters, "Hello worl");
if figcraft_text::find_face("Inter", "Regular").is_some() {
assert!(n.width > w0);
}
// One undo step for the whole edit session.
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
}
#[test]
fn find_replace_replaces_every_occurrence_when_replacement_contains_find() {
for (text, find, replace, want) in [
("cat and cat", "cat", "cats", "cats and cats"),
("cat and cat", "cat", "category", "category and category"),
("banana", "a", "aa", "baanaanaa"),
] {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap());
s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap();
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}");
}
}
#[test]
fn find_replace_rejects_empty_find() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap());
assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err());
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat");
}
#[test]
fn components_and_instances_sync() {
let mut s = sess();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Button"})).unwrap());
let label = id(&s.execute("text.create", &json!({"text": "OK", "x": 10, "y": 10, "parent": f})).unwrap());
let c = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
assert_eq!(c, f);
let i = id(&s.execute("instance.create", &json!({"component": c, "x": 200, "y": 0})).unwrap());
let doc = s.doc().unwrap();
assert_eq!(doc.node(i).unwrap().children.len(), 1);
let inst_label = doc.node(i).unwrap().children[0];
// Edit the main: instance follows.
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
assert_eq!(s.doc().unwrap().node(i).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
// Override text in the instance, then change main text: override wins.
s.execute("text.set", &json!({"id": inst_label, "characters": "Cancel"})).unwrap();
s.execute("text.set", &json!({"id": label, "characters": "Yes"})).unwrap();
let doc = s.doc().unwrap();
let kid = doc.node(i).unwrap().children[0];
assert_eq!(kid, inst_label, "ids are stable across syncs");
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Cancel");
// Reset clears overrides.
s.execute("component.resetInstance", &json!({"ids": [i]})).unwrap();
let doc = s.doc().unwrap();
let kid = doc.node(i).unwrap().children[0];
assert_eq!(doc.node(kid).unwrap().kind.text().unwrap().characters, "Yes");
// Detach.
s.execute("component.detach", &json!({"ids": [i]})).unwrap();
assert_eq!(s.doc().unwrap().node(i).unwrap().kind.type_name(), "FRAME");
assert!(s.doc().unwrap().validate().is_empty());
}
#[test]
fn variants_switch() {
let mut s = sess();
let a = id(&s
.execute(
"node.create",
&json!({"type": "component", "x": 0, "y": 0, "width": 40, "height": 40, "name": "State=Default", "props": {"fill": "#ff0000"}}),
)
.unwrap());
let b = id(&s
.execute(
"node.create",
&json!({"type": "component", "x": 60, "y": 0, "width": 40, "height": 40, "name": "State=Hover", "props": {"fill": "#0000ff"}}),
)
.unwrap());
s.execute("component.combineVariants", &json!({"ids": [a, b]})).unwrap();
let i = id(&s.execute("instance.create", &json!({"component": a, "x": 0, "y": 200})).unwrap());
s.execute("instance.setProperties", &json!({"ids": [i], "properties": {"State": "Hover"}})).unwrap();
let n = s.doc().unwrap().node(i).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Instance { component, .. } if component == b));
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "0000FF");
}
#[test]
fn styles_and_variables() {
let mut s = sess();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#123456"}})).unwrap());
let st = s.execute("style.create", &json!({"type": "paint", "name": "Brand/Primary", "from": r})).unwrap();
let sid = st["id"].as_str().unwrap().to_string();
let r2 = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("style.apply", &json!({"ids": [r2], "style": "Brand/Primary"})).unwrap();
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "123456");
s.execute("style.update", &json!({"style": sid, "value": ["#ff00ff"]})).unwrap();
assert_eq!(s.doc().unwrap().node(r2).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
// Variables with modes.
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#ffffff", "Dark": "#000000"}}))
.unwrap();
s.execute("variable.bind", &json!({"ids": [r], "field": "fill", "variable": "bg"})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "FFFFFF");
let page = s.page().unwrap();
s.execute("variable.setMode", &json!({"ids": [page], "collection": "Theme", "mode": "Dark"})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().to_hex(), "000000");
}
#[test]
fn align_distribute_zorder() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 30, "y": 20, "width": 10, "height": 10})).unwrap());
let c = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 5, "width": 10, "height": 10})).unwrap());
s.execute("align.top", &json!({"ids": [a, b, c]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().y0, 0.0);
s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(b).unwrap().x0, 50.0);
s.execute("arrange.back", &json!({"ids": [c]})).unwrap();
let page = s.page().unwrap();
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
}
#[test]
fn a_layer_alone_on_the_page_has_nothing_to_align_to() {
use crate::cmd::arrange::can_align;
let mut s = sess();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
let inner = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 10, "height": 10, "parent": f})).unwrap());
let loose = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 5, "width": 10, "height": 10})).unwrap());
let doc = s.doc().unwrap();
assert!(!can_align(doc, &[]));
assert!(!can_align(doc, &[loose]), "alone on the page");
assert!(!can_align(doc, &[f, inner]), "a frame with its own child is the frame alone");
assert!(can_align(doc, &[inner]), "one in a frame aligns to it");
assert!(can_align(doc, &[f, loose]), "two align to each other");
// Where the buttons are off, the command still does nothing (and no harm).
let before = doc.world_bounds(loose);
s.execute("align.right", &json!({"ids": [loose]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(loose), before);
s.execute("align.right", &json!({"ids": [inner]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap().x1, 200.0);
}
#[test]
fn send_to_back_across_frames_counts_per_parent() {
let mut s = sess();
let make = |s: &mut Session, p: Value| id(&s.execute("node.create", &p).unwrap());
let fa = make(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200}));
let fb = make(&mut s, json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200}));
let rect = |parent: NodeId| json!({"type": "rectangle", "parent": parent, "x": 10, "y": 10, "width": 20, "height": 20});
let a: Vec<NodeId> = (0..2).map(|_| make(&mut s, rect(fa))).collect();
let b: Vec<NodeId> = (0..3).map(|_| make(&mut s, rect(fb))).collect();
let idx = |s: &Session, n: NodeId| s.doc().unwrap().index_in_parent(n);
assert_eq!(idx(&s, a[1]), Some(1));
assert_eq!(idx(&s, b[2]), Some(2));
s.execute("arrange.back", &json!({"ids": [a[1], b[2]]})).unwrap();
assert_eq!(idx(&s, a[1]), Some(0), "top of frame A at the back");
assert_eq!(idx(&s, b[2]), Some(0), "top of frame B at the back");
// Two from B plus one from A: each parent's selection ends at 0..n in its original order.
s.execute("arrange.back", &json!({"ids": [b[1], a[0], b[0]]})).unwrap();
let kids = |s: &Session, f: NodeId| s.doc().unwrap().node(f).unwrap().children.clone();
assert_eq!(kids(&s, fa), vec![a[0], a[1]]);
assert_eq!(kids(&s, fb), vec![b[0], b[1], b[2]]);
}
#[test]
fn copy_paste_and_duplicate() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap());
s.execute("edit.copy", &json!({"ids": [a]})).unwrap();
let r = s.execute("edit.paste", &json!({})).unwrap();
let pid = params::id_of(&r["ids"][0]).unwrap();
assert_ne!(pid, a);
assert_eq!(s.doc().unwrap().world_bounds(pid).unwrap().x0, 5.0);
let d = s.execute("edit.duplicate", &json!({"ids": [a]})).unwrap();
assert_eq!(d["ids"].as_array().unwrap().len(), 1);
}
#[test]
fn boolean_and_flatten() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap());
let g = id(&s.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
assert_eq!(s.doc().unwrap().node(g).unwrap().kind.type_name(), "BOOLEAN_OPERATION");
let v = id(&s.execute("vector.flatten", &json!({"ids": [g]})).unwrap());
let n = s.doc().unwrap().node(v).unwrap().clone();
assert_eq!(n.kind.type_name(), "VECTOR");
assert_eq!((n.width, n.height), (30.0, 30.0));
}
#[test]
fn pointer_draw_move_resize() {
let mut s = sess();
s.set_tool(ToolId::Rectangle);
let z = 1.0;
s.pointer(&PointerEvent::at(PointerKind::Down, 10.0, 10.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 60.0, 40.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 60.0, 40.0, z)).unwrap();
let r = s.selection()[0];
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap(), figcraft_geom::Rect::new(10.0, 10.0, 60.0, 40.0));
assert_eq!(s.tool.tool, ToolId::Move);
let undo_len = s.ds().unwrap().history.undo.len();
// Move it.
s.pointer(&PointerEvent::at(PointerKind::Down, 30.0, 20.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 130.0, 120.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 130.0, 120.0, z)).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(r).unwrap().x0, 110.0);
assert_eq!(s.ds().unwrap().history.undo.len(), undo_len + 1);
// Resize from the bottom-right corner.
s.pointer(&PointerEvent::at(PointerKind::Down, 160.0, 140.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 210.0, 190.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 210.0, 190.0, z)).unwrap();
let b = s.doc().unwrap().world_bounds(r).unwrap();
assert_eq!((b.width(), b.height()), (100.0, 80.0));
// Marquee empty space deselects then selects.
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 500.0, z)).unwrap();
assert!(s.selection().is_empty());
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 100.0, z)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, z)).unwrap();
assert_eq!(s.selection(), vec![r]);
}
/// No command panics on junk parameters.
#[test]
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
// which would fail this test).
let junk = [
json!({}),
json!(null),
json!({"ids": ["x", -1, 99999]}),
json!({"id": "nope", "x": "NaN", "props": 5}),
json!([1, 2, 3]),
json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}),
json!({"style": 3, "props": {"listStyle": 5, "indentation": 1e308, "listSpacing": "NaN", "hangingList": "yes", "start": -5, "end": 1e12,
"textDecorationThickness": {"unit": "pixels", "value": "x"}, "textDecorationOffset": -1e308, "textDecorationColor": [1]}}),
// Templates and the Getting started tips as junk.
json!({"template": {"id": 1}, "name": [1e308], "show": "yes"}),
// Links (dev resources) and indexes as junk.
json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}),
json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}),
// Widgets: plugins, targets and fuel as junk.
json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}),
// Plugin directories: ids, versions, servers and confirmations as junk.
json!({"id": "../../x", "version": {"x": 1}, "server": "ws://\u{0}/?key=", "confirm": "yes", "path": [5], "plugin": [1], "query": 7, "sha256": 3}),
json!({"id": "org.example.x", "version": "1.0.0", "server": "wss://[::1", "confirm": true, "plugin": "nope"}),
// Prototype expressions and gamepad keys as junk.
json!({"id": -5, "expression": "x[\u{0}", "expr": {"op": {"op": "add", "args": [1]}}, "trigger": {"type": "keyPress", "key": "Gamepad \u{0}"}}),
json!({"id": 1, "expr": {"inMode": {"variable": [], "mode": 1e308}}, "trigger": {"type": "keyPress", "key": 7}}),
// Keyboard steps (view.pan) and toggles as junk.
json!({"dx": 1e308, "dy": "NaN", "big": "yes", "on": 3}),
json!({"dx": -1e308, "dy": [1], "big": null, "on": null}),
// Open hosting: servers, owner tokens, actions, reports and the publish server as junk.
json!({"server": "wss://\u{0}", "file": [1], "owner": {"x": 1}, "action": "remove", "reason": 5, "link": -1, "access": "admin", "url": 7}),
json!({"server": "ws://127.0.0.1:1/", "file": "../x", "owner": "", "action": "createLink", "reason": "\u{7}\u{0}", "name": "", "url": "ws://x/?key=k"}),
// Sharing with a join code: ways, access, codes, servers, ports and interfaces as junk.
json!({"via": 7, "access": [1], "code": {"x": 1}, "link": -1, "server": 5, "port": -1, "bind": 3, "name": [1]}),
json!({"via": "carrier pigeon", "access": "admin", "code": "K7M-2QX-9T\u{0}", "server": "wss://\u{0}", "port": 1e308, "bind": "::::"}),
json!({"via": "local", "access": "view", "code": "\u{1F4A5}".repeat(64), "port": 70000, "bind": "lan"}),
// Online files: ids, servers, refreshes, actions and names as junk.
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
];
for spec in all_commands() {
if matches!(
spec.id,
"file.open"
| "file.save"
| "file.saveAs"
| "file.import"
| "file.export"
| "file.exportSvg"
| "file.placeImage"
| "image.place"
| "file.revert"
// Takes no params and walks the font folders: one walk per junk is slow.
| "text.rescanFonts"
) {
continue;
}
for j in &junk {
let mut s = sess();
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let _ = s.execute("text.create", &json!({"text": "t"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
#[test]
fn parity_catalog_mostly_covered() {
let (done, total, missing) = catalog::parity(&[]);
assert!(total > 150);
assert!(done as f64 / total as f64 > 0.8, "{done}/{total} missing: {missing:?}");
}
#[test]
fn export_frames_to_pdf() {
let mut s = sess();
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 120, "height": 80})).unwrap());
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let r = s.execute("file.exportFramesPdf", &json!({})).unwrap();
assert_eq!(r["pages"], 2);
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF"));
let text = String::from_utf8_lossy(&bytes);
assert_eq!(text.matches("/Type/Page").count() - text.matches("/Type/Pages").count(), 2);
assert!(text.contains("/MediaBox[0 0 200 100]") && text.contains("/MediaBox[0 0 120 80]"));
// file.export with format pdf: frames get a page each, other nodes share one.
let r = s.execute("file.export", &json!({"ids": [a, b], "format": "pdf"})).unwrap();
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
let text = String::from_utf8_lossy(&bytes);
assert_eq!(text.matches("/Type/Page").count() - text.matches("/Type/Pages").count(), 2);
// A page without frames is refused, not panicking.
s.execute("page.add", &json!({})).ok();
let empty = s.doc().unwrap().pages.last().copied().unwrap();
assert!(s.execute("file.exportFramesPdf", &json!({"page": empty})).is_err());
assert!(s.execute("file.exportFramesPdf", &json!({"page": a})).is_err());
}
#[test]
fn vector_edit_mode() {
let mut s = sess();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
s.execute("vector.edit.begin", &json!({"id": r})).unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().kind.type_name(), "VECTOR");
// Move one corner out: the box grows.
s.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap();
s.execute("vector.edit.moveVertices", &json!({"dx": 50, "dy": 50})).unwrap();
let b = s.doc().unwrap().world_bounds(r).unwrap();
assert_eq!((b.width(), b.height()), (150.0, 150.0));
// Add a point on segment 0 and delete-heal it.
let v = s.execute("vector.edit.addVertex", &json!({"segment": 0, "t": 0.5})).unwrap();
assert!(v["vertex"].as_u64().is_some());
let n0 = match &s.doc().unwrap().node(r).unwrap().kind {
NodeKind::Vector { network } => network.vertices.len(),
_ => 0,
};
s.execute("vector.deleteHeal", &json!({})).unwrap();
let n1 = match &s.doc().unwrap().node(r).unwrap().kind {
NodeKind::Vector { network } => (network.vertices.len(), network.regions.len()),
_ => (0, 0),
};
assert_eq!(n1, (n0 - 1, 1), "healed and still closed");
// Pointer: drag a vertex in edit mode.
let before = s.doc().unwrap().world_bounds(r).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, -20.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, -20.0, 0.0, 1.0)).unwrap();
let after = s.doc().unwrap().world_bounds(r).unwrap();
assert_eq!(after.x0, before.x0 - 20.0);
s.execute("vector.edit.end", &json!({})).unwrap();
assert!(s.ds().unwrap().vector_edit.is_none());
}
#[test]
fn arrows_reorder_auto_layout_children_and_cmd_arrows_resize() {
let mut s = sess();
let row = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 40})).unwrap());
let kids: Vec<NodeId> =
(0..3).map(|i| id(&s.execute("shape.rectangle", &json!({"x": i * 60, "y": 0, "width": 40, "height": 20, "parent": row})).unwrap())).collect();
s.execute("layout.add", &json!({"ids": [row]})).unwrap();
let order = |s: &Session| s.doc().unwrap().node(row).unwrap().children.clone();
assert_eq!(order(&s), kids);
// Right moves the first child one place later; Down does nothing in a horizontal flow.
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": 1, "dy": 0})).unwrap();
assert_eq!(order(&s), vec![kids[1], kids[0], kids[2]]);
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": 0, "dy": 1})).unwrap();
assert_eq!(order(&s), vec![kids[1], kids[0], kids[2]]);
// A selected block moves together and stops at the end.
s.execute("transform.nudge", &json!({"ids": [kids[1], kids[0]], "dx": 1, "dy": 0, "big": true})).unwrap();
assert_eq!(order(&s), vec![kids[2], kids[1], kids[0]]);
s.execute("transform.nudge", &json!({"ids": [kids[0]], "dx": -1, "dy": 0})).unwrap();
assert_eq!(order(&s), vec![kids[2], kids[0], kids[1]]);
s.undo().unwrap();
assert_eq!(order(&s), vec![kids[2], kids[1], kids[0]]);
// Free layers still nudge.
let free = id(&s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 10, "height": 10})).unwrap());
s.execute("transform.nudge", &json!({"ids": [free], "dx": 1, "dy": 0, "big": true})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, 510.0);
// Cmd+arrows: resize by the nudge amount, top-left fixed, only the changed axis becomes fixed.
s.execute("transform.nudgeSize", &json!({"ids": [free], "dw": 1, "dh": 0})).unwrap();
let n = s.doc().unwrap().node(free).unwrap().clone();
assert_eq!((n.width, n.height), (11.0, 10.0));
assert_eq!(s.doc().unwrap().world_bounds(free).unwrap().x0, 510.0);
s.execute("transform.nudgeSize", &json!({"ids": [free], "dw": 0, "dh": -1, "big": true})).unwrap();
assert_eq!(s.doc().unwrap().node(free).unwrap().height, 0.0, "never negative");
// A hugging auto layout frame keeps hugging vertically when only its width is nudged.
s.execute("node.set", &json!({"id": row, "props": {"sizingH": "hug", "sizingV": "hug"}})).unwrap();
s.execute("transform.nudgeSize", &json!({"ids": [row], "dw": 1, "dh": 0, "big": true})).unwrap();
let r = s.doc().unwrap().node(row).unwrap().clone();
assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug);
}
#[test]
fn document_colors_are_counted_per_page() {
let mut s = sess();
for (i, c) in ["#FF0000", "#FF0000", "#00FF00"].iter().enumerate() {
let r = id(&s.execute("shape.rectangle", &json!({"x": i * 20, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("node.set", &json!({"id": r, "props": {"fills": [c], "strokes": ["#0000FF"]}})).unwrap();
}
let v = s.execute("paint.documentColors", &json!({})).unwrap();
let list: Vec<(String, u64)> =
v["colors"].as_array().unwrap().iter().map(|c| (c["color"].as_str().unwrap().to_string(), c["count"].as_u64().unwrap())).collect();
assert_eq!(list.first(), Some(&("0000FF".to_string(), 3)));
assert!(list.contains(&("FF0000".to_string(), 2)));
assert!(list.contains(&("00FF00".to_string(), 1)));
let v = s.execute("paint.documentColors", &json!({"limit": 1})).unwrap();
assert_eq!(v["colors"].as_array().unwrap().len(), 1);
// Another page is separate.
s.execute("page.add", &json!({})).unwrap();
let v = s.execute("paint.documentColors", &json!({})).unwrap();
assert!(v["colors"].as_array().unwrap().is_empty());
}
#[test]
fn guides_add_move_remove() {
let mut s = sess();
let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind {
NodeKind::Page { guides, .. } => guides.clone(),
_ => vec![],
};
let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap();
assert_eq!(r["index"], json!(0));
s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap();
s.execute("guide.move", &json!({"index": 0, "offset": 150})).unwrap();
assert_eq!(guides(&s)[0].offset, 150.0);
s.undo().unwrap();
assert_eq!(guides(&s)[0].offset, 120.0);
s.execute("guide.remove", &json!({"index": 1})).unwrap();
assert_eq!(guides(&s).len(), 1);
for j in [json!({"index": -1}), json!({"index": 5, "offset": 1}), json!({"index": "x"}), json!({"offset": "NaN"})] {
assert!(s.execute("guide.remove", &j).is_err() || guides(&s).len() == 1, "{j}");
let _ = s.execute("guide.move", &j);
}
assert_eq!(guides(&s).len(), 1, "junk never removes the first guide");
s.execute("guide.clear", &json!({})).unwrap();
assert!(guides(&s).is_empty());
}
#[test]
fn find_replace_matches_and_replaces_case_insensitively() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap());
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap());
let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone();
let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap();
assert_eq!(r["matches"], json!([t, u, f]));
assert_eq!(chars(&s, t), "bye");
assert_eq!(chars(&s, u), "İ bye bye");
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box");
}
#[test]
fn find_replace_changing_only_the_case_replaces_every_occurrence() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap());
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap());
s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap();
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft");
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft");
}