//! Dev Mode behaviour suite, written from the public documentation of handoff tools (in our own //! words): the canvas inspects and never edits, what the menus and shortcuts may run there, the //! list of what's ready for development, statuses (ready, changed, completed) and the focus view. use crate::Session; use crate::components::tests::{ex, idv}; use crate::devinspect::dev_mode_allows; use crate::tools::{PointerEvent, PointerKind, ToolId}; use figcraft_doc::{DevStatus, NodeId}; use serde_json::{Value, json}; fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { PointerEvent::at(kind, x, y, 1.0) } fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap(); for k in 1..=4 { let t = f64::from(k) / 4.0; s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)).unwrap(); } s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap(); } fn frame(s: &mut Session, x: f64, name: &str) -> NodeId { idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name}))) } fn rect(s: &mut Session, parent: NodeId, x: f64) -> NodeId { idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40, "parent": parent}))) } fn names(v: &Value) -> Vec { v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default() } fn status(s: &mut Session, ids: &[NodeId], st: &str) { ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}})); } #[test] fn a_dev_mode_click_on_a_selected_layer_selects_just_it() { let mut s = Session::new(); let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))); let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 80, "y": 0, "width": 40, "height": 40}))); s.tool.dev_mode = true; ex(&mut s, "select.set", json!({"ids": [a, b]})); // Dev Mode starts no move gesture, so the click narrows the selection at the press itself. s.pointer(&ev(PointerKind::Down, 20.0, 20.0)).unwrap(); s.pointer(&ev(PointerKind::Up, 20.0, 20.0)).unwrap(); assert_eq!(s.selection(), vec![a], "a click on one of several selected layers selects it"); } #[test] fn the_dev_mode_canvas_selects_and_pans_but_never_edits() { let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); let t = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 100, "y": 120, "parent": f}))); s.tool.dev_mode = true; let before = s.doc().unwrap().clone(); let steps = s.undo_labels(100).len(); // A press selects; dragging it moves nothing. drag(&mut s, (40.0, 40.0), (140.0, 90.0)); assert_eq!(s.selection(), vec![r]); // Nor do its resize handles. drag(&mut s, (60.0, 60.0), (120.0, 120.0)); // Drawing tools only select there. for tool in [ToolId::Rectangle, ToolId::Frame, ToolId::Text, ToolId::Pen, ToolId::Pencil, ToolId::Scale] { s.set_tool(tool); drag(&mut s, (300.0, 300.0), (360.0, 360.0)); } s.set_tool(ToolId::Move); // A double-click on text doesn't start typing; on a frame's layer it selects into it. ex(&mut s, "select.set", json!({"ids": [t]})); s.pointer(&ev(PointerKind::DoubleClick, 110.0, 128.0)).unwrap(); assert!(s.ds().unwrap().text_edit.is_none(), "no text editing"); ex(&mut s, "select.set", json!({"ids": [r]})); s.pointer(&ev(PointerKind::DoubleClick, 40.0, 40.0)).unwrap(); assert!(s.ds().unwrap().vector_edit.is_none(), "no vector editing"); assert!(*s.doc().unwrap() == before, "the design is untouched"); assert_eq!(s.undo_labels(100).len(), steps, "nothing to undo"); // Keys that edit with a tool (a selected guide's Delete) go to the commands instead. assert!(!s.tool_key("Delete", false, false, false).unwrap()); // The hand still pans; comments still start. s.set_tool(ToolId::Hand); let cx = s.ds().unwrap().view.cx; s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); s.pointer(&PointerEvent { sx: 550.0, ..ev(PointerKind::Move, 500.0, 500.0) }).unwrap(); s.pointer(&ev(PointerKind::Up, 500.0, 500.0)).unwrap(); assert_ne!(s.ds().unwrap().view.cx, cx, "panned"); s.set_tool(ToolId::Comment); s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); assert!(s.ui_requests.iter().any(|u| u.kind == "comment.new")); // Back in the design: the same drag moves. s.tool.dev_mode = false; s.set_tool(ToolId::Move); drag(&mut s, (40.0, 40.0), (140.0, 90.0)); assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved"); } /// The `annotation.new` requests queued so far, as the layer ids they name. fn note_requests(s: &Session) -> Vec { s.ui_requests.iter().filter(|r| r.kind == "annotation.new").filter_map(|r| crate::params::id_of(&r.data["id"])).collect() } fn click(s: &mut Session, x: f64, y: f64) { s.pointer(&ev(PointerKind::Down, x, y)).unwrap(); s.pointer(&ev(PointerKind::Up, x, y)).unwrap(); } #[test] fn the_annotation_tool_picks_a_layer_and_asks_for_its_note() { for dev_mode in [true, false] { let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); s.tool.dev_mode = dev_mode; ex(&mut s, "select.set", json!({"ids": []})); ex(&mut s, "tool.annotation", json!({})); assert_eq!(s.tool.tool, ToolId::Annotation); assert!(note_requests(&s).is_empty(), "nothing selected: no note is asked for yet"); let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len()); // A press on empty canvas does nothing. click(&mut s, 1000.0, 1000.0); assert!(note_requests(&s).is_empty(), "empty canvas asks for nothing (dev_mode {dev_mode})"); // A press on the rectangle selects it and asks for its note. click(&mut s, 40.0, 40.0); assert_eq!(s.selection(), vec![r], "dev_mode {dev_mode}"); assert_eq!(note_requests(&s), vec![r], "dev_mode {dev_mode}"); assert_eq!(s.tool.tool, ToolId::Annotation, "the tool stays until V or Escape"); // The tool writes nothing. assert_eq!(*s.doc().unwrap(), doc_before); assert_eq!(s.undo_labels(1000).len(), undo_before); } // The keyboard flow: with a layer selected, choosing the tool asks at once. let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); ex(&mut s, "select.set", json!({"ids": [r]})); ex(&mut s, "tool.annotation", json!({})); assert_eq!(note_requests(&s), vec![r]); // And with an `id`. let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); ex(&mut s, "tool.annotation", json!({"id": r})); assert_eq!(note_requests(&s), vec![r]); assert_eq!(s.selection(), vec![r]); } /// A named target that isn't a layer (a page, an unknown id) is an error, and the tool is left as it /// was; several targets pick the first layer among them and select it. #[test] fn the_annotation_tool_checks_the_layer_it_is_named_for_before_changing_anything() { let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); let page = s.page().unwrap(); assert!(s.execute("tool.annotation", &json!({"id": page})).is_err(), "a page is not a layer"); assert!(s.execute("tool.annotation", &json!({"id": "9999:9999"})).is_err(), "no such layer"); assert!(s.execute("tool.annotation", &json!({"ids": []})).is_err(), "nothing named"); assert_eq!(s.tool.tool, ToolId::Move, "the tool did not change"); assert!(note_requests(&s).is_empty()); // The page first, then the rectangle: the rectangle is the target, and selected. ex(&mut s, "tool.annotation", json!({"ids": [page, r]})); assert_eq!((s.tool.tool, s.selection(), note_requests(&s)), (ToolId::Annotation, vec![r], vec![r])); } /// Choosing the tool with `tool.set` (the toolbar) asks for the selected layer's note like Shift+T. #[test] fn choosing_the_annotation_tool_from_the_toolbar_asks_for_the_selected_layers_note() { let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); ex(&mut s, "select.set", json!({"ids": [r]})); ex(&mut s, "tool.set", json!({"tool": "annotation"})); assert_eq!((s.tool.tool, note_requests(&s)), (ToolId::Annotation, vec![r])); } /// A drag with the tool never moves the layer it starts on, in Developer Mode or out of it. #[test] fn dragging_with_the_annotation_tool_moves_nothing() { for dev_mode in [true, false] { let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); rect(&mut s, f, 20.0); s.tool.dev_mode = dev_mode; ex(&mut s, "select.set", json!({"ids": []})); ex(&mut s, "tool.annotation", json!({})); let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len()); drag(&mut s, (40.0, 40.0), (140.0, 90.0)); assert_eq!(*s.doc().unwrap(), doc_before, "dev_mode {dev_mode}"); assert_eq!(s.undo_labels(1000).len(), undo_before, "dev_mode {dev_mode}"); } } #[test] fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() { for id in [ "annotation.add", "annotation.edit", "dev.resolveChange", "dev.focus", "devResource.add", "codeConnect.set", "comment.add", "edit.undo", "edit.redo", "edit.copy", "edit.copyAsCss", "select.all", "view.zoomIn", "view.devMode", "file.export", "file.save", "tool.move", "tool.hand", "tool.comment", "tool.annotation", "inspect.code", ] { assert!(dev_mode_allows(id), "{id} runs in Dev Mode"); } for id in [ "edit.delete", "edit.paste", "edit.cut", "edit.duplicate", "transform.nudge", "object.group", "tool.rectangle", "tool.text", "tool.place", "file.import", "file.revert", "data.fill", ] { assert!(!dev_mode_allows(id), "{id} edits the design"); } } #[test] fn the_ready_list_shows_frames_and_sections_with_a_status_across_pages() { let mut s = Session::new(); let a = frame(&mut s, 0.0, "Login"); let b = frame(&mut s, 300.0, "Signup"); let nested = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": a, "name": "Card"}))); status(&mut s, &[a], "readyForDev"); status(&mut s, &[b], "completed"); // A layer inside a frame takes no status of its own (its frame's covers it). assert!(s.execute("node.set", &json!({"id": nested, "props": {"devStatus": "readyForDev"}})).is_err()); assert!(s.execute("node.set", &json!({"ids": [a, nested], "props": {"devStatus": "completed"}})).is_err(), "all or nothing"); assert_eq!(s.doc().unwrap().node(a).map(|n| n.dev_status), Some(DevStatus::ReadyForDev)); // Clearing one is always fine. ex(&mut s, "node.set", json!({"id": nested, "props": {"devStatus": null}})); let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"}))); ex(&mut s, "page.select", json!({"id": pg2})); let c = frame(&mut s, 0.0, "Profile"); status(&mut s, &[c], "readyForDev"); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login", "Profile"]); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))), vec!["Login", "Signup", "Profile"]); // Statuses are undoable. s.undo().unwrap(); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login"]); } #[test] fn ready_frames_edited_after_marking_show_as_changed_until_resolved_or_completed() { let mut s = Session::new(); let a = frame(&mut s, 0.0, "Login"); let b = frame(&mut s, 300.0, "Signup"); let r = rect(&mut s, a, 20.0); let outside = idv(&ex(&mut s, "shape.rectangle", json!({"x": 900, "y": 0, "width": 10, "height": 10}))); status(&mut s, &[a, b], "readyForDev"); let changed = |s: &mut Session| names(&ex(s, "inspect.readyForDev", json!({"status": "changed"}))); assert!(changed(&mut s).is_empty(), "marking isn't a change"); // Edits elsewhere don't count; an edit inside does. ex(&mut s, "node.set", json!({"id": outside, "props": {"width": 30}})); assert!(changed(&mut s).is_empty()); ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); assert_eq!(changed(&mut s), vec!["Login"]); let items = ex(&mut s, "inspect.readyForDev", json!({})); assert_eq!(items["items"][0]["changed"], true, "the list says which changed: {items}"); assert_eq!(items["items"][1]["changed"], false); // Still ready for dev (the status itself stays). assert_eq!(names(&items), vec!["Login", "Signup"]); // Resolving goes back to plain ready; so does marking it again, or completing it. ex(&mut s, "dev.resolveChange", json!({"ids": [a]})); assert!(changed(&mut s).is_empty()); ex(&mut s, "node.set", json!({"id": r, "props": {"width": 70}})); status(&mut s, &[a], "completed"); assert!(changed(&mut s).is_empty()); let all = ex(&mut s, "inspect.readyForDev", json!({"status": "any"})); assert_eq!(all["items"][0]["changed"], false, "a completed frame isn't changed: {all}"); // Completed frames don't turn changed when edited. ex(&mut s, "node.set", json!({"id": r, "props": {"width": 80}})); assert!(!s.doc().unwrap().node(a).unwrap().dev_changed); } #[test] fn the_focus_view_steps_through_what_is_ready_and_skips_completed_frames() { let mut s = Session::new(); let a = frame(&mut s, 0.0, "Login"); let b = frame(&mut s, 300.0, "Signup"); let c = frame(&mut s, 600.0, "Profile"); let r = rect(&mut s, c, 620.0); status(&mut s, &[a, c], "readyForDev"); status(&mut s, &[b], "completed"); ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); let step = |s: &mut Session, d: &str| ex(s, "dev.focus", json!({"direction": d})); let v = ex(&mut s, "dev.focus", json!({"id": a})); assert_eq!((v["index"].clone(), v["count"].clone()), (json!(0), json!(2))); assert_eq!(step(&mut s, "next")["id"], json!(c), "completed ones are skipped, changed ones aren't"); assert_eq!(step(&mut s, "previous")["id"], json!(a)); // Any top-level frame opens in it, even one that's completed. assert_eq!(ex(&mut s, "dev.focus", json!({"id": b}))["index"], json!(null)); } #[test] fn a_drag_under_way_when_dev_mode_starts_moves_nothing() { let mut s = Session::new(); let f = frame(&mut s, 0.0, "Screen"); let r = rect(&mut s, f, 20.0); ex(&mut s, "select.set", json!({"ids": [r]})); let before = s.doc().unwrap().world_bounds(r); s.pointer(&ev(PointerKind::Down, 40.0, 40.0)).unwrap(); s.pointer(&ev(PointerKind::Move, 90.0, 40.0)).unwrap(); s.drop_gesture().unwrap(); s.tool.dev_mode = true; s.pointer(&ev(PointerKind::Move, 120.0, 40.0)).unwrap(); s.pointer(&ev(PointerKind::Up, 120.0, 40.0)).unwrap(); assert_eq!(s.doc().unwrap().world_bounds(r), before, "the drag was dropped, not committed"); }