import { expect, test, type Page } from "@playwright/test"; import JSZip from "jszip"; import { clickXmindAction, clickXmindSheet } from "./editor-actions"; const DAY = 86400000; async function loadPlan(page: Page) { await page.goto("/dev/flow-board-preview", { waitUntil: "commit" }); const zip = new JSZip(); const task = (start: string, duration: number, progress = 0) => [ { provider: "org.xmind.ui.task", content: { start: new Date(`${start}T00:00:00`).getTime(), due: new Date(`${start}T23:59:59.999`).getTime() + (duration - 1) * DAY, duration: duration * DAY, progress, assignees: ["小明"], }, }, ]; zip.file( "content.json", JSON.stringify([ { id: "sheet", title: "项目计划", rootTopic: { id: "root", title: "项目", structureClass: "org.xmind.ui.logic.right", children: { attached: [ { id: "a", title: "设计", notes: { plain: { content: "第一行\n第二行" } }, extensions: task("2026-10-05", 2), }, { id: "b", title: "开发", extensions: [ { provider: "org.xmind.ui.task", content: { ...task("2026-10-07", 3)[0].content, dependencies: [{ id: "a", type: "FS", lag: 0 }], }, }, ], }, { id: "c", title: "验收" }, ], }, }, }, { title: "其他工作表", rootTopic: { id: "other", title: "其他" } }, ]) ); await page .locator("input[accept*=xmind]") .setInputFiles({ name: "项目计划.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }), }); await expect( page.locator(".drawnix").getByText("设计", { exact: true }) ).toBeVisible(); await page.evaluate(() => document.fonts.ready); } async function exported(page: Page) { const promise = page.waitForEvent("download"); await clickXmindAction(page, "导出 XMind"); const d = await promise, stream = await d.createReadStream(), chunks: Buffer[] = []; for await (const c of stream!) chunks.push(c); const buffer = Buffer.concat(chunks); await d.saveAs(test.info().outputPath("edited-plan.xmind")); const zip = await JSZip.loadAsync(buffer); return { buffer, sheets: JSON.parse(await zip.file("content.json")!.async("string")), }; } test("rich notes format selection, lists and safe links, undo, export and reimport", async ({ page, }) => { const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); await loadPlan(page); await page .getByRole("button", { name: "查看笔记 设计", exact: true }) .click(); const note = page.getByLabel("主题备注"); await expect(note).toContainText("第一行"); await note.fill("交付说明"); await note.press("ControlOrMeta+a"); await page.getByRole("button", { name: "笔记加粗", exact: true }).click(); await expect(note.locator('span[style*="font-weight: 700"]')).toContainText( "交付说明" ); await page.getByRole("button", { name: "笔记有序列表", exact: true }).click(); await expect(note.locator('[data-note-list="number"]')).toHaveCount(1); await page.getByRole("button", { name: "笔记链接", exact: true }).click(); await page.getByLabel("笔记链接地址").fill("https://example.com/spec"); await page.getByRole("button", { name: "确定", exact: true }).click(); await expect(note.locator("a")).toHaveAttribute( "href", "https://example.com/spec" ); await clickXmindAction(page, "撤销"); await expect(note.locator("a")).toHaveCount(0); await clickXmindAction(page, "重做"); await expect(note.locator("a")).toHaveCount(1); await page.screenshot({ path: test.info().outputPath("rich-notes.png") }); await page.getByRole("button", { name: "关闭笔记", exact: true }).click(); const out = await exported(page); const native = out.sheets[0].rootTopic.children.attached[0].notes; expect(native.plain.content).toBe("交付说明"); expect(native.realHTML.content).toContain("
    "); expect(native.realHTML.content).toContain(""); expect( native.html.content.paragraphs[0].spans[0].style.properties[ "fo:font-weight" ] ).toBe("bold"); await page .locator("input[accept*=xmind]") .setInputFiles({ name: "富文本往返.xmind", mimeType: "application/octet-stream", buffer: out.buffer, }); await page.locator(".drawnix").getByText("设计", { exact: true }).click(); await clickXmindAction(page, "备注"); await expect(page.getByLabel("主题备注").locator("a")).toHaveAttribute( "href", "https://example.com/spec" ); expect(errors).toEqual([]); }); test("task dates, dependencies, Gantt move/resize, undo, filters and native file roundtrip", async ({ page, }) => { const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); await loadPlan(page); await page.locator(".drawnix").getByText("验收", { exact: true }).click(); await clickXmindAction(page, "任务"); const editor = page.getByRole("dialog", { name: "任务信息", exact: true }); await page.getByLabel("任务开始日期").fill("2026-10-10"); await page.getByLabel("任务工期").fill("2"); await expect(page.getByLabel("任务截止日期")).toHaveValue("2026-10-11"); await page.getByLabel("任务进度", { exact: true }).fill("50"); await page.getByLabel("任务负责人").fill("小明"); await page .getByLabel("前置任务", { exact: true }) .selectOption({ label: "开发" }); await editor.getByRole("button", { name: "添加", exact: true }).click(); await editor.getByRole("button", { name: "保存任务", exact: true }).click(); await expect(editor).toHaveCount(0); await clickXmindAction(page, "甘特图"); const gantt = page.getByRole("dialog", { name: "甘特图", exact: true }); await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(3); await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(2); const row = (name: string) => gantt .locator(".xmind-gantt-row") .filter({ has: page.getByRole("button", { name, exact: true }) }); await expect(row("验收")).toContainText("2026-10-10"); const design = gantt.getByRole("button", { name: "任务时间条 设计", exact: true, }); await design.focus(); await design.press("ArrowRight"); await expect(row("设计")).toContainText("2026-10-06"); await expect(row("开发")).toContainText("2026-10-08"); await design.press("ControlOrMeta+z"); await expect(row("设计")).toContainText("2026-10-05"); await expect(row("开发")).toContainText("2026-10-07"); const box = await design.boundingBox(); expect(box).not.toBeNull(); await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); await page.mouse.down(); await page.mouse.move( box!.x + box!.width / 2 + 68, box!.y + box!.height / 2, { steps: 5 } ); await page.mouse.up(); await expect(row("设计")).toContainText("2026-10-07"); await expect(row("开发")).toContainText("2026-10-09"); await expect(row("验收")).toContainText("2026-10-12"); const resized = await design.boundingBox(); await page.mouse.move(resized!.x + resized!.width - 2, resized!.y + 10); await page.mouse.down(); await page.mouse.move(resized!.x + resized!.width + 32, resized!.y + 10, { steps: 4, }); await page.mouse.up(); await expect(row("设计")).toContainText("3 天"); await page.getByLabel("搜索甘特图任务").fill("验收"); await expect(gantt.locator(".xmind-gantt-row")).toHaveCount(1); await page.getByRole("button", { name: "清除筛选", exact: true }).click(); await expect(gantt.locator(".xmind-gantt-row")).toHaveCount(3); await page.screenshot({ path: test.info().outputPath("gantt-tasks.png") }); await page.getByRole("button", { name: "关闭甘特图", exact: true }).click(); const out = await exported(page); const topics = out.sheets[0].rootTopic.children.attached; expect(topics[1].extensions[0].content.dependencies[0].id).toBe(topics[0].id); await page .locator("input[accept*=xmind]") .setInputFiles({ name: "任务往返.xmind", mimeType: "application/octet-stream", buffer: out.buffer, }); await clickXmindAction(page, "甘特图"); await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(3); await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(2); expect(errors).toEqual([]); }); test("task validation, circular dependencies, removal, CSV export and sheet isolation", async ({ page, }) => { await loadPlan(page); await page .getByRole("button", { name: "编辑任务 设计", exact: true }) .click(); const editor = page.getByRole("dialog", { name: "任务信息", exact: true }); await page.getByLabel("任务截止日期").fill("2026-10-01"); await editor.getByRole("button", { name: "保存任务", exact: true }).click(); await expect(editor.getByRole("alert")).toContainText("不能早于"); await page.getByLabel("任务截止日期").fill("2026-10-06"); await page .getByLabel("前置任务", { exact: true }) .selectOption({ label: "开发" }); await editor.getByRole("button", { name: "添加", exact: true }).click(); await expect(editor.getByRole("alert")).toContainText("循环"); await editor .getByRole("button", { name: "关闭任务信息", exact: true }) .click(); await clickXmindAction(page, "甘特图"); const gantt = page.getByRole("dialog", { name: "甘特图", exact: true }); const promise = page.waitForEvent("download"); await page .getByRole("button", { name: "导出甘特图 CSV", exact: true }) .click(); const download = await promise; expect(download.suggestedFilename()).toContain("甘特图.csv"); const stream = await download.createReadStream(); const chunks: Buffer[] = []; for await (const c of stream!) chunks.push(c); expect(Buffer.concat(chunks).toString()).toContain( '"设计","2026-10-05","2026-10-06","2"' ); await gantt.getByRole("button", { name: "设计", exact: true }).click(); await editor.getByRole("button", { name: "移除任务", exact: true }).click(); await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(1); await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(0); await gantt .getByRole("button", { name: "任务时间条 开发", exact: true }) .press("ControlOrMeta+z"); await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(2); await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(1); await page.getByRole("button", { name: "关闭甘特图", exact: true }).click(); await clickXmindSheet(page, "其他工作表"); await clickXmindAction(page, "甘特图"); await expect(gantt.getByText("没有任务", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "关闭甘特图", exact: true }).click(); await clickXmindSheet(page, "项目计划"); await clickXmindAction(page, "甘特图"); await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(2); });