ai-chatbot/tests/flow-board/image-export.spec.ts
134 23ced0b760 feat(flow-board): add rich notes, Gantt tasks and PNG export
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.
2026-10-06 17:38:14 +08:00

120 lines
7.3 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import JSZip from "jszip";
import sharp from "sharp";
import { clickXmindAction, clickXmindSheet } from "./editor-actions";
async function openMap(page: Page) {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
const image = await sharp({ create: { width: 40, height: 40, channels: 4, background: "#fa1515" } }).png().toBuffer();
zip.file("resources/red.png", image);
zip.file("content.json", JSON.stringify([
{ title: "完整画布", rootTopic: { id: "root", title: "图片导出验证", structureClass: "org.xmind.ui.logic.right", children: { attached: Array.from({ length: 20 }, (_, index) => ({
id: `topic-${index}`, title: `任务 ${index + 1}`,
...(index === 0 ? { image: { src: "xap:resources/red.png", width: 40 }, markers: [{ markerId: "priority-1" }] } : {}),
})) } } },
{ 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 expect.poll(() => page.locator(".drawnix .xmind-topic-marker").evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true);
}
async function exportPng(page: Page, scale: number, transparent: boolean) {
await page.getByRole("button", { name: "更多导出选项", exact: true }).click();
await page.getByRole("menuitem", { name: "导出图片", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出图片", exact: true });
await dialog.getByLabel("图片清晰度").selectOption(String(scale));
await dialog.getByLabel("透明背景").setChecked(transparent);
const pending = page.waitForEvent("download");
await dialog.getByRole("button", { name: "导出 PNG", exact: true }).click();
const download = await pending;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(chunk);
await expect(dialog).not.toBeVisible();
return { bytes: Buffer.concat(chunks), download };
}
test("PNG exports full content with text, markers, images and transparent high resolution independently of zoom", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await openMap(page);
const standard = await exportPng(page, 1, false);
expect(standard.download.suggestedFilename()).toBe("图片验证-完整画布.png");
const { data, info } = await sharp(standard.bytes).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(info.height).toBeGreaterThan(900); // Includes branches outside the viewport.
expect(Array.from(data.subarray(0, 4))).toEqual([255, 255, 255, 255]);
let red = 0, dark = 0;
for (let index = 0; index < data.length; index += 4) {
if (data[index] > 210 && data[index + 1] < 60 && data[index + 2] < 60) red++;
if (data[index] < 80 && data[index + 1] < 80 && data[index + 2] < 80) dark++;
}
expect(red).toBeGreaterThan(500);
expect(dark).toBeGreaterThan(300); // Topic text is rasterized, not just node shapes.
await standard.download.saveAs(test.info().outputPath("complete.png"));
const high = await exportPng(page, 2, true);
const highInfo = await sharp(high.bytes).metadata();
expect(highInfo.width).toBe(info.width * 2);
expect(highInfo.height).toBe(info.height * 2);
const corner = await sharp(high.bytes).extract({ left: 0, top: 0, width: 1, height: 1 }).ensureAlpha().raw().toBuffer();
expect(corner[3]).toBe(0);
await high.download.saveAs(test.info().outputPath("transparent.png"));
await clickXmindAction(page, "缩小");
await clickXmindAction(page, "缩小");
const zoomed = await exportPng(page, 2, true);
expect(zoomed.bytes.equals(high.bytes)).toBe(true);
await clickXmindSheet(page, "第二页");
await expect(page.locator(".drawnix").getByText("第二页内容", { exact: true })).toBeVisible();
const second = await exportPng(page, 3, false);
expect(second.download.suggestedFilename()).toBe("图片验证-第二页.png");
expect((await sharp(second.bytes).metadata()).height).toBeLessThan(info.height);
expect(errors).toEqual([]);
});
test("image export is keyboard dismissible and failed resources show a recoverable error", async ({ page }) => {
await openMap(page);
await page.getByRole("button", { name: "文件与编辑", exact: true }).click();
await page.getByRole("menuitem", { name: "导出图片", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出图片", exact: true });
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await page.route("**/fonts/xmind/NeverMind-Bold.ttf", route => route.fulfill({ status: 503, body: "unavailable" }));
await page.getByRole("button", { name: "更多导出选项", exact: true }).click();
await page.getByRole("menuitem", { name: "导出图片", exact: true }).click();
await dialog.getByRole("button", { name: "导出 PNG", exact: true }).click();
await expect(page.getByText("图片资源加载失败,请稍后重试", { exact: true })).toBeVisible();
await expect(dialog.getByRole("button", { name: "导出 PNG", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "取消", exact: true }).click();
await expect(dialog).not.toBeVisible();
});
test("PNG includes relationships, boundaries and summaries without selection outlines", async ({ page }) => {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file("content.json", JSON.stringify([{ title: "结构", rootTopic: {
id: "r", title: "结构验证", structureClass: "org.xmind.ui.logic.right",
children: { attached: [{ id: "a", title: "设计" }, { id: "b", title: "交付" }], summary: [{ id: "s", title: "阶段概要" }] },
summaries: [{ range: "(0,1)", topicId: "s" }],
boundaries: [{ range: "(0,1)", style: { properties: { "line-color": "#00ff00" } } }],
}, relationships: [{ end1Id: "a", end2Id: "b", title: "验收联系", style: { properties: { "line-color": "#ff00ff" } } }] }]));
await page.locator("input[accept*=xmind]").setInputFiles({ name: "结构验证.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }) });
const canvas = page.locator(".drawnix");
await expect(canvas.getByText("阶段概要", { exact: true })).toBeVisible();
await expect(canvas.getByText("验收联系", { exact: true })).toBeVisible();
const plain = await exportPng(page, 2, false);
const data = await sharp(plain.bytes).ensureAlpha().raw().toBuffer();
let green = 0, purple = 0;
for (let index = 0; index < data.length; index += 4) {
if (data[index] < 60 && data[index + 1] > 200 && data[index + 2] < 60) green++;
if (data[index] > 200 && data[index + 1] < 60 && data[index + 2] > 200) purple++;
}
expect(green).toBeGreaterThan(40);
expect(purple).toBeGreaterThan(40);
await canvas.getByText("设计", { exact: true }).click();
const selected = await exportPng(page, 2, false);
expect(selected.bytes.equals(plain.bytes)).toBe(true);
await selected.download.saveAs(test.info().outputPath("annotations.png"));
});