Files
edgeever/tests/markdown-export.test.ts
T
edgeever-updater[bot] eaf37b4aeb chore: update EdgeEver from v2.16.1
EdgeEver-Upstream-Commit: 6e79c1bd81a3678b1d0d01f5476564cfecdd40d8
2026-10-31 08:26:31 +00:00

123 lines
4.4 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { MemoDetail, Notebook, Resource } from "@edgeever/shared";
import { strFromU8, unzipSync } from "fflate";
import {
buildMarkdownFrontMatter,
buildNotebookExportPaths,
createMarkdownExport,
sanitizeExportPathSegment,
} from "../apps/web/src/lib/markdown-export";
const notebook = (input: Partial<Notebook> & Pick<Notebook, "id" | "name">): Notebook => ({
id: input.id,
parentId: input.parentId ?? null,
name: input.name,
slug: null,
icon: null,
color: null,
sortOrder: 0,
memoCount: 0,
lastMemoUpdatedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const memo = (input: Partial<MemoDetail> = {}): MemoDetail => ({
id: "memo_1",
notebookId: "nb_child",
title: "项目笔记",
excerpt: "",
tags: ["工作", "重要"],
isPinned: true,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "![diagram](/api/v1/resources/res_1/blob)",
contentText: "",
contentHash: "hash",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
...input,
});
const resource: Resource = {
id: "res_1",
memoId: "memo_1",
originalMemoId: null,
kind: "image",
mimeType: "image/png",
filename: "设计 图.png",
byteSize: 3,
sha256: null,
width: null,
height: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
url: "/api/v1/resources/res_1/blob",
};
describe("Markdown export", () => {
test("sanitizes unsafe and reserved path names", () => {
expect(sanitizeExportPathSegment("CON", "fallback")).toBe("_CON");
expect(sanitizeExportPathSegment('<>:"/\\|?*', "fallback")).toBe("---------");
expect(sanitizeExportPathSegment("...", "fallback")).toBe("fallback");
});
test("preserves notebook hierarchy and disambiguates sibling names", () => {
const paths = buildNotebookExportPaths([
notebook({ id: "nb_root", name: "工作" }),
notebook({ id: "nb_child", name: "项目", parentId: "nb_root" }),
notebook({ id: "nb_child_2", name: "项目", parentId: "nb_root" }),
]);
expect(paths.get("nb_child")).toBe("工作/项目");
expect(paths.get("nb_child_2")).toBe("工作/项目 (2)");
});
test("writes front matter, Markdown, and relative assets into the ZIP", async () => {
const notes = [memo()];
const blob = await createMarkdownExport({
listNotebooks: async () => ({
notebooks: [
notebook({ id: "nb_root", name: "工作" }),
notebook({ id: "nb_child", name: "项目", parentId: "nb_root" }),
],
}),
getPage: async () => ({ memos: notes, resources: [resource], totalCount: 1, nextOffset: null }),
getResourceBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
});
const files = unzipSync(new Uint8Array(await blob.arrayBuffer()));
const markdownPath = "工作/项目/项目笔记.md";
const assetPath = "工作/项目/项目笔记.assets/设计 图.png";
expect(Object.keys(files).sort()).toEqual([assetPath, markdownPath].sort());
expect(Array.from(files[assetPath])).toEqual([1, 2, 3]);
const markdown = strFromU8(files[markdownPath]);
expect(markdown).toStartWith(buildMarkdownFrontMatter(notes[0], "工作/项目"));
expect(markdown).toContain(
"![diagram](%E9%A1%B9%E7%9B%AE%E7%AC%94%E8%AE%B0.assets/%E8%AE%BE%E8%AE%A1%20%E5%9B%BE.png)"
);
});
test("retains the initial total when continuation pages omit counts", async () => {
const progress: { completed: number; total: number }[] = [];
const blob = await createMarkdownExport({
listNotebooks: async () => ({ notebooks: [notebook({ id: "nb_child", name: "Notes" })] }),
getPage: async (offset) => ({
memos: [memo({ id: `memo_${offset}`, title: `Note ${offset}`, contentMarkdown: "body" })],
resources: [], ...(offset === 0 ? { totalCount: 2 } : {}), nextOffset: offset === 0 ? 1 : null,
}),
getResourceBlob: async () => new Blob(),
}, (value) => progress.push(value));
expect(Object.keys(unzipSync(new Uint8Array(await blob.arrayBuffer())))).toHaveLength(2);
expect(progress.every((value) => value.total === 2)).toBe(true);
expect(progress.at(-1)?.completed).toBe(2);
});
});