ai-chatbot/tests/routes/flow-board-notes-tasks.test.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

169 lines
5.2 KiB
TypeScript

import { expect, test } from "@playwright/test";
import type { PlaitElement } from "@plait/core";
import {
getNoteDocument,
serializeNoteDocument,
} from "@/lib/flow-board/topic-notes";
import {
addDays,
dateIndex,
DAY,
getTopicTask,
patchTopicTask,
scheduleTaskChanges,
validateTopicTask,
type TopicTask,
} from "@/lib/flow-board/topic-tasks";
import { exportXmind } from "@/lib/flow-board/xmind-export";
import { patchTopicDetails } from "@/lib/flow-board/topic-details";
import JSZip from "jszip";
const task = (start = "2026-10-05", duration = 2): TopicTask => ({
start,
due: addDays(start, duration - 1),
duration,
progress: 25,
priority: 2,
assignees: ["小明"],
dependencies: [],
});
const topic = (id: string, t: TopicTask): PlaitElement => ({
id,
type: "mindmap",
children: [],
data: { topic: { children: [{ text: id }] } },
xmind: patchTopicTask({ id }, t),
});
test("rich notes retain marks, nested links and lists through serialization, and reject executable links", async () => {
const source = {
id: "notes",
xmind: {
notes: {
plain: { content: "粗体链接\n步骤" },
html: {
content: {
paragraphs: [
{
spans: [
{
text: "粗体",
style: { properties: { "fo:font-weight": "bold" } },
},
{
href: "https://example.com",
spans: [
{
text: "链接",
style: {
properties: {
"fo:font-style": "italic",
"fo:text-decoration": "underline",
},
},
},
],
},
],
},
{
style: { properties: { "fo:text-bullet": "number" } },
spans: [{ text: "步骤", href: "javascript:alert(1)" }],
},
],
},
},
},
labels: ["保留"],
},
};
const document = getNoteDocument(source);
expect(document[0].children[0].bold).toBe(true);
expect(document[0].children[1]).toMatchObject({
href: "https://example.com/",
italic: true,
underline: true,
});
expect(document[1].list).toBe("number");
expect(document[1].children[0].href).toBeUndefined();
const metadata = patchTopicDetails(source, { notesDocument: document });
expect(metadata.labels).toEqual(["保留"]);
expect(
serializeNoteDocument(getNoteDocument({ id: "notes", xmind: metadata }))
).toEqual(metadata.notes);
const result = await exportXmind(
{
children: [
{
id: "notes",
type: "mindmap",
xmind: metadata,
data: { topic: { children: [{ text: "测试" }] } },
},
],
},
"笔记"
);
const zip = await JSZip.loadAsync(await result.blob.arrayBuffer());
expect(
JSON.parse(await zip.file("content.json")!.async("string"))[0].rootTopic
.notes
).toEqual(metadata.notes);
});
test("native task extensions export dates, fractional progress, millisecond durations and remapped dependencies", async () => {
const a = topic("a", task()),
b = topic("b", {
...task("2026-10-07", 3),
dependencies: [{ id: "a", type: "FS", lag: DAY }],
});
const root = {
id: "root",
type: "mindmap",
data: { topic: { children: [{ text: "计划" }] } },
children: [a, b],
};
expect(getTopicTask(a)).toEqual(task());
const result = await exportXmind({ children: [root] }, "计划");
const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()),
content = JSON.parse(await zip.file("content.json")!.async("string"));
const [one, two] = content[0].rootTopic.children.attached;
expect(one.extensions[0].provider).toBe("org.xmind.ui.task");
expect(one.extensions[0].content).toMatchObject({
progress: 0.25,
duration: 2 * DAY,
assignees: ["小明"],
});
expect(two.extensions[0].content.dependencies).toEqual([
{ id: one.id, type: "FS", lag: DAY },
]);
});
test("task scheduling propagates dependencies, detects cycles and invalid dates, and rolls across leap days", () => {
const a = topic("a", task()),
b = topic("b", {
...task("2026-10-07"),
dependencies: [{ id: "a", type: "FS", lag: 0 }],
}),
c = topic("c", {
...task("2026-10-09"),
dependencies: [{ id: "b", type: "FS", lag: 0 }],
});
const changes = scheduleTaskChanges([a, b, c], "a", task("2026-10-08", 3));
expect(changes.get("b")?.start).toBe("2026-10-11");
expect(changes.get("c")?.start).toBe("2026-10-13");
expect(
validateTopicTask(
"a",
{ ...task(), dependencies: [{ id: "c", type: "FS", lag: 0 }] },
[a, b, c]
)
).toContain("循环");
expect(
validateTopicTask("a", { ...task(), due: "2026-10-01" }, [a, b, c])
).toContain("早于");
expect(Number.isNaN(dateIndex("2026-02-30"))).toBe(true);
expect(addDays("2028-02-28", 1)).toBe("2028-02-29");
const independent = patchTopicTask(a, null);
expect(getTopicTask({ ...a, xmind: independent })).toBeNull();
});