import { expect, test } from "@playwright/test"; import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { useCanvasStore } from "@/store/canvas"; import { canvasService } from "@/services/canvas"; const originalFetch = globalThis.fetch; const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, data: { src: `https://example.com/${id}.png` } }); const command = () => createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.remove_watermark", nodeIds: ["source"], source: "toolbar", }); test.beforeEach(() => { useCanvasStore.getState().clearCanvas(); useCanvasStore.getState().setProject("project-a"); useCanvasStore.getState().loadCanvas({ nodes: [node("source") as any], edges: [] }); useCanvasStore.getState().setCanvasRevision("r1", "r1"); }); test.afterEach(() => { globalThis.fetch = originalFetch; }); test("waits for the selected image to be saved before submitting and keeps subsequent saves behind the result", async () => { const queue = createCanvasSaveQueue(); const events: string[] = []; let finishSave!: () => void; const pendingSave = new Promise(resolve => { finishSave = resolve; }); const pending = queue.enqueueOperation(async () => { events.push("saving"); await pendingSave; events.push("saved"); return true; }, async () => { events.push("submitted"); }); const autosave = queue.enqueue(async () => { events.push("autosaved"); }); await Promise.resolve(); expect(events).toEqual(["saving"]); finishSave(); await Promise.all([pending, autosave]); expect(events).toEqual(["saving", "saved", "submitted", "autosaved"]); }); test("a failed save does not submit a paid image task or block later saves", async () => { const queue = createCanvasSaveQueue(); let submissions = 0; await expect(queue.enqueueOperation(async () => false, async () => { submissions++; })).rejects.toThrow("尚未保存成功"); expect(submissions).toBe(0); expect(await queue.enqueue(async () => "saved")).toBe("saved"); }); test("merges the result with edits made while processing and advances the save revision", async () => { globalThis.fetch = (async (url) => { if (String(url).endsWith("/capabilities")) { useCanvasStore.getState().moveNode("source", { x: 50, y: 80 }); return Response.json({ success: true }); } return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] }, } }); }) as typeof fetch; await executeCanvasToolbarCapability(command()); const state = useCanvasStore.getState(); expect(state.nodes.map(n => n.id)).toEqual(["source", "result"]); expect(state.nodes[0].position).toEqual({ x: 50, y: 80 }); expect(state.canvasRevision).toBe("r2"); }); test("a provider failure is shown without reloading or changing the canvas", async () => { globalThis.fetch = (async () => Response.json({ success: false, error: "去水印服务繁忙" }, { status: 503 })) as typeof fetch; await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("去水印服务繁忙"); expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]); expect(useCanvasStore.getState().canvasRevision).toBe("r1"); }); test("switching projects during processing does not load the old result into the new project", async () => { globalThis.fetch = (async (url) => { if (String(url).endsWith("/capabilities")) { useCanvasStore.getState().setProject("project-b"); useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] }); return Response.json({ success: true }); } return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } }); }) as typeof fetch; await executeCanvasToolbarCapability(command()); expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]); expect(useCanvasStore.getState().canvasRevision).toBe("r1"); }); test("cover updates advance the local revision before the next queued save", async () => { const queue = createCanvasSaveQueue(); globalThis.fetch = (async () => Response.json({ success: true, previousCanvasRevision: "r1", data: { updatedAt: "r2" }, })) as typeof fetch; const cover = queue.enqueue(() => canvasService.updateProject("project-a", { thumbnail: "https://example.com/source.png" })); const nextSave = queue.enqueue(async () => useCanvasStore.getState().canvasRevision); await cover; expect(await nextSave).toBe("r2"); }); test("metadata changes never adopt an unseen remote canvas revision", async () => { globalThis.fetch = (async () => Response.json({ success: true, previousCanvasRevision: "external-edit", data: { updatedAt: "r3" }, })) as typeof fetch; await canvasService.updateProject("project-a", { title: "Renamed" }); expect(useCanvasStore.getState().canvasRevision).toBe("r1"); }); test("a result merge conflict retains both the local edit and its original revision", async () => { globalThis.fetch = (async (url) => { if (String(url).endsWith("/capabilities")) { useCanvasStore.getState().moveNode("source", { x: 50, y: 80 }); return Response.json({ success: true }); } return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [{ ...node("source"), position: { x: 200, y: 80 } }, node("result")], edges: [], } } }); }) as typeof fetch; await expect(executeCanvasToolbarCapability(command())).rejects.toThrow("画布有冲突"); expect(useCanvasStore.getState().canvasRevision).toBe("r1"); expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 50, y: 80 }); });