feat: complete TASK-WP2-02 project autosave
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { ProjectService } from "../../apps/api/src/projects.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const now = Date.parse("2026-08-02T08:00:00.000Z");
|
||||
const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const roots: string[] = [];
|
||||
const registrations: RegistrationService[] = [];
|
||||
const projects: ProjectService[] = [];
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
function harness() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp2-02-api-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
|
||||
});
|
||||
registrations.push(registration);
|
||||
const projectService = new ProjectService({ clock: () => now, databasePath });
|
||||
projects.push(projectService);
|
||||
const ownerId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'state@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'State User', '@state_user')")
|
||||
.run(ownerId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)")
|
||||
.run(ownerId, now);
|
||||
const session = registration.issueAuthenticatedSession(ownerId, "user");
|
||||
return { ownerId, projectService, registration, session };
|
||||
}
|
||||
|
||||
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(() => {
|
||||
for (const project of projects.splice(0)) project.close();
|
||||
for (const registration of registrations.splice(0)) registration.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
async function authenticatedApp() {
|
||||
const fixture = harness();
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, projects: fixture.projectService, registration: fixture.registration });
|
||||
const cookie = `dada_session=${fixture.session.sessionToken}`;
|
||||
const session = await app.inject({ headers: { ...baseHeaders, cookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||
return { ...fixture, app, cookie, csrf: session.json().csrf_token as string };
|
||||
}
|
||||
|
||||
describe("TDD-WP2-PROJ-002-cas-success", () => {
|
||||
it("validates a complete Canvas snapshot and replays one idempotent CAS result without incrementing twice", async () => {
|
||||
const fixture = await authenticatedApp();
|
||||
const created = fixture.projectService.createProjectForGeneration({ ownerId: fixture.ownerId, prompt: "CAS 项目", ratio: "3:4", status: "failed" });
|
||||
const url = `/api/v1/projects/${created.project.projectId}/state`;
|
||||
const headers = { ...baseHeaders, cookie: fixture.cookie, "idempotency-key": "wp2-02-cas-key-00000000000000010", "if-match": "1", "x-csrf-token": fixture.csrf };
|
||||
const payload = { canvas_state: canvas, name: "CAS 保存成功" };
|
||||
|
||||
const first = await fixture.app.inject({ headers, method: "PUT", payload, url });
|
||||
const replay = await fixture.app.inject({ headers, method: "PUT", payload, url });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json()).toEqual({ save_status: "saved", state_version: 2 });
|
||||
expect(replay.statusCode).toBe(200);
|
||||
expect(replay.json()).toEqual(first.json());
|
||||
|
||||
const foreign = fixture.projectService.createProjectForGeneration({ ownerId: randomUUID(), prompt: "他人底图", ratio: "3:4", status: "running" });
|
||||
const foreignImageId = randomUUID();
|
||||
fixture.projectService.recordSuccessfulImage({ generationId: foreign.generation.generationId, imageId: foreignImageId });
|
||||
const foreignReference = await fixture.app.inject({
|
||||
headers: { ...headers, "idempotency-key": "wp2-02-foreign-000000000000000000", "if-match": "2" }, method: "PUT",
|
||||
payload: { canvas_state: { ...canvas, background: { ...canvas.background, asset_id: foreignImageId } }, name: "非法引用" }, url,
|
||||
});
|
||||
expect(foreignReference.statusCode).toBe(400);
|
||||
|
||||
const detail = await fixture.app.inject({ headers: { ...baseHeaders, cookie: fixture.cookie }, method: "GET", url: `/api/v1/projects/${created.project.projectId}` });
|
||||
expect(detail.json()).toMatchObject({ canvas_state: canvas, name: "CAS 保存成功", save_status: "saved", state_version: 2 });
|
||||
expect(fixture.projectService.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(created.project.projectId))
|
||||
.toEqual({ count: 2 });
|
||||
writeEvidence("TDD-WP2-PROJ-002-cas-success", "request.json", {
|
||||
expected_state_version: 1, payload, route: "/api/v1/projects/{projectId}/state",
|
||||
});
|
||||
writeEvidence("TDD-WP2-PROJ-002-cas-success", "response.json", { first: first.json(), replay: replay.json() });
|
||||
writeEvidence("TDD-WP2-PROJ-002-cas-success", "db-diff.json", {
|
||||
foreign_reference_write: 0, project_state_rows: 2, state_version_delta: 1,
|
||||
});
|
||||
await fixture.app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("TDD-WP2-PROJ-004-stale-tab", () => {
|
||||
it("returns 412 with only the latest safe version and leaves the stale tab without any write", async () => {
|
||||
const fixture = await authenticatedApp();
|
||||
const created = fixture.projectService.createProjectForGeneration({ ownerId: fixture.ownerId, prompt: "多标签", ratio: "3:4", status: "failed" });
|
||||
const url = `/api/v1/projects/${created.project.projectId}/state`;
|
||||
const request = (name: string, key: string) => fixture.app.inject({
|
||||
headers: { ...baseHeaders, cookie: fixture.cookie, "idempotency-key": key, "if-match": "1", "x-csrf-token": fixture.csrf },
|
||||
method: "PUT", payload: { canvas_state: canvas, name }, url,
|
||||
});
|
||||
const newer = await request("标签 A", "wp2-02-tab-a-0000000000000000010");
|
||||
const stale = await request("标签 B", "wp2-02-tab-b-0000000000000000010");
|
||||
expect(newer.statusCode).toBe(200);
|
||||
expect(stale.statusCode).toBe(412);
|
||||
expect(stale.json()).toEqual({ latest_state_version: 2, save_status: "conflicted" });
|
||||
expect(fixture.projectService.getProject(fixture.ownerId, created.project.projectId)).toMatchObject({ name: "标签 A", stateVersion: 2 });
|
||||
expect(fixture.projectService.listProjects(fixture.ownerId, "active")).toHaveLength(1);
|
||||
expect(fixture.projectService.database.prepare("SELECT COUNT(*) AS count FROM project_states WHERE project_id = ?").get(created.project.projectId))
|
||||
.toEqual({ count: 2 });
|
||||
writeEvidence("TDD-WP2-PROJ-004-stale-tab", "response.json", { newer: newer.json(), stale: stale.json() });
|
||||
writeEvidence("TDD-WP2-PROJ-004-stale-tab", "db-diff.json", { project_count: 1, stale_write_delta: 0, state_rows: 2 });
|
||||
await fixture.app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const projectId = "00000000-0000-4000-8000-000000000401";
|
||||
const canvasState = {
|
||||
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,
|
||||
};
|
||||
const session = {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-project-state-fixture-000000000000000000000000000000000",
|
||||
expires_at: "2026-09-02T08:00:00.000Z",
|
||||
user: { creator_name: "State User", role: "user", social_id: "@state_user", status: "active", user_id: "00000000-0000-4000-8000-000000000402" },
|
||||
};
|
||||
|
||||
function projectPayload(name: string, version: number) {
|
||||
return {
|
||||
canvas_state: canvasState, created_at: "2026-08-02T08:00:00.000Z", current_image_id: null,
|
||||
draft_prompt: "多标签项目", generations: [], images: [], name, pixel_height: 1440, pixel_width: 1080,
|
||||
project_id: projectId, ratio: "3:4", save_status: "saved", state_version: version,
|
||||
status: "failed_empty", successful_image_count: 0, updated_at: "2026-08-02T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function routeSession(target: Page | BrowserContext) {
|
||||
await target.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
async function screenshot(page: Page, caseId: string, file: string) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECT_STATE;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId, "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, file) });
|
||||
}
|
||||
|
||||
test("TDD-WP2-PROJ-002 recovers a failed debounced save without browser draft persistence", async ({ context, page }) => {
|
||||
await routeSession(context);
|
||||
let backendName = "后端已保存";
|
||||
let backendVersion = 4;
|
||||
await context.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload(backendName, backendVersion)), contentType: "application/json", status: 200 }));
|
||||
const timeline: Array<{ name: string; version: string | undefined }> = [];
|
||||
let attempts = 0;
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
let releaseRecovery!: () => void;
|
||||
const recoveryGate = new Promise<void>((resolveRecovery) => {
|
||||
releaseRecovery = resolveRecovery;
|
||||
});
|
||||
await context.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
attempts += 1;
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
timeline.push({ name: route.request().postDataJSON().name, version: route.request().headers()["if-match"] });
|
||||
if (attempts === 1) await route.fulfill({ body: "null", contentType: "application/json", status: 503 });
|
||||
else {
|
||||
await recoveryGate;
|
||||
backendName = route.request().postDataJSON().name;
|
||||
backendVersion += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backendVersion }), contentType: "application/json", status: 200 });
|
||||
}
|
||||
inFlight -= 1;
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
const input = page.getByRole("textbox", { name: "项目名称" });
|
||||
await input.fill("第一次");
|
||||
await input.fill("一秒内最终名称");
|
||||
await expect(page.getByText("未保存", { exact: true })).toBeVisible({ timeout: 4_000 });
|
||||
await screenshot(page, "TDD-WP2-PROJ-002-save-failure-recovery", "save-failed.png");
|
||||
await page.getByRole("link", { exact: true, name: "项目" }).click();
|
||||
const leaveDialog = page.getByRole("dialog", { name: "有未保存修改" });
|
||||
await expect(leaveDialog).toBeVisible();
|
||||
await expect(leaveDialog.getByRole("button", { name: "保存并离开" })).toBeVisible();
|
||||
await expect(leaveDialog.getByRole("button", { name: "放弃修改" })).toBeVisible();
|
||||
await leaveDialog.getByRole("button", { name: "取消" }).click();
|
||||
await expect(leaveDialog).toBeHidden();
|
||||
releaseRecovery();
|
||||
await expect(page.getByText("已保存", { exact: true })).toBeVisible({ timeout: 6_000 });
|
||||
expect(timeline).toEqual([
|
||||
{ name: "一秒内最终名称", version: "4" },
|
||||
{ name: "一秒内最终名称", version: "4" },
|
||||
]);
|
||||
expect(maxInFlight).toBe(1);
|
||||
await input.fill("强制关闭前的未保存修改");
|
||||
await page.close();
|
||||
const reopened = await context.newPage();
|
||||
await reopened.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
await expect(reopened.getByRole("textbox", { name: "项目名称" })).toHaveValue("一秒内最终名称");
|
||||
await expect(reopened.getByText("state version 5")).toBeVisible();
|
||||
const storage = await reopened.evaluate(async () => ({
|
||||
cache_keys: "caches" in window ? await caches.keys() : [],
|
||||
indexed_db: "databases" in indexedDB ? (await indexedDB.databases()).map((entry) => entry.name) : [],
|
||||
local_storage: Object.keys(localStorage),
|
||||
}));
|
||||
expect(JSON.stringify(storage)).not.toContain("强制关闭前的未保存修改");
|
||||
writeEvidence("TDD-WP2-PROJ-002-save-failure-recovery", "cache-enumeration.json", storage);
|
||||
writeEvidence("TDD-WP2-PROJ-002-debounce", "network-timeline.json", { max_in_flight: maxInFlight, requests: timeline });
|
||||
});
|
||||
|
||||
test("TDD-WP2-PROJ-004 turns the stale tab read-only and consumes one local export", async ({ context }) => {
|
||||
await routeSession(context);
|
||||
let backendName = "共同版本";
|
||||
let backendVersion = 1;
|
||||
const network: string[] = [];
|
||||
await context.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload(backendName, backendVersion)), contentType: "application/json", status: 200 }));
|
||||
await context.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
network.push(route.request().url());
|
||||
const expected = Number(route.request().headers()["if-match"]);
|
||||
if (expected !== backendVersion) {
|
||||
await route.fulfill({ body: JSON.stringify({ latest_state_version: backendVersion, save_status: "conflicted" }), contentType: "application/json", status: 412 });
|
||||
return;
|
||||
}
|
||||
backendName = route.request().postDataJSON().name;
|
||||
backendVersion += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backendVersion }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
const pageA = await context.newPage();
|
||||
const pageB = await context.newPage();
|
||||
await Promise.all([pageA.goto(`${webUrl}/app/projects/${projectId}`), pageB.goto(`${webUrl}/app/projects/${projectId}`)]);
|
||||
await pageA.getByRole("textbox", { name: "项目名称" }).fill("标签 A 已保存");
|
||||
await expect.poll(() => backendVersion, { timeout: 4_000 }).toBe(2);
|
||||
await expect(pageA.getByText("已保存", { exact: true })).toBeVisible({ timeout: 4_000 });
|
||||
await pageB.getByRole("textbox", { name: "项目名称" }).fill("标签 B 的本地版本");
|
||||
await expect(pageB.getByRole("heading", { name: "版本冲突" })).toBeVisible({ timeout: 4_000 });
|
||||
await expect(pageB.getByText("本页版本 1")).toBeVisible();
|
||||
await expect(pageB.getByText("最新版本 2")).toBeVisible();
|
||||
await expect(pageB.getByRole("textbox", { name: "项目名称" })).toBeDisabled();
|
||||
await expect(pageB.getByText("另存为")).toHaveCount(0);
|
||||
await expect(pageB.getByRole("link", { name: "修改并重试" })).toHaveCount(0);
|
||||
await screenshot(pageB, "TDD-WP2-PROJ-004-stale-tab", "conflicted.png");
|
||||
|
||||
const download = pageB.waitForEvent("download");
|
||||
await pageB.getByRole("button", { name: "本地导出本页版本" }).click();
|
||||
await download;
|
||||
await expect(pageB.getByRole("button", { name: "本地导出本页版本" })).toBeDisabled();
|
||||
await expect(pageB.getByText("仅下载本页版本,未写入项目")).toBeVisible();
|
||||
expect(network.every((url) => !url.includes("latest") && !url.includes("export"))).toBe(true);
|
||||
await screenshot(pageB, "TDD-WP2-PROJ-004-conflict-export-once", "conflict-export.png");
|
||||
writeEvidence("TDD-WP2-PROJ-004-stale-tab", "response.json", { latest_state_version: 2, save_status: "conflicted" });
|
||||
writeEvidence("TDD-WP2-PROJ-004-stale-tab", "db-diff.json", { stale_writes: 0, version: 2 });
|
||||
writeEvidence("TDD-WP2-PROJ-004-conflict-export-once", "network-timeline.json", { requests: network });
|
||||
writeEvidence("TDD-WP2-PROJ-004-conflict-export-once", "db-diff.json", { latest_export_delta: 0, project_delta: 0, state_delta: 0 });
|
||||
});
|
||||
@@ -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