Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
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("<ol>");
|
|
expect(native.realHTML.content).toContain("<strong>");
|
|
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);
|
|
});
|