mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 04:22:46 +00:00
A connector end attached to a side was routed from the middle of that side, so the stroke's centre line started on the border and half its width plus the round cap lay on the fill, and several connectors leaving one side piled up into a knot sticking into the card. FigJam starts the line a few pixels outside the border and connectors leaving one side run together along the side's normal before they fan out (the issue's screenshots). `board_route::END_GAP` (4 pt): an end attached to a side (`RouteEnd::Box`) starts that far outside it along the side's normal; an end placed by hand on the object (`RouteEnd::On`) keeps its exact point; a free end is unchanged. The pinned route coordinates in the engine, format and UI tests move with it; the overlay's attach points stay on the border. Docs: the Connectors rows, status line and history of boards-parity.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
446 lines
23 KiB
Rust
446 lines
23 KiB
Rust
//! Boards: the file kind, sticky notes, shapes with text, connectors that follow what they join
|
|
//! (locally, through undo and in live sessions), stamps, ink and the eraser, and refusals.
|
|
|
|
use figcraft_doc::{
|
|
BOARD_PAGE, BoardItem, BoardShape, Connector, End, FileKind, Node, NodeId, NodeKind, Routing, Side, Stamp, StrokeCap, board_color,
|
|
};
|
|
use figcraft_geom::Point;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::board::{route, sticky_font_size};
|
|
use crate::collab_live_tests::Room;
|
|
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
|
use crate::{EngineError, Session};
|
|
|
|
pub(crate) fn board() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.newBoard", &json!({"name": "Ideas"})).unwrap();
|
|
s
|
|
}
|
|
|
|
pub(crate) fn id(v: &Value, key: &str) -> NodeId {
|
|
v[key].as_u64().map(NodeId).unwrap_or_else(|| panic!("no {key} in {v}"))
|
|
}
|
|
|
|
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
|
|
s.doc().unwrap().node(id).unwrap().clone()
|
|
}
|
|
|
|
pub(crate) fn connector(s: &Session, id: NodeId) -> Connector {
|
|
match node(s, id).board {
|
|
Some(BoardItem::Connector(c)) => c,
|
|
other => panic!("not a connector: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Connector `id`'s points on the page.
|
|
pub(crate) fn drawn(s: &Session, id: NodeId) -> Vec<Point> {
|
|
let doc = s.doc().unwrap();
|
|
let NodeKind::Vector { network } = &doc.node(id).unwrap().kind else { panic!("not a vector") };
|
|
let w = doc.world_transform(id);
|
|
network.vertices.iter().map(|v| w * v.point()).collect()
|
|
}
|
|
|
|
pub(crate) fn near(a: Point, b: Point) -> bool {
|
|
(a - b).hypot() < 1e-6
|
|
}
|
|
|
|
#[test]
|
|
fn a_board_is_a_kind_of_file_that_saves_and_reopens() {
|
|
let s = board();
|
|
let doc = s.doc().unwrap();
|
|
assert_eq!(doc.kind, FileKind::Board);
|
|
assert!(matches!(doc.node(doc.pages[0]).unwrap().kind, NodeKind::Page { background, .. } if background == BOARD_PAGE));
|
|
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
|
|
assert_eq!(back.kind, FileKind::Board);
|
|
// Files from before boards (no kind) are design files, and design files don't write one.
|
|
let mut d = Session::new();
|
|
d.execute("file.new", &json!({})).unwrap();
|
|
let json = figcraft_format::to_json(d.doc().unwrap()).unwrap();
|
|
assert!(json.get("kind").is_none());
|
|
assert_eq!(figcraft_format::from_json(&json.to_string()).unwrap().0.kind, FileKind::Design);
|
|
assert_eq!(crate::inspect::document(&s, 0, false)["kind"], "board", "inspect shows the kind");
|
|
}
|
|
|
|
#[test]
|
|
fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() {
|
|
let mut d = Session::new();
|
|
d.execute("file.new", &json!({})).unwrap();
|
|
for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0})), ("tool.sticky", json!({})), ("tool.set", json!({"tool": "marker"}))] {
|
|
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd} on a design file");
|
|
}
|
|
let mut s = board();
|
|
for cmd in ["view.devMode", "view.prototype", "proto.present", "proto.flow.add", "dev.focus"] {
|
|
let e = s.execute(cmd, &json!({})).unwrap_err();
|
|
assert!(e.to_string().contains("boards have no prototypes"), "{cmd}: {e}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() {
|
|
let mut s = board();
|
|
s.prefs.user_name = "Ada".into();
|
|
let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap();
|
|
let (st, note) = (id(&r, "id"), id(&r, "text"));
|
|
let n = node(&s, st);
|
|
assert!(n.is_board_atom() && matches!(&n.board, Some(BoardItem::Sticky { author }) if author == "Ada"));
|
|
assert_eq!((n.width, n.height), (200.0, 200.0));
|
|
assert_eq!(n.fills[0].solid_color(), board_color("blue"));
|
|
assert!(n.layout.is_some() && !n.effects.is_empty());
|
|
assert_eq!(n.children.len(), 2, "the note and its author");
|
|
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
|
|
// A click on the note picks the sticky whole.
|
|
let doc = s.doc().unwrap();
|
|
assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st);
|
|
// A long note takes a smaller size; a very long one grows the sticky past its square.
|
|
let long = "word ".repeat(120);
|
|
s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap();
|
|
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long));
|
|
assert!(sticky_font_size(&long) < 16.0);
|
|
assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0);
|
|
// Undo puts the short note and its size back.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
|
|
}
|
|
|
|
#[test]
|
|
fn next_sticky_goes_beside_in_the_same_colour_and_emptied_notes_stay() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink"})).unwrap(), "id");
|
|
let r = s.execute("board.nextSticky", &json!({})).unwrap();
|
|
let b = id(&r, "id");
|
|
let (na, nb) = (node(&s, a), node(&s, b));
|
|
assert_eq!(nb.transform.translation().x, na.width + 24.0);
|
|
assert_eq!(nb.fills, na.fills);
|
|
assert_eq!(s.selection(), vec![b]);
|
|
// Typing in it and emptying it again keeps its note; ending selects the sticky.
|
|
let note = id(&r, "text");
|
|
crate::board::edit_text(&mut s, b).unwrap();
|
|
crate::textedit::insert(&mut s, "x").unwrap();
|
|
crate::textedit::key(&mut s, "Backspace", false, false, false).unwrap();
|
|
crate::textedit::end(&mut s).unwrap();
|
|
assert!(s.doc().unwrap().contains(note));
|
|
assert_eq!(s.selection(), vec![b]);
|
|
}
|
|
|
|
#[test]
|
|
fn shapes_carry_text_and_change_outline() {
|
|
let mut s = board();
|
|
let r = s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "text": "Decide", "color": "green"})).unwrap();
|
|
let sh = id(&r, "id");
|
|
let n = node(&s, sh);
|
|
assert_eq!(n.board, Some(BoardItem::Shape { shape: BoardShape::Diamond }));
|
|
assert_eq!(n.children.len(), 2);
|
|
assert!(matches!(node(&s, n.children[0]).kind, NodeKind::Vector { .. }));
|
|
s.execute("board.setStyle", &json!({"ids": [sh], "shape": "speechBubble", "fontSize": 24})).unwrap();
|
|
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::SpeechBubble }));
|
|
assert_eq!(node(&s, id(&r, "text")).kind.text().unwrap().style.font_size, 24.0);
|
|
s.execute("board.setColor", &json!({"ids": [sh], "color": "#FF0000"})).unwrap();
|
|
assert_eq!(node(&s, n.children[0]).fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
|
|
assert!(s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).is_err());
|
|
}
|
|
|
|
/// Every shape is placed, takes text inside its outline, turns into any other and back by undo.
|
|
#[test]
|
|
fn every_shape_places_and_changes_with_undo() {
|
|
let mut s = board();
|
|
let err = s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).unwrap_err().to_string();
|
|
assert!(err.contains("internalStorage"), "the error lists every shape: {err}");
|
|
for b in BoardShape::ALL {
|
|
let r = s.execute("board.shape", &json!({"shape": b.id(), "x": 0, "y": 0, "text": "Step"})).unwrap();
|
|
let sh = id(&r, "id");
|
|
let n = node(&s, sh);
|
|
assert_eq!(n.board, Some(BoardItem::Shape { shape: b }));
|
|
let NodeKind::Vector { network } = node(&s, n.children[0]).kind else { panic!("{}: no outline", b.id()) };
|
|
assert!(!network.segments.is_empty(), "{}", b.id());
|
|
let other = if b == BoardShape::Star { BoardShape::Hexagon } else { BoardShape::Star };
|
|
s.execute("board.setStyle", &json!({"ids": [sh], "shape": other.id()})).unwrap();
|
|
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: other }));
|
|
s.undo().unwrap();
|
|
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: b }));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn connectors_follow_what_they_join_and_undo_with_it() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
|
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
|
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id");
|
|
let pts = drawn(&s, c);
|
|
assert!(near(pts[0], Point::new(204.0, 100.0)) && near(pts[1], Point::new(396.0, 100.0)), "{pts:?}");
|
|
assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow);
|
|
// Its label sits at the middle.
|
|
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
|
|
let lb = s.doc().unwrap().world_bounds(label).unwrap();
|
|
assert!(near(lb.center(), Point::new(300.0, 100.0)), "{lb:?}");
|
|
// Move one end: the connector re-routes (elbow now, out of the facing sides).
|
|
s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap();
|
|
s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap();
|
|
let pts = drawn(&s, c);
|
|
assert!(near(pts[0], Point::new(204.0, 100.0)) && near(*pts.last().unwrap(), Point::new(396.0, 400.0)), "{pts:?}");
|
|
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}");
|
|
// Undo takes the move back and the route with it.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0)));
|
|
// A deleted end stays where it was, as a point.
|
|
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
|
assert!(matches!(connector(&s, c).to, End::Point { .. }));
|
|
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0)));
|
|
// A connector moved on its own takes its free end along; the attached one stays.
|
|
s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap();
|
|
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 396.0 && y == 150.0), "{:?}", connector(&s, c));
|
|
assert!(near(drawn(&s, c)[0], Point::new(204.0, 100.0)));
|
|
// Deleting the connector takes its label.
|
|
s.execute("edit.delete", &json!({"ids": [c]})).unwrap();
|
|
assert!(!s.doc().unwrap().contains(label));
|
|
assert!(s.execute("board.connector", &json!({"from": a, "to": a})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn routes_are_the_same_for_the_same_document() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
|
let c = Connector {
|
|
from: End::Node { node: a, side: Side::Bottom, at: None },
|
|
to: End::Point { x: 300.0, y: 500.0 },
|
|
routing: Routing::Elbow,
|
|
path: None,
|
|
};
|
|
let doc = s.doc().unwrap();
|
|
let r = route(doc, &c).unwrap();
|
|
assert_eq!(r, route(&doc.clone(), &c).unwrap());
|
|
// Down out of the bottom (END_GAP below it), across halfway, down into the point.
|
|
assert_eq!(r, vec![Point::new(80.0, 164.0), Point::new(80.0, 332.0), Point::new(300.0, 332.0), Point::new(300.0, 500.0)]);
|
|
}
|
|
|
|
#[test]
|
|
fn stamps_and_ink_and_the_eraser() {
|
|
let mut s = board();
|
|
s.prefs.user_name = "Grace".into();
|
|
let st = id(&s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "heart"})).unwrap(), "id");
|
|
let n = node(&s, st);
|
|
assert_eq!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into(), on: None }), "on the empty board: on nothing");
|
|
assert_eq!(s.doc().unwrap().world_bounds(st).unwrap().center(), Point::new(100.0, 100.0));
|
|
assert!(!n.children.is_empty());
|
|
// Marker and highlighter strokes through the tools; the eraser takes what it touches.
|
|
let draw = |s: &mut Session, tool: &str, y: f64| {
|
|
s.execute("tool.set", &json!({"tool": tool})).unwrap();
|
|
for (k, x) in [(PointerKind::Down, 0.0), (PointerKind::Move, 50.0), (PointerKind::Move, 100.0), (PointerKind::Up, 150.0)] {
|
|
s.pointer(&PointerEvent::at(k, x, y, 1.0)).unwrap();
|
|
}
|
|
};
|
|
draw(&mut s, "marker", 300.0);
|
|
draw(&mut s, "highlighter", 400.0);
|
|
let inks: Vec<Node> = s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).cloned().collect();
|
|
assert_eq!(inks.len(), 2);
|
|
assert!(inks.iter().any(|n| n.opacity < 1.0 && n.stroke.weight > 10.0), "a highlighter stroke");
|
|
let undo = s.undo_depth().0;
|
|
s.execute("tool.eraser", &json!({})).unwrap();
|
|
for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Move, 60.0), (PointerKind::Up, 80.0)] {
|
|
s.pointer(&PointerEvent::at(k, x, 300.0, 1.0)).unwrap();
|
|
}
|
|
assert_eq!(s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).count(), 1, "the marker stroke is gone");
|
|
assert_eq!(s.undo_depth().0, undo + 1, "one step");
|
|
assert_eq!(s.tool.tool, ToolId::Eraser);
|
|
}
|
|
|
|
/// A stamp sticks to the object it lands on and moves with it (as the incumbent's stamps do); one
|
|
/// dragged onto another object sticks to that one, one dragged off sticks to nothing, and one
|
|
/// whose object is deleted stays where it is.
|
|
#[test]
|
|
fn stamps_stick_to_what_they_land_on() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
|
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
|
let on = |s: &Session, st: NodeId| match node(s, st).board {
|
|
Some(BoardItem::Stamp { on, .. }) => on,
|
|
other => panic!("not a stamp: {other:?}"),
|
|
};
|
|
let centre = |s: &Session, st: NodeId| s.doc().unwrap().world_bounds(st).unwrap().center();
|
|
let mv = |s: &mut Session, ids: &[NodeId], dx: f64, dy: f64| {
|
|
s.execute("transform.move", &json!({"ids": ids, "dx": dx, "dy": dy})).unwrap();
|
|
};
|
|
let st = id(&s.execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "heart"})).unwrap(), "id");
|
|
assert_eq!(on(&s, st), Some(a));
|
|
// The sticky moves, the stamp with it; both moved together, once; undo takes both back.
|
|
mv(&mut s, &[a], 30.0, 200.0);
|
|
assert_eq!(centre(&s, st), Point::new(80.0, 250.0));
|
|
mv(&mut s, &[a, st], 10.0, 0.0);
|
|
assert_eq!((centre(&s, st), on(&s, st)), (Point::new(90.0, 250.0), Some(a)));
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(centre(&s, st), Point::new(80.0, 250.0));
|
|
// Dragged onto the other sticky, it sticks to that one.
|
|
mv(&mut s, &[st], 370.0, -200.0);
|
|
assert_eq!(on(&s, st), Some(b));
|
|
mv(&mut s, &[b], 0.0, 100.0);
|
|
assert_eq!(centre(&s, st), Point::new(450.0, 150.0));
|
|
mv(&mut s, &[a], 0.0, 300.0);
|
|
assert_eq!(centre(&s, st), Point::new(450.0, 150.0), "the first sticky doesn't take it along any more");
|
|
// Its sticky deleted, it stays where it is, on nothing; dragged off everything, on nothing.
|
|
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
|
assert_eq!((on(&s, st), centre(&s, st)), (None, Point::new(450.0, 150.0)));
|
|
let other = id(&s.execute("board.stamp", &json!({"x": 130, "y": 600, "stamp": "star"})).unwrap(), "id");
|
|
assert_eq!(on(&s, other), Some(a));
|
|
mv(&mut s, &[other], 0.0, 1000.0);
|
|
assert_eq!(on(&s, other), None);
|
|
// One that sits inside its sticky (dropped into it before stickies stopped taking layers in)
|
|
// moves with it once, as its child.
|
|
let inside = id(&s.execute("board.stamp", &json!({"x": 100, "y": 600, "stamp": "check"})).unwrap(), "id");
|
|
assert_eq!(on(&s, inside), Some(a));
|
|
s.doc_mut().unwrap().reparent(inside, a, None).unwrap();
|
|
let was = centre(&s, inside);
|
|
mv(&mut s, &[a], 40.0, 0.0);
|
|
assert_eq!(centre(&s, inside), Point::new(was.x + 40.0, was.y), "moved once");
|
|
// A file from before stamps stuck opens with its stamps on nothing.
|
|
let old: BoardItem = serde_json::from_value(json!({"type": "stamp", "stamp": "heart", "author": "Grace"})).unwrap();
|
|
assert_eq!(old, BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into(), on: None });
|
|
}
|
|
|
|
#[test]
|
|
fn the_sticky_and_connector_tools() {
|
|
let mut s = board();
|
|
s.execute("tool.sticky", &json!({"color": "orange"})).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
|
|
let te = s.ds().unwrap().text_edit.clone().expect("typing in the new sticky");
|
|
let a = crate::board::owner_of_text(s.doc().unwrap(), te.id).unwrap();
|
|
assert_eq!(node(&s, a).fills[0].solid_color(), board_color("orange"));
|
|
crate::textedit::insert(&mut s, "First").unwrap();
|
|
crate::textedit::end(&mut s).unwrap();
|
|
let b = id(&s.execute("board.sticky", &json!({"x": 500, "y": 0})).unwrap(), "id");
|
|
s.execute("tool.connector", &json!({})).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 300.0, 100.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 100.0, 1.0)).unwrap();
|
|
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
|
|
let k = connector(&s, c);
|
|
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
|
|
}
|
|
|
|
#[test]
|
|
fn boards_sync_in_live_sessions() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap(), "id");
|
|
let mut room = Room::new(s.doc().unwrap().clone(), 2, 7);
|
|
assert_eq!(room.s(1).doc().unwrap().kind, FileKind::Board, "joiners get a board");
|
|
let b = id(&room.s(1).execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap(), "id");
|
|
room.settle();
|
|
let c = id(&room.s(0).execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
|
|
room.s(1).execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "star"})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
// Person 1 moves the sticky person 0 connected: both see the connector follow it.
|
|
room.s(1).execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 250})).unwrap();
|
|
room.s(0).execute("board.setColor", &json!({"ids": [a], "color": "violet"})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
let doc = room.server.document();
|
|
let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") };
|
|
let end = doc.world_transform(c) * network.vertices.last().unwrap().point();
|
|
assert_eq!(end, Point::new(396.0, 350.0), "to END_GAP before the moved sticky's facing side");
|
|
assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet"));
|
|
}
|
|
|
|
#[test]
|
|
fn board_commands_survive_junk() {
|
|
let junk = [
|
|
json!({}),
|
|
json!(null),
|
|
json!([1]),
|
|
json!({"x": "NaN", "y": 1e308, "text": 5, "color": [1], "author": {"x": 1}, "parent": "p"}),
|
|
json!({"x": -1e308, "y": 0, "color": "neon", "shape": 7, "width": -5, "height": 1e308, "stamp": "skull"}),
|
|
json!({"from": {"x": "a"}, "to": 99_999, "fromSide": "inside", "style": 3, "startCap": "spike", "endCap": 7, "label": [1]}),
|
|
json!({"from": {"x": 1e308, "y": -1e308}, "to": {"x": 0, "y": 0}, "style": "elbow", "label": "\u{0}"}),
|
|
json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}),
|
|
json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}),
|
|
json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}),
|
|
json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}),
|
|
json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}),
|
|
];
|
|
let ids: Vec<&str> = crate::cmd::all_commands()
|
|
.iter()
|
|
.map(|c| c.id)
|
|
.filter(|i| {
|
|
i.starts_with("board.")
|
|
|| ["tool.sticky", "tool.shape", "tool.connector", "tool.marker", "tool.highlighter", "tool.eraser", "tool.stamp", "file.newBoard"]
|
|
.contains(i)
|
|
})
|
|
.collect();
|
|
assert!(ids.len() >= 17, "{ids:?}");
|
|
for cmd in ids {
|
|
for j in &junk {
|
|
let mut s = board();
|
|
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
|
|
let b = s.execute("board.shape", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
|
|
let _ = s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"}));
|
|
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
|
|
panic!("{cmd} panicked on {j}: {msg}");
|
|
}
|
|
}
|
|
}
|
|
// Every other command on a board with objects on it.
|
|
for spec in crate::cmd::all_commands() {
|
|
if spec.id.starts_with("file.") && spec.id != "file.newBoard" || spec.id == "text.rescanFonts" || spec.id == "image.place" {
|
|
continue;
|
|
}
|
|
for j in &junk[..3] {
|
|
let mut s = board();
|
|
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
|
|
let b = s.execute("board.stamp", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
|
|
let _ = s.execute("board.connector", &json!({"from": a, "to": b}));
|
|
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
|
|
panic!("{cmd} panicked on {j}: {msg}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_press_on_a_stickys_background_picks_and_drags_it() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id");
|
|
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
|
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
// Below the note, on the sticky's own fill (a top-level frame's background is the canvas).
|
|
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap();
|
|
assert_eq!(s.selection(), vec![a]);
|
|
assert_eq!(node(&s, a).transform.translation().y, 300.0);
|
|
// Its own connector's start, END_GAP outside its edge, is no snap target for it (#1916).
|
|
assert_eq!(node(&s, a).transform.translation().x, 0.0, "not nudged sideways by its own connector");
|
|
assert!(near(drawn(&s, c)[0], Point::new(204.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
|
|
}
|
|
|
|
#[test]
|
|
fn a_copy_of_joined_objects_joins_the_copies() {
|
|
let mut s = board();
|
|
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
|
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
|
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})).unwrap(), "id");
|
|
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
|
|
s.execute("edit.duplicate", &json!({"ids": [a, b, c, label]})).unwrap();
|
|
let copies = s.selection();
|
|
assert_eq!(copies.len(), 4, "{copies:?}");
|
|
let k = copies.iter().find_map(|i| match node(&s, *i).board {
|
|
Some(BoardItem::Connector(k)) => Some(k),
|
|
_ => None,
|
|
});
|
|
let k = k.unwrap();
|
|
assert!(copies.contains(&k.from.node().unwrap()) && copies.contains(&k.to.node().unwrap()), "{k:?} {copies:?}");
|
|
// Copy and paste too.
|
|
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
|
|
s.execute("edit.copy", &json!({})).unwrap();
|
|
s.execute("edit.paste", &json!({})).unwrap();
|
|
let pasted = s.selection();
|
|
let k = pasted.iter().find_map(|i| match node(&s, *i).board {
|
|
Some(BoardItem::Connector(k)) => Some(k),
|
|
_ => None,
|
|
});
|
|
let k = k.unwrap();
|
|
assert!(pasted.contains(&k.from.node().unwrap()) && pasted.contains(&k.to.node().unwrap()), "{k:?} {pasted:?}");
|
|
}
|