mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 09:11:04 +00:00
One layer directly on the page has nothing to align to (one layer aligns to its parent frame), so the six Alignment buttons looked active and did nothing. They are now disabled there, with a hover explaining why. - cmd/arrange.rs: align_target (unchanged rule) and pub can_align. - right.rs: the align rows in add_enabled_ui + on_disabled_hover_text. - The hover text in every catalog. Tests: a_layer_alone_on_the_page_has_nothing_to_align_to (engine), the_align_buttons_are_off_for_a_layer_alone_on_the_page (AccessKit). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
556 lines
29 KiB
Rust
556 lines
29 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", ¶ms).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 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 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]);
|
|
}
|
|
|
|
#[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}),
|
|
// 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"
|
|
) {
|
|
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());
|
|
}
|