From e0473a01d7d2b323f0d6e6902b2993395f02f014 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Fri, 10 Apr 2026 05:55:38 +0700 Subject: [PATCH] feat: add flow comparison to diff two .drawd files (#28) Add "Compare Flows" to the file menu that lets users select a .drawd file and see a structured diff against the current canvas -- added, removed, and modified entities across all categories with field-level change details. --- src/Drawd.jsx | 33 +++ src/components/FlowDiffModal.jsx | 364 ++++++++++++++++++++++++++++++ src/components/ModalsLayer.jsx | 11 + src/components/TopBar.jsx | 15 +- src/utils/diffFlows.js | 310 +++++++++++++++++++++++++ src/utils/diffFlows.test.js | 374 +++++++++++++++++++++++++++++++ 6 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 src/components/FlowDiffModal.jsx create mode 100644 src/utils/diffFlows.js create mode 100644 src/utils/diffFlows.test.js diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 821d244..3377988 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -34,6 +34,8 @@ import { CollabPresence } from "./components/CollabPresence"; import { CollabBadge } from "./components/CollabBadge"; import { importFlow } from "./utils/importFlow"; import { detectDrawdFile, findDrawdItem } from "./utils/detectDrawdFile"; +import { diffFlows } from "./utils/diffFlows"; +import { buildPayload } from "./utils/buildPayload"; export default function Drawd({ initialRoomCode }) { @@ -198,6 +200,7 @@ export default function Drawd({ initialRoomCode }) { const [showShortcuts, setShowShortcuts] = useState(false); const [formSummaryScreen, setFormSummaryScreen] = useState(null); const [showTemplateBrowser, setShowTemplateBrowser] = useState(false); + const [flowDiffResult, setFlowDiffResult] = useState(null); // ── Template inserter ───────────────────────────────────────────────── const { insertTemplate } = useTemplateInserter({ @@ -210,6 +213,33 @@ export default function Drawd({ initialRoomCode }) { setShowTemplateBrowser(false); }, [insertTemplate]); + // ── Flow comparison ─────────────────────────────────────────────────── + const onCompareFlows = useCallback(async () => { + if (!isFileSystemSupported) return; + try { + const [handle] = await window.showOpenFilePicker({ + types: [{ + description: "Drawd files", + accept: { "application/json": [".drawd", ".drawd.json"] }, + }], + multiple: false, + }); + const file = await handle.getFile(); + const text = await file.text(); + const baseFlow = importFlow(text); + const currentFlow = buildPayload( + screens, connections, pan, zoom, documents, + featureBrief, taskLink, techStack, + dataModels, stickyNotes, screenGroups, comments, + ); + const diff = diffFlows(baseFlow, currentFlow); + setFlowDiffResult({ diff, fileName: file.name }); + } catch (err) { + if (err.name === "AbortError") return; + console.error("Compare failed:", err); + } + }, [isFileSystemSupported, screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, comments]); + // ── Instruction generation ───────────────────────────────────────────── const { instructions, showInstructions, setShowInstructions, onGenerate, buildInstructionResult } = useInstructionGeneration({ @@ -494,6 +524,7 @@ export default function Drawd({ initialRoomCode }) { onToggleParticipants={() => setShowParticipants((v) => !v)} showParticipants={showParticipants} onTemplates={onTemplates} + onCompareFlows={onCompareFlows} canComment={canComment} showComments={showComments} onToggleComments={() => setShowComments((v) => !v)} @@ -733,6 +764,8 @@ export default function Drawd({ initialRoomCode }) { showTemplateBrowser={showTemplateBrowser} setShowTemplateBrowser={setShowTemplateBrowser} onInsertTemplate={onInsertTemplate} + flowDiffResult={flowDiffResult} + setFlowDiffResult={setFlowDiffResult} showComments={showComments} setShowComments={setShowComments} comments={comments} diff --git a/src/components/FlowDiffModal.jsx b/src/components/FlowDiffModal.jsx new file mode 100644 index 0000000..1c84062 --- /dev/null +++ b/src/components/FlowDiffModal.jsx @@ -0,0 +1,364 @@ +import { useState } from "react"; +import { COLORS, FONTS, styles } from "../styles/theme"; + +const CATEGORY_LABELS = { + screens: "Screens", + connections: "Connections", + documents: "Documents", + dataModels: "Data Models", + stickyNotes: "Sticky Notes", + screenGroups: "Screen Groups", +}; + +function Chevron({ expanded }) { + return ( + + ▶ + + ); +} + +function SummaryBadge({ label, count, color }) { + if (count === 0) return null; + return ( + + {label} {count} + + ); +} + +function ChangeItem({ name, type, color, changes, expanded, onToggle }) { + const isModified = type === "modified"; + const prefix = type === "added" ? "+" : type === "removed" ? "\u2212" : "~"; + + return ( +
+
+ + {prefix} + + {name} + {isModified && ( + + {changes.length} change{changes.length !== 1 ? "s" : ""} + {" "} + + + )} +
+ + {isModified && expanded && ( +
+ {changes.map((change, i) => ( + + ))} +
+ )} +
+ ); +} + +function ChangeDetail({ change }) { + // Hotspot sub-diff + if (change.field === "hotspots") { + const parts = []; + if (change.addedCount > 0) parts.push(`+${change.addedCount} added`); + if (change.removedCount > 0) parts.push(`${change.removedCount} removed`); + if (change.modifiedCount > 0) parts.push(`${change.modifiedCount} modified`); + + return ( +
+
+ hotspots + {": "} + {parts.join(", ")} +
+ {change.details && change.details.map((d, i) => ( +
+ + {d.type === "added" ? "+" : d.type === "removed" ? "\u2212" : "~"} + + {" "} + {d.label} + {d.changes && ( + + {" "}({d.changes.map((c) => c.field).join(", ")}) + + )} +
+ ))} +
+ ); + } + + // Regular field change + return ( +
+ {change.field} + {": "} + {change.from} + {" \u2192 "} + {change.to} +
+ ); +} + +function CategorySection({ label, data, expandedItems, onToggleItem }) { + const [expanded, setExpanded] = useState(true); + const changeCount = data.added.length + data.removed.length + data.modified.length; + + if (changeCount === 0) return null; + + return ( +
+
setExpanded((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "6px 0", + cursor: "pointer", + userSelect: "none", + }} + > + + + {label} + + + ({changeCount} change{changeCount !== 1 ? "s" : ""}) + +
+ + {expanded && ( +
+ {data.added.map((item) => ( + + ))} + {data.removed.map((item) => ( + + ))} + {data.modified.map((item) => ( + onToggleItem(item.id)} + /> + ))} +
+ )} +
+ ); +} + +export function FlowDiffModal({ diffResult, baseFileName, onClose }) { + const [expandedItems, setExpandedItems] = useState(new Set()); + const { summary, categories, metadata } = diffResult; + + const totalChanges = summary.added + summary.removed + summary.modified; + const hasMetadataChanges = metadata.modified.length > 0; + + const toggleItem = (id) => { + setExpandedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
e.stopPropagation()} + style={{ + ...styles.modalCard, + width: 560, + maxHeight: "80vh", + display: "flex", + flexDirection: "column", + padding: 0, + }} + > + {/* Header */} +
+

+ Flow Comparison +

+
+ Comparing against: {baseFileName} +
+ + {/* Summary bar */} +
+ + + + {summary.unchanged > 0 && ( + + {summary.unchanged} unchanged + + )} +
+
+ + {/* Content */} +
+ {totalChanges === 0 && !hasMetadataChanges ? ( +
+ No differences found. The flows are identical. +
+ ) : ( + <> + {hasMetadataChanges && ( +
+
+ Metadata +
+
+ {metadata.modified.map((change, i) => ( + + ))} +
+
+ )} + + {Object.entries(categories).map(([key, data]) => ( + + ))} + + )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/src/components/ModalsLayer.jsx b/src/components/ModalsLayer.jsx index b24ef39..ea1b51a 100644 --- a/src/components/ModalsLayer.jsx +++ b/src/components/ModalsLayer.jsx @@ -14,6 +14,7 @@ import { FormSummaryPanel } from "./FormSummaryPanel"; import { TemplateBrowserModal } from "./TemplateBrowserModal"; import { CommentsPanel } from "./CommentsPanel"; import { CommentComposer } from "./CommentComposer"; +import { FlowDiffModal } from "./FlowDiffModal"; export function ModalsLayer({ // Hotspot modal @@ -48,6 +49,8 @@ export function ModalsLayer({ formSummaryScreen, setFormSummaryScreen, // Template browser showTemplateBrowser, setShowTemplateBrowser, onInsertTemplate, + // Flow diff + flowDiffResult, setFlowDiffResult, // Comments showComments, setShowComments, comments, connections, @@ -265,6 +268,14 @@ export function ModalsLayer({ onCancel={() => setCommentComposer(null)} /> )} + + {flowDiffResult && ( + setFlowDiffResult(null)} + /> + )} ); } diff --git a/src/components/TopBar.jsx b/src/components/TopBar.jsx index 39313d0..389b8e2 100644 --- a/src/components/TopBar.jsx +++ b/src/components/TopBar.jsx @@ -102,7 +102,7 @@ function ShareIcon() { ); } -export function TopBar({ screenCount, connectionCount, onExport, onImport, onGenerate, canUndo, canRedo, onUndo, onRedo, connectedFileName, saveStatus, isFileSystemSupported, onNew, onOpen, onSaveAs, onDocuments, documentCount = 0, onDataModels, dataModelCount = 0, collabState, onShare, collabBadge, collabPresence, onToggleParticipants, showParticipants, onTemplates, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) { +export function TopBar({ screenCount, connectionCount, onExport, onImport, onGenerate, canUndo, canRedo, onUndo, onRedo, connectedFileName, saveStatus, isFileSystemSupported, onNew, onOpen, onSaveAs, onDocuments, documentCount = 0, onDataModels, dataModelCount = 0, collabState, onShare, collabBadge, collabPresence, onToggleParticipants, showParticipants, onTemplates, onCompareFlows, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) { const [fileMenuOpen, setFileMenuOpen] = useState(false); const fileMenuRef = useRef(null); @@ -430,6 +430,19 @@ export function TopBar({ screenCount, connectionCount, onExport, onImport, onGen > Export + + {isFileSystemSupported && ( + <> +
+ + + )}
)} diff --git a/src/utils/diffFlows.js b/src/utils/diffFlows.js new file mode 100644 index 0000000..66ad5e1 --- /dev/null +++ b/src/utils/diffFlows.js @@ -0,0 +1,310 @@ +/** + * Compares two normalized .drawd flow payloads and returns a structured diff + * describing added, removed, and modified entities across all categories. + * + * Both flows should be normalized through importFlow() before diffing to + * ensure consistent field defaults and avoid false positives. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Cheap fingerprint for large strings (imageData, svgContent, sourceHtml). + * Avoids multi-MB string comparisons by checking length + boundary chars. + */ +function fingerprint(str) { + if (!str) return ""; + return `${str.length}:${str.slice(0, 32)}:${str.slice(-32)}`; +} + +/** + * Formats a value for human-readable display in change details. + */ +function displayValue(val) { + if (val === null || val === undefined) return "(none)"; + if (val === "") return "(empty)"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "string" && val.length > 60) return val.slice(0, 57) + "..."; + if (typeof val === "object") { + const json = JSON.stringify(val); + return json.length > 60 ? json.slice(0, 57) + "..." : json; + } + return String(val); +} + +/** + * Compare two values. For arrays/objects, uses JSON serialization. + * For fingerprint fields, compares fingerprints instead of full content. + */ +function valuesEqual(a, b, useFp) { + if (useFp) return fingerprint(a) === fingerprint(b); + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (typeof a === "object" || typeof b === "object") { + return JSON.stringify(a) === JSON.stringify(b); + } + return false; +} + +/** + * Diff two entities field-by-field. + * @param {Object} a - Entity from flow A + * @param {Object} b - Entity from flow B + * @param {string[]} fields - Fields to compare with strict/JSON equality + * @param {string[]} fpFields - Fields to compare with fingerprinting + * @param {string[]} existFields - Fields to compare by existence (truthy vs falsy) + * @returns {Array|null} Array of { field, from, to } changes, or null if identical + */ +function diffEntity(a, b, fields, fpFields = [], existFields = []) { + const changes = []; + + for (const f of fields) { + if (!valuesEqual(a[f], b[f], false)) { + changes.push({ field: f, from: displayValue(a[f]), to: displayValue(b[f]) }); + } + } + + for (const f of fpFields) { + if (!valuesEqual(a[f], b[f], true)) { + const fromLabel = a[f] ? "present" : "(none)"; + const toLabel = b[f] ? "changed" : "(none)"; + changes.push({ field: f, from: fromLabel, to: toLabel }); + } + } + + for (const f of existFields) { + const aHas = !!a[f]; + const bHas = !!b[f]; + if (aHas !== bHas) { + changes.push({ field: f, from: aHas ? "present" : "(none)", to: bHas ? "present" : "(none)" }); + } + } + + return changes.length > 0 ? changes : null; +} + +// --------------------------------------------------------------------------- +// Hotspot sub-diffing +// --------------------------------------------------------------------------- + +const HOTSPOT_FIELDS = [ + "label", "x", "y", "w", "h", + "action", "elementType", "interactionType", + "targetScreenId", "transitionType", "transitionLabel", + "apiEndpoint", "apiMethod", "requestSchema", "responseSchema", "documentId", + "customDescription", + "onSuccessAction", "onSuccessTargetId", "onSuccessCustomDesc", + "onErrorAction", "onErrorTargetId", "onErrorCustomDesc", + "tbd", "tbdNote", + "conditions", "dataFlow", "onSuccessDataFlow", "onErrorDataFlow", + "accessibility", "validation", +]; + +function diffHotspots(hotspotsA, hotspotsB) { + const mapA = new Map((hotspotsA || []).map((h) => [h.id, h])); + const mapB = new Map((hotspotsB || []).map((h) => [h.id, h])); + + let addedCount = 0; + let removedCount = 0; + let modifiedCount = 0; + const details = []; + + for (const [id, hs] of mapB) { + if (!mapA.has(id)) { + addedCount++; + details.push({ type: "added", id, label: hs.label || id }); + } + } + + for (const [id, hs] of mapA) { + if (!mapB.has(id)) { + removedCount++; + details.push({ type: "removed", id, label: hs.label || id }); + } + } + + for (const [id, hsA] of mapA) { + const hsB = mapB.get(id); + if (!hsB) continue; + const changes = diffEntity(hsA, hsB, HOTSPOT_FIELDS); + if (changes) { + modifiedCount++; + details.push({ type: "modified", id, label: hsA.label || id, changes }); + } + } + + if (addedCount === 0 && removedCount === 0 && modifiedCount === 0) return null; + return { field: "hotspots", addedCount, removedCount, modifiedCount, details }; +} + +// --------------------------------------------------------------------------- +// Collection diffing +// --------------------------------------------------------------------------- + +const SCREEN_FIELDS = [ + "name", "x", "y", "width", + "description", "notes", "status", "codeRef", + "tbd", "tbdNote", + "roles", "acceptanceCriteria", + "stateGroup", "stateName", +]; +const SCREEN_FP_FIELDS = ["imageData", "svgContent", "sourceHtml"]; +const SCREEN_EXIST_FIELDS = ["wireframe", "figmaSource"]; + +const CONNECTION_FIELDS = [ + "fromScreenId", "toScreenId", "hotspotId", + "label", "condition", "connectionPath", + "conditionGroupId", "transitionType", "transitionLabel", + "dataFlow", +]; + +const DOCUMENT_FIELDS = ["name", "content"]; +const DATA_MODEL_FIELDS = ["name", "schema"]; +const STICKY_NOTE_FIELDS = ["x", "y", "content", "color"]; +const SCREEN_GROUP_FIELDS = ["name", "screenIds", "color"]; + +/** + * Extracts a display name for an entity based on the category. + */ +function entityName(entity, category, flowScreens) { + if (category === "stickyNotes") { + const text = entity.content || ""; + return text.length > 40 ? text.slice(0, 37) + "..." : text || "(empty note)"; + } + if (category === "connections") { + const from = flowScreens?.get(entity.fromScreenId)?.name || entity.fromScreenId; + const to = flowScreens?.get(entity.toScreenId)?.name || entity.toScreenId; + return `${from} -> ${to}`; + } + return entity.name || entity.id; +} + +function diffCollection(collA, collB, category, fields, opts = {}) { + const { fpFields = [], existFields = [], includeHotspots = false, screenMapA, screenMapB } = opts; + + const mapA = new Map((collA || []).map((e) => [e.id, e])); + const mapB = new Map((collB || []).map((e) => [e.id, e])); + + const added = []; + const removed = []; + const modified = []; + let unchanged = 0; + + // Detect added (in B but not in A) + for (const [id, entity] of mapB) { + if (!mapA.has(id)) { + added.push({ id, name: entityName(entity, category, screenMapB) }); + } + } + + // Detect removed (in A but not in B) + for (const [id, entity] of mapA) { + if (!mapB.has(id)) { + removed.push({ id, name: entityName(entity, category, screenMapA) }); + } + } + + // Detect modified (in both, but different) + for (const [id, entityA] of mapA) { + const entityB = mapB.get(id); + if (!entityB) continue; + + const changes = diffEntity(entityA, entityB, fields, fpFields, existFields); + const hotspotDiff = includeHotspots ? diffHotspots(entityA.hotspots, entityB.hotspots) : null; + + if (changes || hotspotDiff) { + const allChanges = changes || []; + if (hotspotDiff) allChanges.push(hotspotDiff); + modified.push({ + id, + name: entityName(entityA, category, screenMapA), + changes: allChanges, + }); + } else { + unchanged++; + } + } + + return { added, removed, modified, unchanged }; +} + +// --------------------------------------------------------------------------- +// Metadata diffing +// --------------------------------------------------------------------------- + +const METADATA_FIELDS = ["name", "featureBrief", "taskLink", "techStack"]; + +function diffMetadata(metaA, metaB) { + const a = metaA || {}; + const b = metaB || {}; + const modified = []; + + for (const f of METADATA_FIELDS) { + if (!valuesEqual(a[f], b[f], false)) { + modified.push({ field: f, from: displayValue(a[f]), to: displayValue(b[f]) }); + } + } + + return { modified }; +} + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export function diffFlows(flowA, flowB) { + const screenMapA = new Map((flowA.screens || []).map((s) => [s.id, s])); + const screenMapB = new Map((flowB.screens || []).map((s) => [s.id, s])); + + const categories = { + screens: diffCollection( + flowA.screens, flowB.screens, "screens", + SCREEN_FIELDS, + { fpFields: SCREEN_FP_FIELDS, existFields: SCREEN_EXIST_FIELDS, includeHotspots: true, screenMapA, screenMapB }, + ), + connections: diffCollection( + flowA.connections, flowB.connections, "connections", + CONNECTION_FIELDS, + { screenMapA, screenMapB }, + ), + documents: diffCollection( + flowA.documents, flowB.documents, "documents", + DOCUMENT_FIELDS, + ), + dataModels: diffCollection( + flowA.dataModels, flowB.dataModels, "dataModels", + DATA_MODEL_FIELDS, + ), + stickyNotes: diffCollection( + flowA.stickyNotes, flowB.stickyNotes, "stickyNotes", + STICKY_NOTE_FIELDS, + ), + screenGroups: diffCollection( + flowA.screenGroups, flowB.screenGroups, "screenGroups", + SCREEN_GROUP_FIELDS, + ), + }; + + const metadata = diffMetadata(flowA.metadata, flowB.metadata); + + let added = 0; + let removed = 0; + let modified = 0; + let unchanged = 0; + + for (const cat of Object.values(categories)) { + added += cat.added.length; + removed += cat.removed.length; + modified += cat.modified.length; + unchanged += cat.unchanged; + } + + return { + summary: { added, removed, modified, unchanged }, + categories, + metadata, + }; +} diff --git a/src/utils/diffFlows.test.js b/src/utils/diffFlows.test.js new file mode 100644 index 0000000..b887aad --- /dev/null +++ b/src/utils/diffFlows.test.js @@ -0,0 +1,374 @@ +import { describe, it, expect } from "vitest"; +import { diffFlows } from "./diffFlows"; + +function makeFlow(overrides = {}) { + return { + version: 14, + metadata: { + name: "Test Flow", + exportedAt: new Date().toISOString(), + screenCount: 0, + connectionCount: 0, + documentCount: 0, + featureBrief: "", + taskLink: "", + techStack: {}, + }, + viewport: { pan: { x: 0, y: 0 }, zoom: 1 }, + screens: [], + connections: [], + documents: [], + dataModels: [], + stickyNotes: [], + screenGroups: [], + comments: [], + ...overrides, + }; +} + +function makeScreen(id, overrides = {}) { + return { + id, + name: `Screen ${id}`, + x: 0, + y: 0, + width: 220, + imageData: null, + description: "", + notes: "", + codeRef: "", + status: "new", + acceptanceCriteria: [], + roles: [], + tbd: false, + tbdNote: "", + stateGroup: null, + stateName: "", + figmaSource: null, + svgContent: null, + sourceHtml: null, + wireframe: null, + hotspots: [], + ...overrides, + }; +} + +function makeConnection(id, from, to, overrides = {}) { + return { + id, + fromScreenId: from, + toScreenId: to, + hotspotId: null, + label: "", + condition: "", + connectionPath: "default", + conditionGroupId: null, + transitionType: null, + transitionLabel: "", + dataFlow: [], + ...overrides, + }; +} + +function makeHotspot(id, overrides = {}) { + return { + id, + label: `Hotspot ${id}`, + x: 10, + y: 10, + w: 20, + h: 20, + action: "navigate", + elementType: "button", + interactionType: "tap", + targetScreenId: null, + transitionType: null, + transitionLabel: "", + apiEndpoint: "", + apiMethod: "", + requestSchema: "", + responseSchema: "", + documentId: null, + customDescription: "", + onSuccessAction: "", + onSuccessTargetId: "", + onSuccessCustomDesc: "", + onErrorAction: "", + onErrorTargetId: "", + onErrorCustomDesc: "", + tbd: false, + tbdNote: "", + conditions: [], + dataFlow: [], + onSuccessDataFlow: [], + onErrorDataFlow: [], + accessibility: null, + validation: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- + +describe("diffFlows", () => { + it("reports zero changes for identical flows", () => { + const flow = makeFlow({ + screens: [makeScreen("s1")], + connections: [makeConnection("c1", "s1", "s1")], + }); + + const result = diffFlows(flow, JSON.parse(JSON.stringify(flow))); + + expect(result.summary.added).toBe(0); + expect(result.summary.removed).toBe(0); + expect(result.summary.modified).toBe(0); + expect(result.summary.unchanged).toBe(2); // 1 screen + 1 connection + }); + + it("detects added screens", () => { + const flowA = makeFlow({ screens: [makeScreen("s1")] }); + const flowB = makeFlow({ screens: [makeScreen("s1"), makeScreen("s2")] }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.added).toHaveLength(1); + expect(result.categories.screens.added[0].id).toBe("s2"); + expect(result.summary.added).toBe(1); + }); + + it("detects removed screens", () => { + const flowA = makeFlow({ screens: [makeScreen("s1"), makeScreen("s2")] }); + const flowB = makeFlow({ screens: [makeScreen("s1")] }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.removed).toHaveLength(1); + expect(result.categories.screens.removed[0].id).toBe("s2"); + expect(result.summary.removed).toBe(1); + }); + + it("detects modified screen fields", () => { + const flowA = makeFlow({ screens: [makeScreen("s1", { name: "Login" })] }); + const flowB = makeFlow({ screens: [makeScreen("s1", { name: "Sign In" })] }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.modified).toHaveLength(1); + const mod = result.categories.screens.modified[0]; + expect(mod.id).toBe("s1"); + expect(mod.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "name", from: "Login", to: "Sign In" }), + ]), + ); + }); + + it("detects image changes via fingerprint", () => { + const imgA = "data:image/png;base64," + "A".repeat(1000); + const imgB = "data:image/png;base64," + "B".repeat(1000); + + const flowA = makeFlow({ screens: [makeScreen("s1", { imageData: imgA })] }); + const flowB = makeFlow({ screens: [makeScreen("s1", { imageData: imgB })] }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.modified).toHaveLength(1); + const changes = result.categories.screens.modified[0].changes; + expect(changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "imageData" }), + ]), + ); + }); + + it("treats identical images as unchanged", () => { + const img = "data:image/png;base64," + "X".repeat(500); + + const flowA = makeFlow({ screens: [makeScreen("s1", { imageData: img })] }); + const flowB = makeFlow({ screens: [makeScreen("s1", { imageData: img })] }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.modified).toHaveLength(0); + expect(result.categories.screens.unchanged).toBe(1); + }); + + it("detects hotspot additions within a screen", () => { + const flowA = makeFlow({ screens: [makeScreen("s1", { hotspots: [] })] }); + const flowB = makeFlow({ screens: [makeScreen("s1", { hotspots: [makeHotspot("h1")] })] }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.modified).toHaveLength(1); + const hotspotChange = result.categories.screens.modified[0].changes.find( + (c) => c.field === "hotspots", + ); + expect(hotspotChange).toBeDefined(); + expect(hotspotChange.addedCount).toBe(1); + }); + + it("detects hotspot removals within a screen", () => { + const flowA = makeFlow({ screens: [makeScreen("s1", { hotspots: [makeHotspot("h1")] })] }); + const flowB = makeFlow({ screens: [makeScreen("s1", { hotspots: [] })] }); + + const result = diffFlows(flowA, flowB); + + const hotspotChange = result.categories.screens.modified[0].changes.find( + (c) => c.field === "hotspots", + ); + expect(hotspotChange.removedCount).toBe(1); + }); + + it("detects hotspot modifications within a screen", () => { + const flowA = makeFlow({ + screens: [makeScreen("s1", { hotspots: [makeHotspot("h1", { label: "Login" })] })], + }); + const flowB = makeFlow({ + screens: [makeScreen("s1", { hotspots: [makeHotspot("h1", { label: "Sign In" })] })], + }); + + const result = diffFlows(flowA, flowB); + + const hotspotChange = result.categories.screens.modified[0].changes.find( + (c) => c.field === "hotspots", + ); + expect(hotspotChange.modifiedCount).toBe(1); + expect(hotspotChange.details[0].changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "label" }), + ]), + ); + }); + + it("detects connection changes", () => { + const flowA = makeFlow({ + screens: [makeScreen("s1"), makeScreen("s2")], + connections: [makeConnection("c1", "s1", "s2", { label: "Next" })], + }); + const flowB = makeFlow({ + screens: [makeScreen("s1"), makeScreen("s2")], + connections: [makeConnection("c1", "s1", "s2", { label: "Continue" })], + }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.connections.modified).toHaveLength(1); + expect(result.categories.connections.modified[0].changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "label", from: "Next", to: "Continue" }), + ]), + ); + }); + + it("detects document, dataModel, stickyNote, screenGroup changes", () => { + const flowA = makeFlow({ + documents: [{ id: "d1", name: "API Docs", content: "old", createdAt: "" }], + dataModels: [{ id: "dm1", name: "User", schema: { name: { type: "string" } }, createdAt: "" }], + stickyNotes: [{ id: "sn1", x: 0, y: 0, width: 220, content: "Todo", color: "yellow", author: "" }], + screenGroups: [{ id: "sg1", name: "Auth", screenIds: ["s1"], color: "#61afef", folderHint: "" }], + }); + const flowB = makeFlow({ + documents: [{ id: "d1", name: "API Reference", content: "new", createdAt: "" }], + dataModels: [{ id: "dm1", name: "Account", schema: { name: { type: "string" } }, createdAt: "" }], + stickyNotes: [{ id: "sn1", x: 0, y: 0, width: 220, content: "Done", color: "green", author: "" }], + screenGroups: [{ id: "sg1", name: "Authentication", screenIds: ["s1", "s2"], color: "#61afef", folderHint: "" }], + }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.documents.modified).toHaveLength(1); + expect(result.categories.dataModels.modified).toHaveLength(1); + expect(result.categories.stickyNotes.modified).toHaveLength(1); + expect(result.categories.screenGroups.modified).toHaveLength(1); + }); + + it("excludes viewport differences", () => { + const flowA = makeFlow({ viewport: { pan: { x: 0, y: 0 }, zoom: 1 } }); + const flowB = makeFlow({ viewport: { pan: { x: 999, y: -500 }, zoom: 3.5 } }); + + const result = diffFlows(flowA, flowB); + + expect(result.summary.added).toBe(0); + expect(result.summary.removed).toBe(0); + expect(result.summary.modified).toBe(0); + }); + + it("excludes metadata.exportedAt from diff", () => { + const flowA = makeFlow(); + const flowB = makeFlow(); + flowA.metadata.exportedAt = "2025-01-01T00:00:00Z"; + flowB.metadata.exportedAt = "2026-04-10T12:00:00Z"; + + const result = diffFlows(flowA, flowB); + + expect(result.metadata.modified).toHaveLength(0); + }); + + it("detects metadata field changes", () => { + const flowA = makeFlow(); + const flowB = makeFlow(); + flowA.metadata.featureBrief = "old brief"; + flowB.metadata.featureBrief = "new brief"; + + const result = diffFlows(flowA, flowB); + + expect(result.metadata.modified).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "featureBrief" }), + ]), + ); + }); + + it("summary counts match category totals", () => { + const flowA = makeFlow({ + screens: [makeScreen("s1"), makeScreen("s2"), makeScreen("s3")], + connections: [makeConnection("c1", "s1", "s2")], + documents: [{ id: "d1", name: "Doc", content: "", createdAt: "" }], + }); + const flowB = makeFlow({ + screens: [makeScreen("s1", { name: "Renamed" }), makeScreen("s3"), makeScreen("s4")], + connections: [], + documents: [{ id: "d1", name: "Doc", content: "", createdAt: "" }, { id: "d2", name: "New", content: "", createdAt: "" }], + }); + + const result = diffFlows(flowA, flowB); + + let totalAdded = 0; + let totalRemoved = 0; + let totalModified = 0; + let totalUnchanged = 0; + + for (const cat of Object.values(result.categories)) { + totalAdded += cat.added.length; + totalRemoved += cat.removed.length; + totalModified += cat.modified.length; + totalUnchanged += cat.unchanged; + } + + expect(result.summary.added).toBe(totalAdded); + expect(result.summary.removed).toBe(totalRemoved); + expect(result.summary.modified).toBe(totalModified); + expect(result.summary.unchanged).toBe(totalUnchanged); + }); + + it("detects wireframe/figmaSource existence changes", () => { + const flowA = makeFlow({ screens: [makeScreen("s1", { wireframe: null, figmaSource: null })] }); + const flowB = makeFlow({ + screens: [makeScreen("s1", { + wireframe: { type: "basic" }, + figmaSource: { fileKey: "abc", nodeId: "1:2" }, + })], + }); + + const result = diffFlows(flowA, flowB); + + expect(result.categories.screens.modified).toHaveLength(1); + const changes = result.categories.screens.modified[0].changes; + expect(changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "wireframe" }), + expect.objectContaining({ field: "figmaSource" }), + ]), + ); + }); +});