mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 18:36:44 -06:00
- Compare changes: a ready-for-dev frame or section keeps a snapshot of itself from just before
its first change (Document::dev_snapshots, saved only while changed; dropped on resolve, re-mark,
other status or delete). `dev.compare {id?, show?}` diffs it layer by layer: added/removed
layers, and property changes formatted like Inspect (`width 120 → 136`, `fill #FF0000 →
#E03030`, text, parent, order, component, auto layout, typography, effects). The compare view
shows before / now side by side or overlaid with a slider, changed layers outlined, the list of
changes and Resolve change.
- Focus view: `dev.focus {id? | direction}` / `dev.unfocus`: one frame drawn and hit alone
(render Options::solo), a bar with name, status, Compare changes, previous / next ready frame and
exit; Escape leaves.
- Annotation categories: defaults plus the file's own with colours (`annotation.categories`,
`addCategory`, `editCategory`, `removeCategory`), `category` on annotations, coloured callouts,
a category filter and editor in the Inspect panel; stored backwards-compatibly.
- Shared widgets::icon_button / canvas_banner / dropdown_lead; has_status moved to the engine.
- Tests: devcompare_tests, devfocus_tests, annotation_categories_tests, doc handoff_tests, render
solo_tests, UI dev_mode_tests.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
15 KiB
Rust
275 lines
15 KiB
Rust
//! Dev Mode "Compare changes": snapshots of ready-for-dev frames and the layer-by-layer diff.
|
||
|
||
use crate::components::tests::{card, copy_of, ex, idv, node};
|
||
use crate::devcompare::{ChangeKind, Comparison, compare};
|
||
use crate::{EngineError, Session};
|
||
use figcraft_doc::*;
|
||
use serde_json::json;
|
||
|
||
/// A ready-for-dev "Screen" frame holding a "Title" text, a "Button" rectangle and a "Badge" group.
|
||
struct Screen {
|
||
frame: NodeId,
|
||
title: NodeId,
|
||
button: NodeId,
|
||
badge: NodeId,
|
||
dot: NodeId,
|
||
}
|
||
|
||
fn screen(s: &mut Session) -> Screen {
|
||
let frame = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": "Screen"})));
|
||
let title = idv(&ex(s, "text.create", json!({"text": "Welcome", "x": 20, "y": 20, "parent": frame, "props": {"name": "Title"}})));
|
||
let button = idv(&ex(
|
||
s,
|
||
"shape.rectangle",
|
||
json!({"x": 20, "y": 200, "width": 120, "height": 40, "parent": frame, "props": {"name": "Button", "fill": "#FF0000"}}),
|
||
));
|
||
let dot = idv(&ex(s, "shape.ellipse", json!({"x": 300, "y": 20, "width": 20, "height": 20, "parent": frame, "props": {"name": "Dot"}})));
|
||
let badge = idv(&ex(s, "object.group", json!({"ids": [dot], "name": "Badge"})));
|
||
ex(s, "node.set", json!({"id": frame, "props": {"devStatus": "readyForDev"}}));
|
||
Screen { frame, title, button, badge, dot }
|
||
}
|
||
|
||
fn texts(c: &Comparison, id: NodeId) -> Vec<String> {
|
||
c.changes.iter().filter(|l| l.id == id).flat_map(|l| l.properties.iter().map(|p| p.text.clone())).collect()
|
||
}
|
||
|
||
fn kind(c: &Comparison, id: NodeId) -> Option<ChangeKind> {
|
||
c.changes.iter().find(|l| l.id == id).map(|l| l.change)
|
||
}
|
||
|
||
#[test]
|
||
fn the_first_change_keeps_a_snapshot_until_resolved() {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
assert!(s.doc().unwrap().dev_snapshots.is_empty(), "marking takes no snapshot");
|
||
let c = compare(s.doc().unwrap(), sc.frame);
|
||
assert!(!c.changed && c.changes.is_empty());
|
||
// The first edit keeps what the frame was; later edits keep that snapshot.
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
|
||
let first = s.doc().unwrap().dev_snapshots.get(&sc.frame).cloned().expect("snapshot");
|
||
assert_eq!(first.root().map(|n| n.id), Some(sc.frame));
|
||
assert_eq!(first.node(sc.button).map(|n| n.width), Some(120.0));
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 150}}));
|
||
assert!(std::sync::Arc::ptr_eq(&first, &s.doc().unwrap().dev_snapshots[&sc.frame]), "later edits keep the first snapshot");
|
||
assert_eq!(texts(&compare(s.doc().unwrap(), sc.frame), sc.button), vec!["width 120 → 150"]);
|
||
// Undoing back to before the first change drops it with the change.
|
||
s.undo().unwrap();
|
||
s.undo().unwrap();
|
||
assert!(s.doc().unwrap().dev_snapshots.is_empty());
|
||
s.redo().unwrap();
|
||
assert!(s.doc().unwrap().dev_snapshots.contains_key(&sc.frame));
|
||
// Resolving, marking again or another status drop it; the next change starts from there.
|
||
ex(&mut s, "dev.resolveChange", json!({"ids": [sc.frame]}));
|
||
assert!(s.doc().unwrap().dev_snapshots.is_empty());
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"height": 48}}));
|
||
assert_eq!(texts(&compare(s.doc().unwrap(), sc.frame), sc.button), vec!["height 40 → 48"], "compared with the resolved state");
|
||
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"devStatus": "readyForDev"}}));
|
||
assert!(s.doc().unwrap().dev_snapshots.is_empty(), "marked again");
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"height": 50}}));
|
||
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"devStatus": "completed"}}));
|
||
assert!(s.doc().unwrap().dev_snapshots.is_empty(), "completed");
|
||
// Deleting the frame drops its snapshot too.
|
||
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"devStatus": "readyForDev"}}));
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"height": 52}}));
|
||
assert_eq!(s.doc().unwrap().dev_snapshots.len(), 1);
|
||
ex(&mut s, "edit.delete", json!({"ids": [sc.frame]}));
|
||
assert!(s.doc().unwrap().dev_snapshots.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn property_changes_read_like_inspect() {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136, "fill": "#E03030", "name": "Primary", "cornerRadius": 8}}));
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"opacity": 0.5, "x": 30}}));
|
||
ex(&mut s, "text.set", json!({"id": sc.title, "characters": "Hello there"}));
|
||
ex(&mut s, "node.set", json!({"id": sc.title, "props": {"fontSize": 24}}));
|
||
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"fills": ["#F0F0F0"]}}));
|
||
let c = compare(s.doc().unwrap(), sc.frame);
|
||
assert!(c.changed && c.snapshot && !c.truncated);
|
||
let button = texts(&c, sc.button);
|
||
for want in ["name Button → Primary", "width 120 → 136", "left 20 → 30", "opacity 100% → 50%", "corner radius 0 → 8", "fill #FF0000 → #E03030"]
|
||
{
|
||
assert!(button.contains(&want.to_string()), "{want} in {button:?}");
|
||
}
|
||
let title = texts(&c, sc.title);
|
||
assert!(title.contains(&"text “Welcome” → “Hello there”".to_string()), "{title:?}");
|
||
assert!(title.iter().any(|t| t.starts_with("font size") && t.ends_with("→ 24")), "{title:?}");
|
||
assert!(texts(&c, sc.frame).contains(&"fill #FFFFFF → #F0F0F0".to_string()));
|
||
assert_eq!(kind(&c, sc.badge), None, "untouched layers aren't listed");
|
||
assert_eq!((c.added, c.removed, c.modified), (0, 0, 3));
|
||
// Sections and levels are reported with each change.
|
||
let fill = c.changes.iter().find(|l| l.id == sc.button).and_then(|l| l.properties.iter().find(|p| p.property == "fill")).unwrap();
|
||
assert_eq!((fill.section, fill.before.as_str(), fill.after.as_str()), ("Fills", "#FF0000", "#E03030"));
|
||
assert_eq!(c.changes.iter().find(|l| l.id == sc.button).map(|l| l.depth), Some(1));
|
||
// Auto layout and effects.
|
||
ex(&mut s, "layout.add", json!({"ids": [sc.frame]}));
|
||
ex(&mut s, "node.set", json!({"id": sc.dot, "props": {"effects": [{"type": "dropShadow"}]}}));
|
||
let c = compare(s.doc().unwrap(), sc.frame);
|
||
assert!(texts(&c, sc.frame).iter().any(|t| t.starts_with("auto layout none → ")), "{:?}", texts(&c, sc.frame));
|
||
assert!(texts(&c, sc.dot).iter().any(|t| t.starts_with("drop shadow none → ")), "{:?}", texts(&c, sc.dot));
|
||
}
|
||
|
||
#[test]
|
||
fn added_removed_moved_and_reordered_layers() {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
let extra = idv(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 100, "width": 10, "height": 10, "parent": sc.frame})));
|
||
let wrap = idv(&ex(&mut s, "object.group", json!({"ids": [extra], "name": "New group"})));
|
||
ex(&mut s, "edit.delete", json!({"ids": [sc.title]}));
|
||
// The dot leaves its group (the group, emptied, disappears) for the frame.
|
||
ex(&mut s, "node.reparent", json!({"ids": [sc.dot], "parent": sc.frame}));
|
||
let c = compare(s.doc().unwrap(), sc.frame);
|
||
assert_eq!(kind(&c, wrap), Some(ChangeKind::Added));
|
||
assert_eq!(c.changes.iter().find(|l| l.id == wrap).map(|l| l.layers), Some(2), "the group and its rectangle");
|
||
assert_eq!(kind(&c, extra), None, "listed with its group");
|
||
assert_eq!(kind(&c, sc.title), Some(ChangeKind::Removed));
|
||
assert_eq!(kind(&c, sc.badge), Some(ChangeKind::Removed));
|
||
assert!(texts(&c, sc.dot).contains(&"parent Badge → Screen".to_string()), "{:?}", texts(&c, sc.dot));
|
||
// Removed layers come from the snapshot, with their names.
|
||
let removed = c.changes.iter().find(|l| l.id == sc.title).unwrap();
|
||
assert_eq!((removed.name.as_str(), removed.type_name), ("Title", "TEXT"));
|
||
// Reordering among the layers both versions have.
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
ex(&mut s, "arrange.front", json!({"ids": [sc.title]}));
|
||
let c = compare(s.doc().unwrap(), sc.frame);
|
||
assert!(texts(&c, sc.title).iter().any(|t| t.starts_with("order ") && t.ends_with("→ 1")), "{:?}", texts(&c, sc.title));
|
||
}
|
||
|
||
#[test]
|
||
fn nested_and_instance_changes_are_found() {
|
||
let mut s = Session::new();
|
||
let (c, bg, title, _dot) = card(&mut s);
|
||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 500, "y": 0, "width": 400, "height": 300, "name": "Screen"})));
|
||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 10, "parent": frame})));
|
||
let group_child = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 10, "height": 10, "parent": frame})));
|
||
let outer = idv(&ex(&mut s, "object.group", json!({"ids": [group_child], "name": "Outer"})));
|
||
let _inner = idv(&ex(&mut s, "object.group", json!({"ids": [group_child], "name": "Inner"})));
|
||
ex(&mut s, "node.set", json!({"id": frame, "props": {"devStatus": "readyForDev"}}));
|
||
// A main component edit reaches the instance in the ready frame.
|
||
ex(&mut s, "node.set", json!({"id": bg, "props": {"fill": "#0000FF"}}));
|
||
assert!(node(&s, frame).dev_changed, "the instance changed");
|
||
let ibg = copy_of(&s, inst, bg);
|
||
let cmp = compare(s.doc().unwrap(), frame);
|
||
assert!(texts(&cmp, ibg).contains(&"fill #EEEEEE → #0000FF".to_string()), "{:?}", cmp.changes);
|
||
// An override inside the instance, and a change deep in nested groups.
|
||
let ititle = copy_of(&s, inst, title);
|
||
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Inbox"}));
|
||
ex(&mut s, "node.set", json!({"id": group_child, "props": {"fill": "#00FF00"}}));
|
||
let cmp = compare(s.doc().unwrap(), frame);
|
||
assert!(texts(&cmp, ititle).contains(&"text “Hello” → “Inbox”".to_string()), "{:?}", cmp.changes);
|
||
let deep = cmp.changes.iter().find(|l| l.id == group_child).expect("nested change");
|
||
assert_eq!(deep.depth, 3, "frame › Outer › Inner › layer");
|
||
assert!(cmp.changes.iter().all(|l| l.id != outer), "the groups themselves didn't change");
|
||
// Swapping the instance's component is reported by name.
|
||
let (c2, ..) = card(&mut s);
|
||
ex(&mut s, "node.set", json!({"id": c2, "props": {"name": "Card 2"}}));
|
||
ex(&mut s, "instance.swap", json!({"ids": [inst], "component": c2}));
|
||
let cmp = compare(s.doc().unwrap(), frame);
|
||
assert!(texts(&cmp, inst).contains(&"component Card → Card 2".to_string()), "{:?}", texts(&cmp, inst));
|
||
}
|
||
|
||
#[test]
|
||
fn the_compare_command_and_its_errors() {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
let v = ex(&mut s, "dev.compare", json!({"id": sc.frame}));
|
||
assert_eq!((v["changed"].clone(), v["changes"].clone()), (json!(false), json!([])));
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"fill": "#E03030"}}));
|
||
ex(&mut s, "select.set", json!({"ids": [sc.frame]}));
|
||
s.ui_requests.clear();
|
||
let v = ex(&mut s, "dev.compare", json!({"show": true}));
|
||
assert_eq!(v["snapshot"], json!(true));
|
||
assert_eq!(v["changes"][0]["id"], json!(sc.button));
|
||
assert_eq!(v["changes"][0]["change"], json!("changed"));
|
||
assert_eq!(v["changes"][0]["type"], json!("RECTANGLE"));
|
||
assert_eq!(v["changes"][0]["properties"][0]["text"], json!("fill #FF0000 → #E03030"));
|
||
assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["id"] == json!(sc.frame)));
|
||
// Only frames and sections marked ready for dev.
|
||
for p in [json!({"id": sc.button}), json!({"id": 987_654}), json!({"ids": []})] {
|
||
assert!(s.execute("dev.compare", &p).is_err(), "{p}");
|
||
}
|
||
let other = idv(&ex(&mut s, "frame.create", json!({"x": 900, "y": 0, "width": 10, "height": 10})));
|
||
assert!(s.execute("dev.compare", &json!({"id": other})).is_err(), "no status");
|
||
// Changed before snapshots were kept: compared as unknown, not as unchanged.
|
||
let doc = s.doc_mut().unwrap();
|
||
doc.dev_snapshots.clear();
|
||
let v = ex(&mut s, "dev.compare", json!({"id": sc.frame}));
|
||
assert_eq!((v["changed"].clone(), v["snapshot"].clone()), (json!(true), json!(false)));
|
||
}
|
||
|
||
#[test]
|
||
fn snapshots_are_saved_and_old_files_open() {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
let plain = figcraft_format::to_json(s.doc().unwrap()).unwrap();
|
||
assert!(plain.get("devSnapshots").is_none() && plain.get("dev_snapshots").is_none(), "nothing written without a change");
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
|
||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||
assert!(issues.is_empty(), "{issues:?}");
|
||
assert_eq!(back.dev_snapshots, s.doc().unwrap().dev_snapshots);
|
||
assert_eq!(texts(&compare(&back, sc.frame), sc.button), vec!["width 120 → 136"]);
|
||
// A file from before snapshots (the field missing) still opens.
|
||
let mut v = figcraft_format::to_json(&back).unwrap();
|
||
if let Some(o) = v.as_object_mut() {
|
||
o.retain(|k, _| !k.to_lowercase().contains("snapshot"));
|
||
}
|
||
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
|
||
assert!(old.dev_snapshots.is_empty());
|
||
assert!(!compare(&old, sc.frame).snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn damaged_snapshots_are_ignored() {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
|
||
let page = s.page().unwrap();
|
||
let doc = s.doc().unwrap().clone();
|
||
let good = doc.dev_snapshots[&sc.frame].clone();
|
||
let damaged = [
|
||
DevSnapshot { nodes: vec![] },
|
||
DevSnapshot { nodes: good.nodes.iter().skip(1).cloned().collect() },
|
||
DevSnapshot { nodes: good.nodes.iter().cloned().chain(doc.node_arc(page).cloned()).collect() },
|
||
];
|
||
for (k, snap) in damaged.into_iter().enumerate() {
|
||
let mut d = doc.clone();
|
||
d.dev_snapshots.insert(sc.frame, std::sync::Arc::new(snap));
|
||
let c = compare(&d, sc.frame);
|
||
if k < 2 {
|
||
assert!(!c.snapshot, "{k}");
|
||
}
|
||
// A page claimed by the snapshot isn't put in the copy.
|
||
if let Some(copy) = d.with_dev_snapshot(sc.frame) {
|
||
assert!(copy.node(page).is_some_and(|p| p.is_page()));
|
||
}
|
||
// Repairing (as after loading) drops snapshots that don't start with their layer.
|
||
d.repair();
|
||
assert_eq!(d.dev_snapshots.contains_key(&sc.frame), k == 2, "{k}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compare_commands_survive_junk() {
|
||
let junk = [
|
||
json!({}),
|
||
json!(null),
|
||
json!({"id": "x", "show": [1], "direction": 5}),
|
||
json!({"ids": [-1, "0:0"], "id": 1e300, "show": "yes"}),
|
||
json!({"id": {"a": 1}, "direction": "sideways"}),
|
||
];
|
||
for cmd in ["dev.compare", "dev.focus", "dev.unfocus", "dev.resolveChange"] {
|
||
for j in &junk {
|
||
let mut s = Session::new();
|
||
let sc = screen(&mut s);
|
||
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
|
||
ex(&mut s, "select.set", json!({"ids": [sc.frame]}));
|
||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
|
||
panic!("{cmd} panicked on {j}: {msg}");
|
||
}
|
||
}
|
||
}
|
||
}
|