271 lines
8.5 KiB
TypeScript
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);
|
|
});
|