import { expect, test, type Page } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); async function waitForEngine(page: Page): Promise { await page.goto("/"); await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 }); } test("M7-02 rejects a repeated open before it can replace the first project owner", async ({ page }) => { await waitForEngine(page); const bytes = Array.from(fs.readFileSync(basicBlend)); await page.evaluate(({ blendBytes }) => { const input = document.querySelector("[data-testid=blend-file-input]"); if (!input) throw new Error("blend input missing"); const dispatchOpen = (name: string): void => { const transfer = new DataTransfer(); transfer.items.add(new File([new Uint8Array(blendBytes)], name, { type: "application/octet-stream" })); input.files = transfer.files; input.dispatchEvent(new Event("change", { bubbles: true })); }; dispatchOpen("first-open.blend"); dispatchOpen("second-open.blend"); }, { blendBytes: bytes }); const app = page.locator(".blender-app"); await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT"); await expect(app).toHaveAttribute("data-project-action-conflict-reason", "REPEATED_OPEN"); await expect(app).toHaveAttribute("data-project-action-conflict-requested", "OPEN"); await expect(app).toHaveAttribute("data-project-action-conflict-owner", "OPEN"); await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED"); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("first-open"); }); test("M7-02 grants only one concurrent manual save and emits one download", async ({ page }) => { await waitForEngine(page); await page.getByTestId("blend-file-input").setInputFiles(basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); let downloadCount = 0; page.on("download", () => { downloadCount += 1; }); await page.evaluate(() => { const save = document.querySelector('button[aria-label="保存项目"]'); if (!save) throw new Error("save button missing"); save.click(); save.click(); }); const app = page.locator(".blender-app"); await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT"); await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CONCURRENT_SAVE"); await expect(app).toHaveAttribute("data-project-action-conflict-requested", "SAVE"); await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE"); await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED"); await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED"); await expect.poll(() => downloadCount).toBe(1); }); test("M7-02 blocks close until save commit completes without terminating either worker", async ({ page }) => { await waitForEngine(page); await page.getByTestId("blend-file-input").setInputFiles(basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); const firstDownload = page.waitForEvent("download"); await page.evaluate(() => { const save = document.querySelector('button[aria-label="保存项目"]'); const close = document.querySelector('button[aria-label="关闭项目"]'); if (!save || !close) throw new Error("project action button missing"); save.click(); close.click(); }); const app = page.locator(".blender-app"); await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT"); await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CLOSE_DURING_SAVE"); await expect(app).toHaveAttribute("data-project-action-conflict-requested", "CLOSE"); await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE"); await firstDownload; await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED"); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "添加立方体" }).click(); await expect(page.getByTestId("scene-stats")).toContainText("Objects 4"); const secondDownload = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); await secondDownload; await expect(app).not.toHaveAttribute("data-project-action-conflict"); await page.getByRole("button", { name: "关闭项目" }).click(); await expect(page.getByTestId("scene-stats")).toContainText("Objects 0"); await expect(page.getByRole("button", { name: "关闭项目" })).toBeDisabled(); });