import { mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { isCanvasState, type ProjectEditableState } from "../../packages/shared-contracts/src/canvas.js"; import { ConflictExportGuard, ProjectAutoSaveQueue, ProjectStateConflict, SessionHistory, } from "../../apps/web/src/project-autosave.js"; const canvas = { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0, }, asset_id: null, }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1, } as const; function state(name: string): ProjectEditableState { return { canvas_state: structuredClone(canvas), name }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); return { promise, reject, resolve }; } function writeEvidence(caseId: string, file: string, value: unknown) { const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE; if (!root) return; const directory = resolve(root, caseId); mkdirSync(directory, { recursive: true }); writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); } afterEach(() => vi.useRealTimers()); describe("TDD-WP2-PROJ-002-debounce", () => { it("debounces committed edits, keeps one request in flight, and sends only the latest full snapshot", async () => { vi.useFakeTimers(); const first = deferred<{ stateVersion: number }>(); const second = deferred<{ stateVersion: number }>(); const requests: Array<{ snapshot: ProjectEditableState; stateVersion: number }> = []; const statuses: string[] = []; const queue = new ProjectAutoSaveQueue({ initialState: state("初始名称"), initialVersion: 3, onStatus: (status) => statuses.push(status), save: (snapshot, stateVersion) => { requests.push({ snapshot: structuredClone(snapshot), stateVersion }); return requests.length === 1 ? first.promise : second.promise; }, }); queue.commit(state("第一次编辑")); await vi.advanceTimersByTimeAsync(500); queue.commit(state("一秒内最终编辑")); await vi.advanceTimersByTimeAsync(999); expect(requests).toHaveLength(0); await vi.advanceTimersByTimeAsync(1); expect(requests).toEqual([{ snapshot: state("一秒内最终编辑"), stateVersion: 3 }]); queue.commit(state("请求期间的最新编辑")); await vi.advanceTimersByTimeAsync(2_000); expect(requests).toHaveLength(1); first.resolve({ stateVersion: 4 }); await vi.advanceTimersByTimeAsync(999); expect(requests).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(requests[1]).toEqual({ snapshot: state("请求期间的最新编辑"), stateVersion: 4 }); second.resolve({ stateVersion: 5 }); await vi.runAllTimersAsync(); expect(queue.status).toBe("saved"); expect(queue.stateVersion).toBe(5); expect(statuses).toEqual(expect.arrayContaining(["dirty", "saving", "saved"])); writeEvidence("TDD-WP2-PROJ-002-debounce", "network-timeline.json", { max_in_flight: 1, requests }); writeEvidence("TDD-WP2-PROJ-002-debounce", "response.json", { final_status: queue.status, state_version: queue.stateVersion }); writeEvidence("TDD-WP2-PROJ-002-debounce", "db-diff.json", { successful_version_delta: 2 }); queue.dispose(); }); it("validates normalized Canvas state and rejects Fabric-private fields or a fifty-first element", () => { expect(isCanvasState(canvas)).toBe(true); expect(isCanvasState({ ...canvas, _objects: [] })).toBe(false); expect(isCanvasState({ ...canvas, elements: Array.from({ length: 51 }, (_, index) => ({ created_at: "2026-08-02T08:00:00.000Z", element_id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, opacity: 1, position: { x: 0, y: 0 }, resource_version: "v1", rotation: 0, scale: { x: 1, y: 1 }, template_or_asset_id: `STK${String(index).padStart(3, "0")}`, type: "static_sticker", z_index: index, })) })).toBe(false); }); }); describe("TDD-WP2-PROJ-002-save-failure-recovery", () => { it("retries while the page lives and restores a saved session with an empty undo stack", async () => { vi.useFakeTimers(); let attempts = 0; const statuses: string[] = []; const queue = new ProjectAutoSaveQueue({ initialState: state("后端已保存"), initialVersion: 7, onStatus: (status) => statuses.push(status), retryDelaysMs: [1_000], save: async () => { attempts += 1; if (attempts === 1) throw new Error("network unavailable"); return { stateVersion: 8 }; }, }); queue.commit(state("只在本页内存的修改")); await vi.advanceTimersByTimeAsync(1_000); expect(queue.status).toBe("failed"); await vi.advanceTimersByTimeAsync(1_000); expect(queue.status).toBe("saved"); expect(attempts).toBe(2); const history = new SessionHistory(state("后端已保存")); history.commit(state("会话修改")); expect(history.undo()?.name).toBe("后端已保存"); const reopened = new SessionHistory(state("后端已保存")); expect(reopened.canUndo).toBe(false); expect(reopened.canRedo).toBe(false); expect(statuses).toContain("failed"); writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "response.json", { attempts, statuses }); writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "db-diff.json", { failed_delta: 0, recovered_delta: 1 }); queue.dispose(); }); }); describe("TDD-WP2-PROJ-004-conflict-export-once", () => { it("stops saving on conflict and consumes the local export allowance even when export fails", async () => { vi.useFakeTimers(); const queue = new ProjectAutoSaveQueue({ initialState: state("旧标签"), initialVersion: 2, save: async () => { throw new ProjectStateConflict(3); }, }); queue.commit(state("旧标签本地修改")); await vi.advanceTimersByTimeAsync(1_000); expect(queue.status).toBe("conflicted"); queue.commit(state("冲突后不应发送")); await vi.runAllTimersAsync(); expect(queue.status).toBe("conflicted"); const guard = new ConflictExportGuard(); await expect(guard.run(async () => { throw new Error("render failed"); })).rejects.toThrow("render failed"); expect(guard.used).toBe(true); await expect(guard.run(async () => undefined)).resolves.toBe(false); queue.dispose(); }); });