import { describe, expect, it } from "vitest"; import { buildEditHistoryEntry, canApplyEditHistoryEntry, createEmptyEditHistory, hashEditHistoryContent, pushEditHistoryEntry, redoEditHistory, undoEditHistory, } from "./editHistory"; describe("edit history", () => { it("pushes changed file snapshots onto undo and clears redo", () => { const state = createEmptyEditHistory(); const entry = buildEditHistoryEntry({ projectId: "project-1", label: "Move layer", files: { "index.html": { before: '
', after: '
', }, }, now: 100, id: "entry-1", }); const withUndo = pushEditHistoryEntry(state, entry); const redoEntry = buildEditHistoryEntry({ projectId: "project-1", label: "Redoable edit", files: { "index.html": { before: '
', after: '
', }, }, now: 200, id: "redo-entry", }); const next = pushEditHistoryEntry( { ...withUndo, redo: [redoEntry], }, { ...entry, id: "entry-2", label: "Resize layer", createdAt: 300, }, ); expect(withUndo.undo).toHaveLength(1); expect(withUndo.redo).toHaveLength(0); expect(next.undo.map((item) => item.label)).toEqual(["Move layer", "Resize layer"]); expect(next.redo).toHaveLength(0); }); it("undo returns before contents and moves entry to redo", () => { const entry = buildEditHistoryEntry({ projectId: "project-1", label: "Move layer", files: { "index.html": { before: "before", after: "after" }, }, now: 100, id: "entry-1", }); const state = pushEditHistoryEntry(createEmptyEditHistory(), entry); const result = undoEditHistory(state, { "index.html": hashEditHistoryContent("after") }, 200); expect(result.ok).toBe(true); expect(result.filesToWrite).toEqual({ "index.html": "before" }); expect(result.state.undo).toHaveLength(0); expect(result.state.redo.map((item) => item.id)).toEqual(["entry-1"]); }); it("redo returns after contents and moves entry to undo", () => { const entry = buildEditHistoryEntry({ projectId: "project-1", label: "Move layer", files: { "index.html": { before: "before", after: "after" }, }, now: 100, id: "entry-1", }); const undone = undoEditHistory( pushEditHistoryEntry(createEmptyEditHistory(), entry), { "index.html": hashEditHistoryContent("after") }, 200, ).state; const result = redoEditHistory(undone, { "index.html": hashEditHistoryContent("before") }, 300); expect(result.ok).toBe(true); expect(result.filesToWrite).toEqual({ "index.html": "after" }); expect(result.state.undo.map((item) => item.id)).toEqual(["entry-1"]); expect(result.state.redo).toHaveLength(0); }); it("blocks undo when current content hash does not match the recorded after hash", () => { const entry = buildEditHistoryEntry({ projectId: "project-1", label: "Move layer", files: { "index.html": { before: "before", after: "after" }, }, now: 100, id: "entry-1", }); const state = pushEditHistoryEntry(createEmptyEditHistory(), entry); const result = undoEditHistory( state, { "index.html": hashEditHistoryContent("external") }, 200, ); expect(result.ok).toBe(false); expect(result.reason).toBe("content-mismatch"); expect(result.state).toBe(state); expect(result.filesToWrite).toEqual({}); }); it("can validate all files in a multi-file entry before applying", () => { const entry = buildEditHistoryEntry({ projectId: "project-1", label: "Update files", files: { "index.html": { before: "a", after: "b" }, "compositions/title.html": { before: "c", after: "d" }, }, now: 100, id: "entry-1", }); expect( canApplyEditHistoryEntry(entry, "undo", { "index.html": hashEditHistoryContent("b"), "compositions/title.html": hashEditHistoryContent("d"), }), ).toEqual({ ok: true }); expect( canApplyEditHistoryEntry(entry, "undo", { "index.html": hashEditHistoryContent("b"), "compositions/title.html": hashEditHistoryContent("external"), }), ).toEqual({ ok: false, reason: "content-mismatch", path: "compositions/title.html" }); }); it("prunes oldest undo entries when the limit is exceeded", () => { let state = createEmptyEditHistory({ maxEntries: 2 }); for (let index = 1; index <= 3; index += 1) { state = pushEditHistoryEntry( state, buildEditHistoryEntry({ projectId: "project-1", label: `Edit ${index}`, files: { "index.html": { before: `${index - 1}`, after: `${index}` }, }, now: index, id: `entry-${index}`, }), { maxEntries: 2 }, ); } expect(state.undo.map((entry) => entry.id)).toEqual(["entry-2", "entry-3"]); }); it("coalesces source editor edits for the same file inside the coalesce window", () => { const first = buildEditHistoryEntry({ projectId: "project-1", label: "Edit source", kind: "source", coalesceKey: "source:index.html", files: { "index.html": { before: "a", after: "b" }, }, now: 100, id: "entry-1", }); const second = buildEditHistoryEntry({ projectId: "project-1", label: "Edit source", kind: "source", coalesceKey: "source:index.html", files: { "index.html": { before: "b", after: "c" }, }, now: 300, id: "entry-2", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), first), second, { coalesceMs: 1000 }, ); expect(state.undo).toHaveLength(1); expect(state.undo[0].id).toBe("entry-2"); expect(state.undo[0].files["index.html"].before).toBe("a"); expect(state.undo[0].files["index.html"].after).toBe("c"); }); it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => { // The z entry records only after the move persist's round-trip — often >300ms. // Both sides pass coalesceMs: 5000 with the shared gesture key so the pair // still folds into ONE undo step. const move = buildEditHistoryEntry({ projectId: "project-1", label: "Move timeline clips", kind: "timeline", coalesceKey: "clip-lane-move:1", coalesceMs: 5000, files: { "index.html": { before: "a", after: "b" } }, now: 100, id: "move-entry", }); const zReorder = buildEditHistoryEntry({ projectId: "project-1", label: "Reorder layers", kind: "manual", coalesceKey: "clip-lane-move:1", coalesceMs: 5000, files: { "index.html": { before: "b", after: "c" } }, now: 500, id: "z-entry", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), move), zReorder, ); expect(state.undo).toHaveLength(1); expect(state.undo[0].files["index.html"].before).toBe("a"); expect(state.undo[0].files["index.html"].after).toBe("c"); }); it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => { const timing = buildEditHistoryEntry({ projectId: "project-1", label: "Resize timeline clip", kind: "timeline", coalesceKey: "timeline-resize:clip", files: { "index.html": { before: "orig", after: "timing" } }, now: 0, id: "timing", }); // The server GSAP rewrite lands ~2s later, past the 300ms default window, but the // follow-up carries a large coalesceMs so undo still collapses to a single step. const gsap = buildEditHistoryEntry({ projectId: "project-1", label: "Resize timeline clip", kind: "timeline", coalesceKey: "timeline-resize:clip", coalesceMs: 10_000, files: { "index.html": { before: "timing", after: "timing+gsap" } }, now: 2000, id: "gsap", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), timing), gsap, ); expect(state.undo).toHaveLength(1); expect(state.undo[0].files["index.html"].before).toBe("orig"); expect(state.undo[0].files["index.html"].after).toBe("timing+gsap"); }); it("keeps timing-only files when a multi-file GSAP follow-up touches one path", () => { const timing = buildEditHistoryEntry({ projectId: "project-1", label: "Move timeline clips", kind: "timeline", coalesceKey: "timeline-group-move:a,b", files: { "index.html": { before: "index-before", after: "index-timing" }, "scene.html": { before: "scene-before", after: "scene-timing" }, }, now: 0, id: "timing", }); const gsap = buildEditHistoryEntry({ projectId: "project-1", label: "Move timeline clips", kind: "timeline", coalesceKey: "timeline-group-move:a,b", coalesceMs: 10_000, files: { "index.html": { before: "index-timing", after: "index-timing+gsap" }, }, now: 2000, id: "gsap", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), timing), gsap, ); expect(state.undo).toHaveLength(1); expect(Object.keys(state.undo[0].files)).toEqual(["index.html", "scene.html"]); expect(state.undo[0].files).toMatchObject({ "index.html": { before: "index-before", after: "index-timing+gsap" }, "scene.html": { before: "scene-before", after: "scene-timing" }, }); }); it("keeps a discontinuous GSAP follow-up separate and preserves foreign bytes on undo", () => { const timing = buildEditHistoryEntry({ projectId: "project-1", label: "Move timeline clip", kind: "timeline", coalesceKey: "timeline-move:clip", files: { "index.html": { before: "A", after: "B" } }, now: 0, id: "timing", }); const gsapAfterForeignWrite = buildEditHistoryEntry({ projectId: "project-1", label: "Move timeline clip", kind: "timeline", coalesceKey: "timeline-move:clip", coalesceMs: 10_000, files: { "index.html": { before: "F", after: "G" } }, now: 2000, id: "gsap", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), timing), gsapAfterForeignWrite, ); expect(state.undo.map((entry) => entry.id)).toEqual(["timing", "gsap"]); const undoGsap = undoEditHistory(state, { "index.html": hashEditHistoryContent("G") }, 3000); expect(undoGsap.ok).toBe(true); expect(undoGsap.filesToWrite).toEqual({ "index.html": "F" }); const undoTiming = undoEditHistory( undoGsap.state, { "index.html": hashEditHistoryContent("F") }, 4000, ); expect(undoTiming).toMatchObject({ ok: false, reason: "content-mismatch", filesToWrite: {}, }); }); it("does not merge a slow follow-up without the coalesceMs override", () => { const timing = buildEditHistoryEntry({ projectId: "project-1", label: "Resize timeline clip", kind: "timeline", coalesceKey: "timeline-resize:clip", files: { "index.html": { before: "orig", after: "timing" } }, now: 0, id: "timing", }); const late = buildEditHistoryEntry({ projectId: "project-1", label: "Resize timeline clip", kind: "timeline", coalesceKey: "timeline-resize:clip", files: { "index.html": { before: "timing", after: "timing+gsap" } }, now: 2000, id: "late", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), timing), late, ); expect(state.undo).toHaveLength(2); }); it("coalesces entries with the same coalesceKey within the window (prop: format)", () => { const first = buildEditHistoryEntry({ projectId: "project-1", label: "Edit title color", kind: "source", coalesceKey: "prop:title.color", files: { "index.html": { before: "a", after: "b" }, }, now: 100, id: "entry-1", }); const second = buildEditHistoryEntry({ projectId: "project-1", label: "Edit title color", kind: "source", coalesceKey: "prop:title.color", files: { "index.html": { before: "b", after: "c" }, }, now: 200, id: "entry-2", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), first), second, { coalesceMs: 1000 }, ); expect(state.undo).toHaveLength(1); expect(state.undo[0].id).toBe("entry-2"); expect(state.undo[0].files["index.html"].before).toBe("a"); expect(state.undo[0].files["index.html"].after).toBe("c"); }); it("does not coalesce entries with different coalesceKeys (cross-prop separation)", () => { const titleEdit = buildEditHistoryEntry({ projectId: "project-1", label: "Edit title color", kind: "source", coalesceKey: "prop:title.color", files: { "index.html": { before: "a", after: "b" }, }, now: 100, id: "entry-title", }); const bodyEdit = buildEditHistoryEntry({ projectId: "project-1", label: "Edit body color", kind: "source", coalesceKey: "prop:body.color", files: { "index.html": { before: "b", after: "c" }, }, now: 200, id: "entry-body", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), titleEdit), bodyEdit, { coalesceMs: 1000 }, ); expect(state.undo.map((e) => e.id)).toEqual(["entry-title", "entry-body"]); }); it("does not coalesce source editor edits outside the coalesce window", () => { const first = buildEditHistoryEntry({ projectId: "project-1", label: "Edit source", kind: "source", coalesceKey: "source:index.html", files: { "index.html": { before: "a", after: "b" }, }, now: 100, id: "entry-1", }); const second = buildEditHistoryEntry({ projectId: "project-1", label: "Edit source", kind: "source", coalesceKey: "source:index.html", files: { "index.html": { before: "b", after: "c" }, }, now: 5000, id: "entry-2", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), first), second, { coalesceMs: 1000 }, ); expect(state.undo.map((entry) => entry.id)).toEqual(["entry-1", "entry-2"]); }); it("coalesces entries exactly at the coalesce boundary (delta === coalesceMs is inclusive)", () => { const first = buildEditHistoryEntry({ projectId: "project-1", label: "Edit source", kind: "source", coalesceKey: "source:index.html", files: { "index.html": { before: "a", after: "b" }, }, now: 100, id: "entry-1", }); const second = buildEditHistoryEntry({ projectId: "project-1", label: "Edit source", kind: "source", coalesceKey: "source:index.html", files: { "index.html": { before: "b", after: "c" }, }, now: 1100, // exactly coalesceMs=1000ms after first id: "entry-2", }); const state = pushEditHistoryEntry( pushEditHistoryEntry(createEmptyEditHistory(), first), second, { coalesceMs: 1000 }, ); // Boundary is <=: delta of exactly 1000ms coalesces into one entry. expect(state.undo).toHaveLength(1); expect(state.undo[0].id).toBe("entry-2"); expect(state.undo[0].files["index.html"].before).toBe("a"); expect(state.undo[0].files["index.html"].after).toBe("c"); }); it.todo("gesture-start/commit collapses intermediate drag steps into one undo entry"); it.todo( "origin:applyPatches edits are excluded from undo stack to prevent undo loops (requires SDK session)", ); });