ai-chatbot/tests/flow-board/xmind-native-fixtures.spec.ts
134 822c4cbfb9 fix(flow-board): align Xmind editor UI and restore marquee feedback
Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support.

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 18:57:02 +08:00

112 lines
4.6 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import JSZip from "jszip";
// Optional, locally extracted fixtures; do not redistribute XMind's bundled samples.
const fixtureDirectory = process.env.XMIND_NATIVE_FIXTURES;
test("native XMind basic samples retain every topic through import and export", async ({
page,
}) => {
test.skip(
!fixtureDirectory,
"Set XMIND_NATIVE_FIXTURES to an extracted basic sample directory"
);
test.setTimeout(120_000);
const files = (await readdir(fixtureDirectory!)).filter((name) =>
name.endsWith(".xmind")
);
expect(files.length).toBeGreaterThan(0);
const titles = (topic: any): string[] => [
String(topic.title ?? "").trim() || "未命名主题",
...Object.values(topic.children || {}).flatMap((group) =>
(group as any[]).flatMap(titles)
),
];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
for (const name of files)
await test.step(name, async () => {
const buffer = await readFile(path.join(fixtureDirectory!, name));
const input = await JSZip.loadAsync(buffer);
const original = JSON.parse(
await input.file("content.json")!.async("string")
);
await page
.locator("input[accept*=xmind]")
.setInputFiles({ name, mimeType: "application/octet-stream", buffer });
await expect(page.getByLabel("白板标题", { exact: true })).toHaveValue(
name.replace(/\.xmind$/, "")
);
const special = (
{
"org.xmind.ui.fishbone.leftHeaded": "fishbone",
"org.xmind.ui.timeline.sided.horizontal": "timeline",
"org.xmind.ui.timeline.vertical": "verticalTimeline",
"org.xmind.ui.treetable.toptitle": "treeTable",
"org.xmind.ui.treetable": "treeTableSide",
"org.xmind.ui.spreadsheet": "spreadsheet",
"org.xmind.ui.brace.right": "brace",
} as Record<string, string>
)[original[0].rootTopic.structureClass];
if (["treeTable", "treeTableSide", "spreadsheet"].includes(special)) {
const table = page.locator(
`.drawnix [data-special-table="${special}"]`
);
await expect(table).toBeVisible();
expect(
await table.locator("[data-special-table-cell]").count()
).toBeGreaterThan(1);
} else if (special === "verticalTimeline") {
// A straight SVG path has a zero-width geometric box despite its visible stroke.
const axis = page.locator(
'.drawnix [data-special-axis="verticalTimeline"] path'
);
await expect(axis).toHaveAttribute("d", /V/);
expect(Number(await axis.getAttribute("stroke-width"))).toBeGreaterThan(
0
);
} else if (special)
await expect(
page
.locator(
`.drawnix [data-special-structure="${special}"] ${special === "timeline" ? "circle" : "path"}`
)
.first()
).toBeVisible();
if (name === "mind_map.xmind") {
const topic = page.locator(".drawnix").getByText("Central Topic", { exact: true });
await expect(topic).toHaveCSS("font-family", /NeverMind/);
const metrics = await topic.evaluate(element => {
const container = element.closest(".plait-text-container")!;
const foreign = container.parentElement!;
return { contentHeight: container.scrollHeight, frameHeight: Number(foreign.getAttribute("height")) };
});
expect(metrics.contentHeight).toBeLessThanOrEqual(Math.ceil(metrics.frameHeight));
}
const downloadPromise = page.waitForEvent("download");
await page
.getByRole("button", { name: "导出 XMind", exact: true })
.click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
const zip = await JSZip.loadAsync(Buffer.concat(chunks));
const output = JSON.parse(
await zip.file("content.json")!.async("string")
);
expect(output).toHaveLength(original.length);
if (special)
expect(output[0].rootTopic.structureClass).toBe(
original[0].rootTopic.structureClass
);
for (let i = 0; i < original.length; i++)
expect(titles(output[i].rootTopic).sort(), name).toEqual(
titles(original[i].rootTopic).sort()
);
expect(errors, name).toEqual([]);
});
});