ai-chatbot/tests/routes/video-canvas-save-queue.test.ts

271 lines
8.5 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { CanvasRevisionConflict, VideoCanvasSaveQueue } from "@/lib/video-canvas/save-queue";
test("coalesces edits made during a save and uses the returned revision", async () => {
let release!: (value: string) => void;
const calls: string[][] = [];
const queue = new VideoCanvasSaveQueue(
"initial",
"r0",
async (value, revision) => {
calls.push([value, revision]);
return calls.length === 1
? new Promise<string>((resolve) => {
release = resolve;
})
: "r2";
},
() => {},
);
queue.update("first");
const pending = queue.flush();
queue.update("second");
queue.update("latest");
expect(queue.flush()).toBe(pending);
release("r1");
await pending;
expect(calls).toEqual([
["first", "r0"],
["latest", "r1"],
]);
expect(queue.dirty).toBe(false);
});
test("failed save retains edits and retries only the latest snapshot", async () => {
const calls: string[][] = [];
const queue = new VideoCanvasSaveQueue(
"initial",
"r0",
async (value, revision) => {
calls.push([value, revision]);
if (calls.length === 1) throw new Error("offline");
return "r1";
},
() => {},
);
queue.update("first");
await queue.flush();
expect(queue.dirty).toBe(true);
expect(queue.failed).toBe(true);
queue.update("latest");
await queue.flush();
expect(calls).toEqual([
["first", "r0"],
["latest", "r0"],
]);
expect(queue.dirty).toBe(false);
});
test("revision conflict cannot be retried as an overwrite", async () => {
let calls = 0;
const queue = new VideoCanvasSaveQueue(
"initial",
"r0",
async () => {
calls++;
throw new CanvasRevisionConflict("changed elsewhere");
},
() => {},
);
queue.update("first");
await queue.flush();
queue.update("latest");
await queue.flush();
expect(calls).toBe(1);
expect(queue.dirty).toBe(true);
});
test("unchanged canvas does not write", async () => {
let calls = 0;
const queue = new VideoCanvasSaveQueue(
"initial",
"r0",
async () => {
calls++;
return "r1";
},
() => {},
);
queue.update("initial");
await queue.flush();
expect(calls).toBe(0);
});
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
const snapshot = (nodes: any[], edges: any[] = []) =>
JSON.stringify({
title: "QA",
canvasData: {
nodes,
edges,
viewport: { x: 0, y: 0, zoom: 1 },
selectedNodeIds: [],
tool: "select",
},
});
const base = snapshot([
{ id: "a", title: "A", x: 0, draftValues: { text: "original" } },
{ id: "b", title: "B", x: 0 },
]);
test("merges independent node edits, additions and deletion without reviving removed edges", () => {
const local = snapshot([
{ id: "a", title: "A", x: 20, draftValues: { text: "original" } },
{ id: "b", title: "B", x: 0 },
{ id: "c", title: "C" },
]);
const remote = snapshot(
[
{ id: "a", title: "new A", x: 0, draftValues: { text: "original" } },
{ id: "d", title: "D" },
],
[{ id: "e", source: "a", target: "b" }],
);
const merged = mergeCanvasSnapshots(base, local, remote);
expect(merged.conflicts).toEqual([]);
expect(JSON.parse(merged.snapshot).canvasData.nodes).toEqual([
{ id: "a", title: "new A", x: 20, draftValues: { text: "original" } },
{ id: "d", title: "D" },
{ id: "c", title: "C" },
]);
expect(JSON.parse(merged.snapshot).canvasData.edges).toEqual([]);
});
test("same field and deletion versus modification require a decision, preserving other edits", () => {
const local = snapshot([{ id: "a", title: "local", x: 10, draftValues: { text: "original" } }]);
const remote = snapshot([
{ id: "a", title: "remote", x: 0, draftValues: { text: "new" } },
{ id: "b", title: "changed B", x: 0 },
]);
const merged = mergeCanvasSnapshots(base, local, remote);
expect(merged.conflicts).toEqual(["canvasData.nodes[a].title", "canvasData.nodes[b]"]);
const localChoice = JSON.parse(mergeCanvasSnapshots(base, local, remote, "local").snapshot)
.canvasData.nodes;
expect(localChoice).toEqual([{ id: "a", title: "local", x: 10, draftValues: { text: "new" } }]);
const remoteChoice = JSON.parse(mergeCanvasSnapshots(base, local, remote, "remote").snapshot)
.canvasData.nodes;
expect(remoteChoice[0]).toMatchObject({ title: "remote", x: 10, draftValues: { text: "new" } });
expect(remoteChoice).toHaveLength(2);
});
test("view state stays local, metadata timestamps do not create false content conflicts", () => {
const local = JSON.parse(base),
remote = JSON.parse(base);
local.canvasData.nodes[0].updatedAt = "local";
remote.canvasData.nodes[0].updatedAt = "remote";
remote.canvasData.nodes[0].title = "remote";
remote.canvasData.viewport.x = 50;
const merged = mergeCanvasSnapshots(base, JSON.stringify(local), JSON.stringify(remote));
expect(merged.conflicts).toEqual([]);
expect(JSON.parse(merged.snapshot).canvasData.viewport.x).toBe(0);
});
test("queued edits made while reading a conflict survive the automatic merge and CAS retry", async () => {
let release!: (e: Error) => void;
let calls = 0;
let applied = "";
const local = snapshot([
{ id: "a", title: "A", x: 10, draftValues: { text: "original" } },
{ id: "b", title: "B", x: 0 },
]);
const newer = snapshot([
{ id: "a", title: "A", x: 20, draftValues: { text: "original" } },
{ id: "b", title: "B", x: 0 },
]);
const remote = snapshot([
{ id: "a", title: "remote", x: 0, draftValues: { text: "original" } },
{ id: "b", title: "B", x: 0 },
]);
const queue = new VideoCanvasSaveQueue(
base,
"r0",
async (value, revision) => {
calls++;
if (calls === 1) return new Promise((_, reject) => (release = reject));
expect(revision).toBe("r1");
expect(JSON.parse(value).canvasData.nodes[0]).toMatchObject({ title: "remote", x: 20 });
return "r2";
},
() => {},
{ merge: mergeCanvasSnapshots, apply: (value) => (applied = value), conflict: () => {} },
);
queue.update(local);
const saving = queue.flush();
queue.update(newer);
release(new CanvasRevisionConflict("conflict", remote, "r1"));
await saving;
expect(calls).toBe(2);
expect(queue.dirty).toBe(false);
expect(applied).toContain("remote");
});
test("conflict resolution and restored browser drafts never blindly overwrite the remote document", async () => {
const local = snapshot([
{ id: "a", title: "local", x: 0, draftValues: { text: "original" } },
{ id: "b", title: "B", x: 0 },
]);
const remote = snapshot([
{ id: "a", title: "remote", x: 0, draftValues: { text: "new" } },
{ id: "b", title: "B", x: 0 },
]);
let conflict: string[] = [];
let written = "";
const queue = new VideoCanvasSaveQueue(
remote,
"r2",
async (value, revision) => {
expect(revision).toBe("r2");
written = value;
return "r3";
},
() => {},
{ merge: mergeCanvasSnapshots, apply: () => {}, conflict: (fields) => (conflict = fields) },
);
queue.restoreBackup(base, local);
await queue.flush();
expect(written).toBe("");
expect(conflict).toHaveLength(1);
await queue.resolveConflict("local");
expect(JSON.parse(written).canvasData.nodes[0]).toMatchObject({
title: "local",
draftValues: { text: "new" },
});
expect(conflict).toEqual([]);
expect(queue.dirty).toBe(false);
});
test("rapid remote saves pause automatic merging without permanently blocking manual retry", async () => {
let calls = 0;
let conflictFields: string[] = [];
const local = JSON.parse(base);
local.canvasData.nodes[0].title = "local";
const queue = new VideoCanvasSaveQueue(
base,
"r0",
async (value) => {
calls++;
if (calls <= 4) {
const remote = JSON.parse(base);
remote.canvasData.nodes[1].title = `remote-${calls}`;
throw new CanvasRevisionConflict("conflict", JSON.stringify(remote), `r${calls}`);
}
const nodes = JSON.parse(value).canvasData.nodes;
expect(nodes[0].title).toBe("local");
expect(nodes[1].title).toBe("remote-4");
return "r5";
},
() => {},
{
merge: mergeCanvasSnapshots,
apply: () => {},
conflict: (fields) => {
conflictFields = fields;
},
},
);
queue.update(JSON.stringify(local));
await queue.flush();
expect(calls).toBe(4);
expect(queue.failed).toBe(true);
expect(queue.dirty).toBe(true);
expect(conflictFields).toEqual([]);
await queue.flush();
expect(calls).toBe(5);
expect(queue.dirty).toBe(false);
});