feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user