diff --git a/.gitignore b/.gitignore index 7554983..e6f64b4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ larkDocx2mdOutput/ # idea .qoder .agents +coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 6163b8a..bb5b98d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.5.0 + +### Features + +- **按标题过滤文档内容**:新增 `--filter-title` CLI 参数与 `ConvertOptions.filterTitle` 选项,仅输出指定标题所在章节的内容 + +### Fixed + +- **filterTitle 入参容错**:对 `filterTitle` 应用 `trim()`,避免空白字符导致匹配失败 + ## 0.3.2 ### Changed diff --git a/README.md b/README.md index a2542c8..afc49d6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,13 @@ 将飞书文档、电子表格转换为 Markdown 文件的命令行工具。 -> 支持的飞书文档链接格式:`https://*.feishu.cn/wiki/*`、`https://*.feishu.cn/sheets/*`(支持 `?sheet=` 指定子表) + +支持: +- 飞书文档:`https://*.feishu.cn/wiki/*` + - Markdown 类型中的标题、列表、基础文字等常见的文本样式 + - 飞书文档中的内嵌画板、电子表格 + - ...见下表‘支持的内容块类型’ +- 飞书电子表格:`https://*.feishu.cn/sheets/*`(支持 `?sheet=` 指定子表)、`https://*.feishu.cn/wiki/*` ## 使用 diff --git a/package.json b/package.json index c597f18..1c40d73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lark-docx2md", - "version": "0.3.2", + "version": "0.5.0", "description": "Convert Lark/Feishu documents to Markdown", "type": "module", "main": "./dist/converter.js", @@ -15,6 +15,9 @@ "scripts": { "build": "tsdown", "dev": "tsx src/cli.ts", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "prepack": "pnpm build" }, "keywords": [ @@ -44,9 +47,11 @@ }, "devDependencies": { "@types/node": "^25.5.2", + "@vitest/coverage-v8": "^4.1.5", "ts-node": "^10.9.2", "tsdown": "^0.12.9", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4360fdf..8d1c61d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@types/node': specifier: ^25.5.2 version: 25.5.2 + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@25.5.2)(typescript@6.0.2) @@ -30,6 +33,9 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.2 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.5.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) packages: @@ -54,19 +60,32 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -248,9 +267,18 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@oxc-project/types@0.123.0': resolution: {integrity: sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -290,30 +318,60 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.13': resolution: {integrity: sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.13': resolution: {integrity: sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.13': resolution: {integrity: sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13': resolution: {integrity: sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13': resolution: {integrity: sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -321,6 +379,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.13': resolution: {integrity: sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -328,6 +393,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13': resolution: {integrity: sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -335,6 +407,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13': resolution: {integrity: sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -342,6 +421,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.13': resolution: {integrity: sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -349,6 +435,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.13': resolution: {integrity: sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -356,32 +449,68 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.13': resolution: {integrity: sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.13': resolution: {integrity: sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13': resolution: {integrity: sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.13': resolution: {integrity: sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-rc.13': resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -397,9 +526,56 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -416,10 +592,17 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -441,6 +624,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -453,6 +640,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -472,6 +662,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -505,6 +699,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -518,6 +715,13 @@ packages: engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -563,6 +767,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -578,15 +786,107 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lodash.identity@3.0.0: resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} @@ -599,6 +899,16 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -617,17 +927,32 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -670,6 +995,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -691,6 +1021,26 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} @@ -699,6 +1049,14 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -760,6 +1118,95 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -799,16 +1246,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.1': dependencies: tslib: 2.8.1 @@ -819,6 +1279,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -937,8 +1402,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@oxc-project/types@0.123.0': {} + '@oxc-project/types@0.127.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -969,39 +1443,75 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-rc.13': optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.13': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.13': optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.13': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.13': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.13': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.13': dependencies: '@emnapi/core': 1.9.1 @@ -1009,14 +1519,31 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.13': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + '@rolldown/pluginutils@1.0.0-rc.13': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -1030,10 +1557,74 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@25.5.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 @@ -1044,11 +1635,19 @@ snapshots: arg@4.1.3: {} + assertion-error@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.2 pathe: 2.0.3 + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} axios@1.13.6: @@ -1073,6 +1672,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1083,6 +1684,8 @@ snapshots: commander@14.0.3: {} + convert-source-map@2.0.0: {} + create-require@1.1.1: {} debug@4.4.3: @@ -1093,6 +1696,8 @@ snapshots: delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} + diff@4.0.4: {} diff@8.0.4: {} @@ -1111,6 +1716,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -1151,6 +1758,12 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1194,6 +1807,8 @@ snapshots: gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -1206,10 +1821,76 @@ snapshots: hookable@5.5.3: {} + html-escaper@2.0.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.6.1: {} + js-tokens@10.0.0: {} + jsesc@3.1.0: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lodash.identity@3.0.0: {} lodash.merge@4.6.2: {} @@ -1218,6 +1899,20 @@ snapshots: long@5.3.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + make-error@1.3.6: {} math-intrinsics@1.1.0: {} @@ -1230,12 +1925,24 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.12: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@4.0.4: {} + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -1301,6 +2008,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.13 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.13 + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + semver@7.7.4: {} side-channel-list@1.0.0: @@ -1331,6 +2059,20 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: @@ -1338,6 +2080,13 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + ts-node@10.9.2(@types/node@25.5.2)(typescript@6.0.2): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -1408,6 +2157,53 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + vite@8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.5.2 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.6.1 + tsx: 4.21.0 + + vitest@4.1.5(@types/node@25.5.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.2 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.20.0: {} yn@3.1.1: {} diff --git a/tests/converter.test.ts b/tests/converter.test.ts new file mode 100644 index 0000000..08eb523 --- /dev/null +++ b/tests/converter.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { parseWikiUrl } from '../src/converter.js'; + +describe('parseWikiUrl', () => { + it('parses docx URL', () => { + const result = parseWikiUrl('https://example.feishu.cn/docx/abc123'); + expect(result).toEqual({ docType: 'docx', docToken: 'abc123', sheetId: undefined }); + }); + + it('parses wiki URL', () => { + const result = parseWikiUrl('https://example.feishu.cn/wiki/XYZ789'); + expect(result).toEqual({ docType: 'wiki', docToken: 'XYZ789', sheetId: undefined }); + }); + + it('parses docs URL', () => { + const result = parseWikiUrl('https://example.feishu.cn/docs/token123'); + expect(result).toEqual({ docType: 'docs', docToken: 'token123', sheetId: undefined }); + }); + + it('parses sheets URL with sheet query param', () => { + const result = parseWikiUrl('https://example.feishu.cn/sheets/abc123?sheet=Sheet1'); + expect(result).toEqual({ docType: 'sheets', docToken: 'abc123', sheetId: 'Sheet1' }); + }); + + it('parses sheets URL without sheet param', () => { + const result = parseWikiUrl('https://example.feishu.cn/sheets/abc123'); + expect(result).toEqual({ docType: 'sheets', docToken: 'abc123', sheetId: undefined }); + }); + + it('handles custom domain', () => { + const result = parseWikiUrl('https://mycompany.larksuite.com/docx/TOKEN'); + expect(result).toEqual({ docType: 'docx', docToken: 'TOKEN', sheetId: undefined }); + }); + + it('throws on invalid URL', () => { + expect(() => parseWikiUrl('https://example.com/invalid/path')).toThrow('Invalid feishu document URL'); + expect(() => parseWikiUrl('not a url')).toThrow(); + }); + + it('handles URL with path suffix', () => { + const result = parseWikiUrl('https://a.feishu.cn/docx/abc123/extra'); + expect(result.docToken).toBe('abc123'); + }); +}); diff --git a/tests/core/registry.test.ts b/tests/core/registry.test.ts new file mode 100644 index 0000000..51801df --- /dev/null +++ b/tests/core/registry.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { Registry } from '../../src/core/registry.js'; + +describe('Registry', () => { + it('registers and retrieves a value', () => { + const reg = new Registry(); + reg.register('a', 1); + expect(reg.get('a')).toBe(1); + }); + + it('returns undefined for unregistered key', () => { + const reg = new Registry(); + expect(reg.get('missing')).toBeUndefined(); + }); + + it('has() returns true/false correctly', () => { + const reg = new Registry(); + reg.register('x', 42); + expect(reg.has('x')).toBe(true); + expect(reg.has('y')).toBe(false); + }); + + it('overwrites existing key', () => { + const reg = new Registry(); + reg.register('k', 'old'); + reg.register('k', 'new'); + expect(reg.get('k')).toBe('new'); + }); + + it('getAll returns a read-only map of all entries', () => { + const reg = new Registry(); + reg.register('a', 1); + reg.register('b', 2); + const all = reg.getAll(); + expect(all.size).toBe(2); + expect(all.get('a')).toBe(1); + expect(all.get('b')).toBe(2); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..04e23d3 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createLogger, setLogLevel } from '../src/logger.js'; +import { LoggerLevel } from '@larksuiteoapi/node-sdk'; + +describe('logger', () => { + let writeSpy: any; + let writes: string[] = []; + + beforeEach(() => { + writes = []; + writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => { + writes.push(String(chunk)); + return true; + }); + // 每个测试开始前把级别设成最高(trace)以便所有 log 都能输出 + setLogLevel(LoggerLevel.trace); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('createLogger returns object with all level methods', () => { + const l = createLogger('test'); + expect(typeof l.fatal).toBe('function'); + expect(typeof l.error).toBe('function'); + expect(typeof l.warn).toBe('function'); + expect(typeof l.info).toBe('function'); + expect(typeof l.debug).toBe('function'); + expect(typeof l.trace).toBe('function'); + }); + + it('writes each level with correct label', () => { + const l = createLogger('mod'); + l.fatal('fm'); + l.error('em'); + l.warn('wm'); + l.info('im'); + l.debug('dm'); + l.trace('tm'); + expect(writes.length).toBe(6); + expect(writes[0]).toContain('[FATAL]'); + expect(writes[0]).toContain('[mod]'); + expect(writes[0]).toContain('fm'); + expect(writes[1]).toContain('[ERROR]'); + expect(writes[2]).toContain('[WARN]'); + expect(writes[3]).toContain('[INFO]'); + expect(writes[4]).toContain('[DEBUG]'); + expect(writes[5]).toContain('[TRACE]'); + }); + + it('includes ISO timestamp', () => { + const l = createLogger('mod'); + l.info('hi'); + expect(writes[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/); + }); + + it('joins multiple args with spaces and coerces to string', () => { + const l = createLogger('mod'); + l.info('a', 1, true, { toString: () => 'X' }); + expect(writes[0]).toContain('a 1 true X'); + }); + + it('applies ANSI color and reset codes', () => { + const l = createLogger('mod'); + l.error('oops'); + expect(writes[0]).toContain('\x1b[31m'); // red for error + expect(writes[0]).toContain('\x1b[0m'); // reset + }); + + it('setLogLevel filters lower-priority messages', () => { + const l = createLogger('mod'); + setLogLevel(LoggerLevel.warn); + + l.fatal('f'); // fatal=0 <= warn=2 → show + l.error('e'); // error=1 <= warn=2 → show + l.warn('w'); // warn=2 <= warn=2 → show + l.info('i'); // info=3 > warn=2 → skip + l.debug('d'); // skip + l.trace('t'); // skip + + expect(writes.length).toBe(3); + expect(writes[0]).toContain('[FATAL]'); + expect(writes[1]).toContain('[ERROR]'); + expect(writes[2]).toContain('[WARN]'); + }); + + it('setLogLevel=fatal only allows fatal', () => { + const l = createLogger('mod'); + setLogLevel(LoggerLevel.fatal); + l.fatal('f'); + l.error('e'); + l.info('i'); + expect(writes.length).toBe(1); + expect(writes[0]).toContain('[FATAL]'); + }); + + it('each logger instance carries its own module tag', () => { + const a = createLogger('modA'); + const b = createLogger('modB'); + a.info('x'); + b.info('y'); + expect(writes[0]).toContain('[modA]'); + expect(writes[1]).toContain('[modB]'); + }); + + it('writes end with newline', () => { + const l = createLogger('mod'); + l.info('line'); + expect(writes[0]!.endsWith('\n')).toBe(true); + }); +}); diff --git a/tests/md-ast/parser.test.ts b/tests/md-ast/parser.test.ts new file mode 100644 index 0000000..e46e515 --- /dev/null +++ b/tests/md-ast/parser.test.ts @@ -0,0 +1,458 @@ +import { describe, it, expect } from 'vitest'; +import { Parser } from '../../src/md-ast/parser.js'; +import { registerBuiltinParsers } from '../../src/md-ast/parsers/index.js'; +import { MdSerializer, registerBuiltinSerializers } from '../../src/md-ast/serializer.js'; +import type { DocxBlock, DocInfo } from '../../src/types.js'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function textRun (content: string, style?: any) { + return { text_run: { content, text_element_style: style } }; +} + +function b (partial: Partial): DocxBlock { + return partial as unknown as DocxBlock; +} + +function buildParser (): Parser { + const p = new Parser(); + registerBuiltinParsers(p); + return p; +} + +const doc: DocInfo = { documentId: 'root', title: 'Doc' }; + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe('Parser - end-to-end', () => { + it('parses a simple document with heading + paragraph', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, page: { elements: [textRun('Title')] }, children: ['h1', 'p1'] }), + b({ block_id: 'h1', block_type: 3, parent_id: 'root', heading1: { elements: [textRun('Hello')] } }), + b({ block_id: 'p1', block_type: 2, parent_id: 'root', text: { elements: [textRun('World')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + expect(ast.type).toBe('page'); + if (ast.type !== 'page') return; + expect(ast.title).toEqual([{ type: 'text', content: 'Title' }]); + expect(ast.children).toHaveLength(2); + expect(ast.children[0]).toMatchObject({ type: 'heading', level: 1 }); + expect(ast.children[1]).toMatchObject({ type: 'paragraph' }); + }); + + it('parses all heading levels 1~9', () => { + const children = Array.from({ length: 9 }, (_, i) => `h${i + 1}`); + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children }), + ...children.map((id, i) => { + const level = i + 1; + return b({ + block_id: id, + block_type: level + 2, + parent_id: 'root', + [`heading${level}`]: { elements: [textRun(`H${level}`)] }, + } as any); + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + for (let i = 0; i < 9; i++) { + const node = ast.children[i]; + expect(node).toMatchObject({ type: 'heading', level: i + 1 }); + } + }); + + it('parses inline styles (bold/italic/underline/strikethrough/inlineCode/link)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['p'] }), + b({ + block_id: 'p', + block_type: 2, + parent_id: 'root', + text: { + elements: [ + textRun('bold', { bold: true }), + textRun('italic', { italic: true }), + textRun('strike', { strikethrough: true }), + textRun('under', { underline: true }), + textRun('code', { inline_code: true }), + textRun('linked', { link: { url: 'https%3A%2F%2Fexample.com' } }), + ], + }, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const p = ast.children[0]; + if (p?.type !== 'paragraph') throw new Error(); + expect(p.children).toMatchObject([ + { type: 'bold' }, + { type: 'italic' }, + { type: 'strikethrough' }, + { type: 'underline' }, + { type: 'inlineCode', content: 'code' }, + { type: 'link', url: 'https://example.com' }, + ]); + }); + + it('parses combined inline styles (bold + italic + link)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['p'] }), + b({ + block_id: 'p', + block_type: 2, + parent_id: 'root', + text: { elements: [textRun('x', { bold: true, italic: true, link: { url: 'http%3A%2F%2Fa' } })] }, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const p = ast.children[0]; + if (p?.type !== 'paragraph') throw new Error(); + // 包裹顺序:link → italic → bold(最外层) + expect(p.children[0]).toMatchObject({ + type: 'bold', + children: [{ type: 'italic', children: [{ type: 'link', url: 'http://a' }] }], + }); + }); + + it('parses mention_user and mention_doc', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['p'] }), + b({ + block_id: 'p', + block_type: 2, + parent_id: 'root', + text: { + elements: [ + { mention_user: { user_id: 'U123' } }, + { mention_doc: { title: 'Doc', url: 'https%3A%2F%2Fd.com' } }, + ], + } as any, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const p = ast.children[0]; + if (p?.type !== 'paragraph') throw new Error(); + expect(p.children).toMatchObject([ + { type: 'mentionUser', userId: 'U123' }, + { type: 'mentionDoc', title: 'Doc', url: 'https://d.com' }, + ]); + }); + + it('parses equation (inline when among others, block when only element)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['eq1', 'eq2'] }), + b({ + block_id: 'eq1', + block_type: 16, + parent_id: 'root', + equation: { elements: [{ equation: { content: 'x+1\n' } }] } as any, + }), + b({ + block_id: 'eq2', + block_type: 16, + parent_id: 'root', + equation: { elements: [textRun('prefix '), { equation: { content: 'y' } }] } as any, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const p1 = ast.children[0]; + const p2 = ast.children[1]; + if (p1?.type !== 'paragraph' || p2?.type !== 'paragraph') throw new Error(); + // 单元素 → 块级公式 + expect(p1.children[0]).toMatchObject({ type: 'equation', content: 'x+1', inline: false }); + // 多元素 → inline + expect(p2.children[1]).toMatchObject({ type: 'equation', content: 'y', inline: true }); + }); + + it('parses bullet list with nested children', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['b1'] }), + b({ + block_id: 'b1', + block_type: 12, + parent_id: 'root', + bullet: { elements: [textRun('item1')] }, + children: ['b2'], + }), + b({ + block_id: 'b2', + block_type: 12, + parent_id: 'b1', + bullet: { elements: [textRun('nested')] }, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const bullet = ast.children[0]; + if (bullet?.type !== 'bullet') throw new Error(); + expect(bullet.text).toMatchObject([{ type: 'text', content: 'item1' }]); + expect(bullet.children[0]).toMatchObject({ type: 'bullet' }); + }); + + it('parses ordered list with correct auto-increment order', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['o1', 'o2', 'o3'] }), + b({ block_id: 'o1', block_type: 13, parent_id: 'root', ordered: { elements: [textRun('a')] } }), + b({ block_id: 'o2', block_type: 13, parent_id: 'root', ordered: { elements: [textRun('b')] } }), + b({ block_id: 'o3', block_type: 13, parent_id: 'root', ordered: { elements: [textRun('c')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect((ast.children[0] as any).order).toBe(1); + expect((ast.children[1] as any).order).toBe(2); + expect((ast.children[2] as any).order).toBe(3); + }); + + it('ordered list restarts counter when preceded by non-ordered', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['p1', 'o1', 'o2'] }), + b({ block_id: 'p1', block_type: 2, parent_id: 'root', text: { elements: [textRun('p')] } }), + b({ block_id: 'o1', block_type: 13, parent_id: 'root', ordered: { elements: [textRun('a')] } }), + b({ block_id: 'o2', block_type: 13, parent_id: 'root', ordered: { elements: [textRun('b')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect((ast.children[1] as any).order).toBe(1); + expect((ast.children[2] as any).order).toBe(2); + }); + + it('parses code block with language', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['c'] }), + b({ + block_id: 'c', + block_type: 14, + parent_id: 'root', + code: { + elements: [textRun('const x = 1;')], + style: { language: 63 }, // typescript + } as any, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect(ast.children[0]).toMatchObject({ type: 'codeBlock', lang: 'typescript', content: 'const x = 1;' }); + }); + + it('parses code block with unknown language falls back to ""', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['c'] }), + b({ + block_id: 'c', + block_type: 14, + parent_id: 'root', + code: { elements: [textRun('x')], style: { language: 9999 } } as any, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect((ast.children[0] as any).lang).toBe(''); + }); + + it('parses todo (checked / unchecked)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['t1', 't2'] }), + b({ + block_id: 't1', + block_type: 17, + parent_id: 'root', + todo: { elements: [textRun('done')], style: { done: true } } as any, + }), + b({ + block_id: 't2', + block_type: 17, + parent_id: 'root', + todo: { elements: [textRun('undone')] } as any, + }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect(ast.children[0]).toMatchObject({ type: 'todo', checked: true }); + expect(ast.children[1]).toMatchObject({ type: 'todo', checked: false }); + }); + + it('parses callout (container with children)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['co'] }), + b({ block_id: 'co', block_type: 19, parent_id: 'root', children: ['cp'] }), + b({ block_id: 'cp', block_type: 2, parent_id: 'co', text: { elements: [textRun('tip')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const callout = ast.children[0]; + expect(callout?.type).toBe('callout'); + if (callout?.type !== 'callout') return; + expect(callout.children).toHaveLength(1); + }); + + it('parses divider', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['d'] }), + b({ block_id: 'd', block_type: 22, parent_id: 'root' }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect(ast.children[0]).toEqual({ type: 'divider' }); + }); + + it('parses image, sheet, whiteboard (leaf tokens)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['img', 'sh', 'wb'] }), + b({ block_id: 'img', block_type: 27, parent_id: 'root', image: { token: 'IMG_T' } as any }), + b({ block_id: 'sh', block_type: 30, parent_id: 'root', sheet: { token: 'SHEET_T' } as any }), + b({ block_id: 'wb', block_type: 43, parent_id: 'root', board: { token: 'BOARD_T' } as any }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect(ast.children[0]).toEqual({ type: 'image', alt: '图片-IMG_T', src: 'IMG_T' }); + expect(ast.children[1]).toEqual({ type: 'sheet', token: 'SHEET_T' }); + expect(ast.children[2]).toEqual({ type: 'whiteboard', token: 'BOARD_T' }); + }); + + it('parses quote (inline) and quoteContainer (block)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['q1', 'qc'] }), + b({ + block_id: 'q1', + block_type: 15, + parent_id: 'root', + quote: { elements: [textRun('inline quote')] } as any, + }), + b({ block_id: 'qc', block_type: 34, parent_id: 'root', children: ['qcp'] }), + b({ block_id: 'qcp', block_type: 2, parent_id: 'qc', text: { elements: [textRun('container')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect(ast.children[0]).toMatchObject({ type: 'quote' }); + expect(ast.children[1]).toMatchObject({ type: 'quote' }); + }); + + it('parses grid (2 columns with children each)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['g'] }), + b({ block_id: 'g', block_type: 24, parent_id: 'root', children: ['col1', 'col2'] }), + b({ block_id: 'col1', block_type: 25, parent_id: 'g', children: ['pa'] }), + b({ block_id: 'col2', block_type: 25, parent_id: 'g', children: ['pb'] }), + b({ block_id: 'pa', block_type: 2, parent_id: 'col1', text: { elements: [textRun('A')] } }), + b({ block_id: 'pb', block_type: 2, parent_id: 'col2', text: { elements: [textRun('B')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const grid = ast.children[0]; + if (grid?.type !== 'grid') throw new Error(); + expect(grid.children).toHaveLength(2); + }); + + it('parses simple 2x2 table', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['tbl'] }), + b({ + block_id: 'tbl', + block_type: 31, + parent_id: 'root', + table: { + property: { column_size: 2, row_size: 2 }, + cells: ['c00', 'c01', 'c10', 'c11'], + } as any, + }), + b({ block_id: 'c00', block_type: 32, parent_id: 'tbl', children: ['t00'] }), + b({ block_id: 'c01', block_type: 32, parent_id: 'tbl', children: ['t01'] }), + b({ block_id: 'c10', block_type: 32, parent_id: 'tbl', children: ['t10'] }), + b({ block_id: 'c11', block_type: 32, parent_id: 'tbl', children: ['t11'] }), + b({ block_id: 't00', block_type: 2, parent_id: 'c00', text: { elements: [textRun('A')] } }), + b({ block_id: 't01', block_type: 2, parent_id: 'c01', text: { elements: [textRun('B')] } }), + b({ block_id: 't10', block_type: 2, parent_id: 'c10', text: { elements: [textRun('C')] } }), + b({ block_id: 't11', block_type: 2, parent_id: 'c11', text: { elements: [textRun('D')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const table = ast.children[0]; + if (table?.type !== 'table') throw new Error(); + expect(table.rows).toHaveLength(2); + expect(table.rows[0]!.cells).toHaveLength(2); + expect(table.rows[0]!.cells[0]!.content[0]).toMatchObject({ type: 'text', content: 'A' }); + }); + + it('parses table with merge (covered cells are skipped)', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['tbl'] }), + b({ + block_id: 'tbl', + block_type: 31, + parent_id: 'root', + table: { + property: { + column_size: 2, + row_size: 2, + merge_info: [ + { row_span: 1, col_span: 2 }, + { row_span: 1, col_span: 1 }, // covered + { row_span: 1, col_span: 1 }, + { row_span: 1, col_span: 1 }, + ], + }, + cells: ['c00', 'c01', 'c10', 'c11'], + } as any, + }), + b({ block_id: 'c00', block_type: 32, parent_id: 'tbl', children: ['t00'] }), + b({ block_id: 'c01', block_type: 32, parent_id: 'tbl', children: [] }), + b({ block_id: 'c10', block_type: 32, parent_id: 'tbl', children: ['t10'] }), + b({ block_id: 'c11', block_type: 32, parent_id: 'tbl', children: ['t11'] }), + b({ block_id: 't00', block_type: 2, parent_id: 'c00', text: { elements: [textRun('A')] } }), + b({ block_id: 't10', block_type: 2, parent_id: 'c10', text: { elements: [textRun('C')] } }), + b({ block_id: 't11', block_type: 2, parent_id: 'c11', text: { elements: [textRun('D')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + const table = ast.children[0]; + if (table?.type !== 'table') throw new Error(); + // Row 0 should only have 1 cell (colspan=2) + expect(table.rows[0]!.cells).toHaveLength(1); + expect(table.rows[0]!.cells[0]!.colSpan).toBe(2); + // Row 1 still has 2 cells + expect(table.rows[1]!.cells).toHaveLength(2); + }); + + it('falls back to root-level blocks when documentId is missing', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'p1', block_type: 2, text: { elements: [textRun('orphan')] } }), + ]; + const ast = buildParser().parse(doc, blocks); + expect(ast.type).toBe('page'); + if (ast.type !== 'page') return; + expect(ast.title).toEqual([]); + expect(ast.children).toHaveLength(1); + }); + + it('unknown block_type yields null and is skipped', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, children: ['x'] }), + b({ block_id: 'x', block_type: 999, parent_id: 'root' } as any), + ]; + const ast = buildParser().parse(doc, blocks); + if (ast.type !== 'page') throw new Error(); + expect(ast.children).toHaveLength(0); + }); + + // ─── Parser + Serializer integration ────────────────────────────────────── + it('end-to-end: parse → serialize produces expected markdown', () => { + const blocks: DocxBlock[] = [ + b({ block_id: 'root', block_type: 1, page: { elements: [textRun('My')] }, children: ['h', 'p', 'd'] }), + b({ block_id: 'h', block_type: 4, parent_id: 'root', heading2: { elements: [textRun('Sub')] } }), + b({ block_id: 'p', block_type: 2, parent_id: 'root', text: { elements: [textRun('hi')] } }), + b({ block_id: 'd', block_type: 22, parent_id: 'root' }), + ]; + const ast = buildParser().parse(doc, blocks); + const s = new MdSerializer(); + registerBuiltinSerializers(s); + const md = s.serialize(ast); + expect(md).toContain('# My'); + expect(md).toContain('## Sub'); + expect(md).toContain('hi'); + expect(md).toContain('---'); + }); +}); diff --git a/tests/md-ast/serializer.test.ts b/tests/md-ast/serializer.test.ts new file mode 100644 index 0000000..b71ab93 --- /dev/null +++ b/tests/md-ast/serializer.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { MdSerializer, registerBuiltinSerializers } from '../../src/md-ast/serializer.js'; +import type { MdBlockNode, MdInlineNode } from '../../src/md-ast/types.js'; + +function createSerializer (): MdSerializer { + const s = new MdSerializer(); + registerBuiltinSerializers(s); + return s; +} + +const text = (content: string): MdInlineNode => ({ type: 'text', content }); +const bold = (content: string): MdInlineNode => ({ type: 'bold', children: [text(content)] }); +const italic = (content: string): MdInlineNode => ({ type: 'italic', children: [text(content)] }); +const code = (content: string): MdInlineNode => ({ type: 'inlineCode', content }); +const link = (label: string, url: string): MdInlineNode => ({ type: 'link', url, children: [text(label)] }); + +describe('MdSerializer', () => { + const s = createSerializer(); + + describe('heading', () => { + it('serializes h1~h6', () => { + for (let level = 1; level <= 6; level++) { + const node: MdBlockNode = { type: 'heading', level, children: [text('Title')] }; + expect(s.serialize(node)).toBe(`${'#'.repeat(level)} Title\n\n`); + } + }); + + it('serializes inline marks in headings', () => { + const node: MdBlockNode = { type: 'heading', level: 2, children: [bold('Important')] }; + expect(s.serialize(node)).toBe('## **Important**\n\n'); + }); + }); + + describe('paragraph', () => { + it('serializes plain text', () => { + const node: MdBlockNode = { type: 'paragraph', children: [text('Hello world')] }; + expect(s.serialize(node)).toBe('Hello world\n\n'); + }); + + it('serializes mixed inline nodes', () => { + const node: MdBlockNode = { + type: 'paragraph', + children: [text('Use '), code('vitest'), text(' for testing')], + }; + expect(s.serialize(node)).toBe('Use `vitest` for testing\n\n'); + }); + }); + + describe('bullet list', () => { + it('serializes simple bullet', () => { + const node: MdBlockNode = { type: 'bullet', text: [text('item')], children: [] }; + expect(s.serialize(node)).toBe('- item\n'); + }); + + it('serializes nested bullets', () => { + const child: MdBlockNode = { type: 'bullet', text: [text('child')], children: [] }; + const node: MdBlockNode = { type: 'bullet', text: [text('parent')], children: [child] }; + const result = s.serialize(node); + expect(result).toContain('- parent'); + expect(result).toContain('\t- child'); + }); + }); + + describe('ordered list', () => { + it('serializes with order number', () => { + const node: MdBlockNode = { type: 'ordered', order: 1, text: [text('first')], children: [] }; + expect(s.serialize(node)).toBe('1. first\n'); + }); + }); + + describe('code block', () => { + it('serializes with language', () => { + const node: MdBlockNode = { type: 'codeBlock', lang: 'ts', content: 'const x = 1;' }; + expect(s.serialize(node)).toBe('```ts\nconst x = 1;\n```\n\n'); + }); + }); + + describe('todo', () => { + it('serializes unchecked', () => { + const node: MdBlockNode = { type: 'todo', checked: false, text: [text('task')] }; + expect(s.serialize(node)).toBe('- [ ] task\n'); + }); + + it('serializes checked', () => { + const node: MdBlockNode = { type: 'todo', checked: true, text: [text('done')] }; + expect(s.serialize(node)).toBe('- [x] done\n'); + }); + }); + + describe('divider', () => { + it('serializes as ---', () => { + const node: MdBlockNode = { type: 'divider' }; + expect(s.serialize(node)).toBe('---\n\n'); + }); + }); + + describe('image', () => { + it('serializes as markdown image', () => { + const node: MdBlockNode = { type: 'image', alt: 'pic', src: 'http://img.png' }; + expect(s.serialize(node)).toBe('![pic](http://img.png)\n'); + }); + }); + + describe('quote', () => { + it('serializes children with > prefix', () => { + const node: MdBlockNode = { + type: 'quote', + children: [{ type: 'paragraph', children: [text('quoted')] }], + }; + expect(s.serialize(node)).toContain('> quoted'); + }); + }); + + describe('callout', () => { + it('serializes with >[!TIP] prefix', () => { + const node: MdBlockNode = { + type: 'callout', + children: [{ type: 'paragraph', children: [text('note')] }], + }; + const result = s.serialize(node); + expect(result).toContain('>[!TIP]'); + expect(result).toContain('> note'); + }); + }); + + describe('table', () => { + it('serializes as HTML table', () => { + const node: MdBlockNode = { + type: 'table', + rows: [ + { cells: [{ content: [text('A')] }, { content: [text('B')] }] }, + { cells: [{ content: [text('C')] }, { content: [text('D')] }] }, + ], + }; + const result = s.serialize(node); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('
A
'); + }); + + it('serializes colspan/rowspan', () => { + const node: MdBlockNode = { + type: 'table', + rows: [{ cells: [{ content: [text('X')], colSpan: 2, rowSpan: 3 }] }], + }; + const result = s.serialize(node); + expect(result).toContain('rowspan="3"'); + expect(result).toContain('colspan="2"'); + }); + }); + + describe('page', () => { + it('serializes title as h1 + children', () => { + const node: MdBlockNode = { + type: 'page', + title: [text('My Doc')], + children: [{ type: 'paragraph', children: [text('content')] }], + }; + const result = s.serialize(node); + expect(result.startsWith('# My Doc\n\n')).toBe(true); + expect(result).toContain('content'); + }); + + it('omits title when empty', () => { + const node: MdBlockNode = { + type: 'page', + title: [], + children: [{ type: 'divider' }], + }; + const result = s.serialize(node); + expect(result).not.toContain('#'); + expect(result).toContain('---'); + }); + }); + + describe('inline nodes', () => { + it('bold', () => { + const node: MdBlockNode = { type: 'paragraph', children: [bold('text')] }; + expect(s.serialize(node)).toContain('**text**'); + }); + + it('italic', () => { + const node: MdBlockNode = { type: 'paragraph', children: [italic('text')] }; + expect(s.serialize(node)).toContain('_text_'); + }); + + it('strikethrough', () => { + const node: MdBlockNode = { + type: 'paragraph', + children: [{ type: 'strikethrough', children: [text('del')] }], + }; + expect(s.serialize(node)).toContain('~~del~~'); + }); + + it('underline', () => { + const node: MdBlockNode = { + type: 'paragraph', + children: [{ type: 'underline', children: [text('u')] }], + }; + expect(s.serialize(node)).toContain('u'); + }); + + it('link', () => { + const node: MdBlockNode = { type: 'paragraph', children: [link('click', 'http://x')] }; + expect(s.serialize(node)).toContain('[click](http://x)'); + }); + + it('equation inline', () => { + const node: MdBlockNode = { + type: 'paragraph', + children: [{ type: 'equation', content: 'E=mc^2', inline: true }], + }; + expect(s.serialize(node)).toContain('$E=mc^2$'); + }); + + it('equation block', () => { + const node: MdBlockNode = { + type: 'paragraph', + children: [{ type: 'equation', content: 'x^2', inline: false }], + }; + expect(s.serialize(node)).toContain('$$x^2$$'); + }); + }); +}); diff --git a/tests/md-ast/transformer.test.ts b/tests/md-ast/transformer.test.ts new file mode 100644 index 0000000..e04d869 --- /dev/null +++ b/tests/md-ast/transformer.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { MdTransformer } from '../../src/md-ast/transformer.js'; +import type { MdBlockNode } from '../../src/md-ast/types.js'; +import type { ConvertOptions } from '../../src/types.js'; +import type { LarkClient } from '../../src/client.js'; + +// ─── helpers ───────────────────────────────────────────────────────────── + +function makeOpts (overrides: Partial = {}): ConvertOptions { + return { + appId: 'x', + appSecret: 'x', + url: 'https://example.com/docx/TOK', + output: '/tmp/not-used', + imageMode: 'online', + wbImageMode: 'online', + wbBg: 'none', + wbFormat: 'svg', + ...overrides, + }; +} + +/** 极简 mock client,按需覆盖单个方法 */ +function mockClient (overrides: Partial = {}): LarkClient { + const base: any = { + getWikiNodeInfo: vi.fn(), + getDocxDocument: vi.fn(), + getDocxBlocks: vi.fn(), + batchGetTmpDownloadUrl: vi.fn().mockResolvedValue({}), + downloadImage: vi.fn(), + getWhiteboardNodes: vi.fn().mockResolvedValue([]), + downloadWhiteboardAsImage: vi.fn(), + getSpreadsheetInfo: vi.fn(), + listSheets: vi.fn(), + getSheetMeta: vi.fn(), + readSheetValues: vi.fn(), + }; + return { ...base, ...overrides } as LarkClient; +} + +// ─── image resolution ──────────────────────────────────────────────────── + +describe('MdTransformer - image resolution (online)', () => { + it('resolves image src via batchGetTmpDownloadUrl', async () => { + const client = mockClient({ + batchGetTmpDownloadUrl: vi.fn().mockResolvedValue({ + TOK1: 'https://cdn/1.png', + TOK2: 'https://cdn/2.png', + }), + } as any); + const tr = new MdTransformer(client, makeOpts({ imageMode: 'online' })); + + const ast: MdBlockNode = { + type: 'page', + title: [], + children: [ + { type: 'image', alt: 'a', src: 'TOK1' }, + { type: 'image', alt: 'b', src: 'TOK2' }, + ], + }; + await tr.transform(ast); + const images = (ast.type === 'page' ? ast.children : []) as any[]; + expect(images[0].src).toBe('https://cdn/1.png'); + expect(images[1].src).toBe('https://cdn/2.png'); + }); + + it('deduplicates tokens and batches by 5', async () => { + const batchFn = vi.fn().mockResolvedValue({}); + const client = mockClient({ batchGetTmpDownloadUrl: batchFn } as any); + const tr = new MdTransformer(client, makeOpts({ imageMode: 'online' })); + const tokens = Array.from({ length: 12 }, (_, i) => `T${i}`); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [ + ...tokens.map(t => ({ type: 'image' as const, alt: '', src: t })), + // 重复 token 不再触发额外调用 + { type: 'image', alt: '', src: 'T0' }, + ], + }; + await tr.transform(ast); + expect(batchFn).toHaveBeenCalledTimes(3); // 12 tokens / 5 = 3 batches (5,5,2) + }); + + it('agent=true forces online mode', async () => { + const batchFn = vi.fn().mockResolvedValue({ T: 'https://cdn/t' }); + const client = mockClient({ batchGetTmpDownloadUrl: batchFn } as any); + const tr = new MdTransformer(client, makeOpts({ imageMode: 'local', agent: true })); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'image', alt: '', src: 'T' }], + }; + await tr.transform(ast); + expect(batchFn).toHaveBeenCalled(); + const img = (ast as any).children[0]; + expect(img.src).toBe('https://cdn/t'); + }); +}); + +describe('MdTransformer - image resolution (local)', () => { + let tmpDir: string; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('downloads images and rewrites src to relative path', async () => { + const downloadImage = vi.fn().mockImplementation(async (token: string, outDir: string) => { + fs.mkdirSync(outDir, { recursive: true }); + const p = path.join(outDir, `${token}.png`); + fs.writeFileSync(p, 'fake'); + return p; + }); + const client = mockClient({ downloadImage } as any); + const tr = new MdTransformer(client, makeOpts({ imageMode: 'local', output: tmpDir })); + + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'image', alt: '', src: 'IMG1' }], + }; + await tr.transform(ast); + expect(downloadImage).toHaveBeenCalledWith('IMG1', path.join(tmpDir, 'static')); + const img = (ast as any).children[0]; + expect(img.src).toBe(path.join('static', 'IMG1.png')); + }); +}); + +// ─── whiteboard resolution ────────────────────────────────────────────── + +describe('MdTransformer - whiteboard SVG resolution', () => { + let tmpDir: string; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wb-')); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('writes whiteboard SVG to static/ and replaces node with image', async () => { + const client = mockClient({ + getWhiteboardNodes: vi.fn().mockResolvedValue([]), + } as any); + const tr = new MdTransformer(client, makeOpts({ wbFormat: 'svg', output: tmpDir })); + + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'whiteboard', token: 'WB1' }], + }; + await tr.transform(ast); + const replaced = (ast as any).children[0]; + expect(replaced.type).toBe('image'); + expect(replaced.src).toBe(path.join('static', 'WB1.svg')); + // 文件真的写入了 + expect(fs.existsSync(path.join(tmpDir, 'static', 'WB1.svg'))).toBe(true); + }); + + it('base64 format inlines SVG as data URI', async () => { + const client = mockClient() as any; + const tr = new MdTransformer(client, makeOpts({ wbFormat: 'base64' })); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'whiteboard', token: 'WB2' }], + }; + await tr.transform(ast); + const replaced = (ast as any).children[0]; + expect(replaced.type).toBe('image'); + expect(replaced.src).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it('inline-svg format converts node to html', async () => { + const client = mockClient() as any; + const tr = new MdTransformer(client, makeOpts({ wbFormat: 'inline-svg' })); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'whiteboard', token: 'WB3' }], + }; + await tr.transform(ast); + const replaced = (ast as any).children[0]; + expect(replaced.type).toBe('html'); + expect(replaced.content).toContain(' { + const client = mockClient({ + getWhiteboardNodes: vi.fn().mockRejectedValue(new Error('fail')), + } as any); + const tr = new MdTransformer(client, makeOpts({ wbFormat: 'svg' })); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'whiteboard', token: 'WB4' }], + }; + // 不应抛错 + await expect(tr.transform(ast)).resolves.toBeUndefined(); + // 失败时节点不被替换 + const node = (ast as any).children[0]; + expect(node.type).toBe('whiteboard'); + }); +}); + +describe('MdTransformer - whiteboard YAML resolution', () => { + it('renders whiteboard as yaml codeBlock', async () => { + const client = mockClient({ + getWhiteboardNodes: vi.fn().mockResolvedValue([ + { id: 'n1', type: 'text_shape', x: 0, y: 0, width: 100, height: 40, text: { text: 'hi' } } as any, + ]), + } as any); + const tr = new MdTransformer(client, makeOpts({ wbFormat: 'yaml' })); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'whiteboard', token: 'WB_Y' }], + }; + await tr.transform(ast); + const replaced = (ast as any).children[0]; + expect(replaced.type).toBe('codeBlock'); + expect(replaced.lang).toBe('yaml'); + expect(replaced.content).toContain('hi'); + }); + + it('yaml mode with wbImageMode=base64 falls back to online', async () => { + const batchFn = vi.fn().mockResolvedValue({ IMG: 'https://cdn/img' }); + const client = mockClient({ + getWhiteboardNodes: vi.fn().mockResolvedValue([ + { id: 'i1', type: 'image', x: 0, y: 0, width: 50, height: 50, image: { token: 'IMG' } } as any, + ]), + batchGetTmpDownloadUrl: batchFn, + } as any); + const tr = new MdTransformer(client, makeOpts({ wbFormat: 'yaml', wbImageMode: 'base64' })); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'whiteboard', token: 'WB_Y2' }], + }; + await tr.transform(ast); + // 回退到 online:调用了 batchGetTmpDownloadUrl + expect(batchFn).toHaveBeenCalled(); + const replaced = (ast as any).children[0]; + expect(replaced.content).toContain('https://cdn/img'); + }); +}); + +// ─── sheet resolution ─────────────────────────────────────────────────── + +describe('MdTransformer - sheet resolution', () => { + it('resolves sheet by reading values and expanding merges', async () => { + const getSpreadsheetInfo = vi.fn().mockResolvedValue({ title: 'My Sheet' }); + const listSheets = vi.fn().mockResolvedValue([ + { sheet_id: 'SID', title: 'Sheet1', resource_type: 'sheet', hidden: false }, + ]); + const getSheetMeta = vi.fn().mockResolvedValue({ + grid_properties: { row_count: 2, column_count: 2 }, + merges: [], + }); + const readSheetValues = vi.fn().mockResolvedValue([ + ['A', 'B'], + ['1', '2'], + ]); + const client = mockClient({ + getSpreadsheetInfo, listSheets, getSheetMeta, readSheetValues, + } as any); + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'TOK_SID' }], + }; + await tr.transform(ast); + const resolved = (ast as any).children[0]; + expect(resolved.type).toBe('sheetResolved'); + expect(resolved.title).toBe('My Sheet'); + expect(resolved.sheets).toHaveLength(1); + expect(resolved.sheets[0].rows).toEqual([['A', 'B'], ['1', '2']]); + }); + + it('marks non-sheet resource types (bitable) as skipped', async () => { + const client = mockClient({ + getSpreadsheetInfo: vi.fn().mockResolvedValue({ title: '' }), + listSheets: vi.fn().mockResolvedValue([ + { sheet_id: 'SID', title: 'Bi', resource_type: 'bitable' }, + ]), + } as any); + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'TOK_SID' }], + }; + await tr.transform(ast); + const resolved = (ast as any).children[0]; + expect(resolved.sheets[0].error).toContain('非网格表(bitable)'); + }); + + it('marks empty grid as no rows', async () => { + const client = mockClient({ + getSpreadsheetInfo: vi.fn().mockResolvedValue({ title: '' }), + listSheets: vi.fn().mockResolvedValue([ + { sheet_id: 'SID', resource_type: 'sheet' }, + ]), + getSheetMeta: vi.fn().mockResolvedValue({ + grid_properties: { row_count: 0, column_count: 0 }, + }), + } as any); + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'TOK_SID' }], + }; + await tr.transform(ast); + const resolved = (ast as any).children[0]; + expect(resolved.sheets[0].rows).toEqual([]); + }); + + it('captures readSheetValues errors per-sheet', async () => { + const client = mockClient({ + getSpreadsheetInfo: vi.fn().mockResolvedValue({ title: '' }), + listSheets: vi.fn().mockResolvedValue([ + { sheet_id: 'SID', resource_type: 'sheet' }, + ]), + getSheetMeta: vi.fn().mockResolvedValue({ + grid_properties: { row_count: 1, column_count: 1 }, + }), + readSheetValues: vi.fn().mockRejectedValue(new Error('no perm')), + } as any); + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'TOK_SID' }], + }; + await tr.transform(ast); + const resolved = (ast as any).children[0]; + expect(resolved.sheets[0].error).toContain('读取失败'); + }); + + it('skips hidden sheets', async () => { + const getSheetMeta = vi.fn(); + const client = mockClient({ + getSpreadsheetInfo: vi.fn().mockResolvedValue({ title: '' }), + listSheets: vi.fn().mockResolvedValue([ + { sheet_id: 'SID', hidden: true }, + ]), + getSheetMeta, + } as any); + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'TOK_SID' }], + }; + await tr.transform(ast); + expect(getSheetMeta).not.toHaveBeenCalled(); + }); + + it('catches top-level sheet failure and yields empty sheetResolved', async () => { + const client = mockClient({ + getSpreadsheetInfo: vi.fn().mockRejectedValue(new Error('boom')), + } as any); + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'TOK_SID' }], + }; + await expect(tr.transform(ast)).resolves.toBeUndefined(); + const resolved = (ast as any).children[0]; + expect(resolved.type).toBe('sheetResolved'); + expect(resolved.sheets).toEqual([]); + }); + + it('sheet source type includes all non-hidden sheets when sheetId omitted', async () => { + const getSheetMeta = vi.fn().mockResolvedValue({ + grid_properties: { row_count: 1, column_count: 1 }, + }); + const readSheetValues = vi.fn().mockResolvedValue([['x']]); + const client = mockClient({ + getSpreadsheetInfo: vi.fn().mockResolvedValue({ title: 'S' }), + listSheets: vi.fn().mockResolvedValue([ + { sheet_id: 's1', title: 'A', resource_type: 'sheet' }, + { sheet_id: 's2', title: 'B', resource_type: 'sheet' }, + ]), + getSheetMeta, + readSheetValues, + } as any); + const tr = new MdTransformer(client, makeOpts(), 'sheet'); + // token 没有下划线 → sheetId undefined,sheet 模式下处理所有 + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'sheet', token: 'ONLYTOK' }], + }; + await tr.transform(ast); + const resolved = (ast as any).children[0]; + expect(resolved.sheets).toHaveLength(2); + }); +}); + +// ─── token collection / deep traversal ───────────────────────────────── + +describe('MdTransformer - deep AST traversal', () => { + it('finds tokens in nested containers (bullet > quote > image)', async () => { + const batchFn = vi.fn().mockResolvedValue({ NESTED: 'https://cdn/nested' }); + const client = mockClient({ batchGetTmpDownloadUrl: batchFn } as any); + const tr = new MdTransformer(client, makeOpts()); + + const imageNode: any = { type: 'image', alt: '', src: 'NESTED' }; + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ + type: 'bullet', + text: [], + children: [{ + type: 'quote', + children: [imageNode], + }], + }], + }; + await tr.transform(ast); + expect(imageNode.src).toBe('https://cdn/nested'); + }); + + it('does nothing when AST has no image/whiteboard/sheet', async () => { + const client = mockClient() as any; + const tr = new MdTransformer(client, makeOpts()); + const ast: MdBlockNode = { + type: 'page', title: [], + children: [{ type: 'paragraph', children: [{ type: 'text', content: 'hi' }] }], + }; + await tr.transform(ast); + // batchGetTmpDownloadUrl 不应被调用 + expect(client.batchGetTmpDownloadUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/sheet/index.test.ts b/tests/sheet/index.test.ts new file mode 100644 index 0000000..383284c --- /dev/null +++ b/tests/sheet/index.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { cellToMd, expandMerges, trimTrailingEmpty, columnIndexToLetter } from '../../src/sheet/index.js'; + +// ─── cellToMd ─────────────────────────────────────────────────────────────── + +describe('cellToMd', () => { + it('returns empty string for null/undefined', () => { + expect(cellToMd(null)).toBe(''); + expect(cellToMd(undefined)).toBe(''); + }); + + it('converts primitive values to string', () => { + expect(cellToMd(123)).toBe('123'); + expect(cellToMd(true)).toBe('true'); + expect(cellToMd('hello')).toBe('hello'); + }); + + it('escapes pipe and newline in primitives', () => { + expect(cellToMd('a|b')).toBe('a\\|b'); + expect(cellToMd('a\nb')).toBe('a
b'); + }); + + it('handles text type object', () => { + expect(cellToMd({ type: 'text', text: 'hello' })).toBe('hello'); + expect(cellToMd({ type: 'text', text: 'a|b' })).toBe('a\\|b'); + }); + + it('handles url type object', () => { + expect(cellToMd({ type: 'url', text: 'Google', link: 'https://google.com' })) + .toBe('[Google](https://google.com)'); + }); + + it('handles mentionUser type', () => { + expect(cellToMd({ type: 'mentionUser', name: 'Alice' })).toBe('@Alice'); + }); + + it('handles formula type', () => { + expect(cellToMd({ type: 'formula', text: 'SUM(A1:B2)' })).toBe('`SUM(A1:B2)`'); + }); + + it('handles array of cells (rich text)', () => { + expect(cellToMd([ + { type: 'text', text: 'hello ' }, + { type: 'url', text: 'world', link: 'https://example.com' }, + ])).toBe('hello [world](https://example.com)'); + }); + + it('handles unknown object with text field', () => { + expect(cellToMd({ type: 'unknown', text: 'fallback' })).toBe('fallback'); + }); +}); + +// ─── expandMerges ─────────────────────────────────────────────────────────── + +describe('expandMerges', () => { + it('returns same grid when no merges', () => { + const rows = [['a', 'b'], ['c', 'd']]; + expect(expandMerges(rows, [])).toEqual([['a', 'b'], ['c', 'd']]); + }); + + it('does not mutate original array', () => { + const rows = [['a', 'b'], ['c', 'd']]; + const result = expandMerges(rows, [{ start_row_index: 0, end_row_index: 1, start_column_index: 0, end_column_index: 0 }]); + expect(rows[1]![0]).toBe('c'); // original unchanged + expect(result[1]![0]).toBe('a'); // merged value + }); + + it('fills merged region with top-left value', () => { + const rows = [['X', '', ''], ['', '', ''], ['', '', '']]; + const merges = [{ start_row_index: 0, end_row_index: 2, start_column_index: 0, end_column_index: 2 }]; + const result = expandMerges(rows, merges); + expect(result).toEqual([['X', 'X', 'X'], ['X', 'X', 'X'], ['X', 'X', 'X']]); + }); + + it('handles multiple non-overlapping merges', () => { + const rows = [['A', 'B', 'C'], ['', 'D', '']]; + const merges = [ + { start_row_index: 0, end_row_index: 1, start_column_index: 0, end_column_index: 0 }, + { start_row_index: 0, end_row_index: 0, start_column_index: 1, end_column_index: 2 }, + ]; + const result = expandMerges(rows, merges); + expect(result[0]).toEqual(['A', 'B', 'B']); + expect(result[1]![0]).toBe('A'); + }); +}); + +// ─── trimTrailingEmpty ────────────────────────────────────────────────────── + +describe('trimTrailingEmpty', () => { + it('returns empty array when all cells empty', () => { + expect(trimTrailingEmpty([['', ''], ['', '']])).toEqual([]); + }); + + it('trims trailing empty rows', () => { + const rows = [['a', 'b'], ['c', 'd'], ['', ''], ['', '']]; + expect(trimTrailingEmpty(rows)).toEqual([['a', 'b'], ['c', 'd']]); + }); + + it('trims trailing empty columns', () => { + const rows = [['a', '', ''], ['b', '', '']]; + expect(trimTrailingEmpty(rows)).toEqual([['a'], ['b']]); + }); + + it('trims both trailing empty rows and columns', () => { + const rows = [['a', 'b', ''], ['c', '', ''], ['', '', '']]; + expect(trimTrailingEmpty(rows)).toEqual([['a', 'b'], ['c', '']]); + }); + + it('keeps middle empty rows/columns', () => { + const rows = [['a', '', 'b'], ['', '', ''], ['c', '', 'd']]; + expect(trimTrailingEmpty(rows)).toEqual([['a', '', 'b'], ['', '', ''], ['c', '', 'd']]); + }); +}); + +// ─── columnIndexToLetter ──────────────────────────────────────────────────── + +describe('columnIndexToLetter', () => { + it('converts single letter columns', () => { + expect(columnIndexToLetter(1)).toBe('A'); + expect(columnIndexToLetter(26)).toBe('Z'); + }); + + it('converts double letter columns', () => { + expect(columnIndexToLetter(27)).toBe('AA'); + expect(columnIndexToLetter(28)).toBe('AB'); + expect(columnIndexToLetter(52)).toBe('AZ'); + expect(columnIndexToLetter(53)).toBe('BA'); + }); + + it('converts triple letter columns', () => { + expect(columnIndexToLetter(703)).toBe('AAA'); + }); + + it('returns empty string for 0', () => { + expect(columnIndexToLetter(0)).toBe(''); + }); +}); diff --git a/tests/title-filter.test.ts b/tests/title-filter.test.ts new file mode 100644 index 0000000..d8e3c82 --- /dev/null +++ b/tests/title-filter.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { createTitleFilter, getHeadingLevel, extractHeadingText } from '../src/title-filter.js'; +import type { DocxBlock } from '../src/types.js'; + +// ─── Helper: 创建模拟 DocxBlock ───────────────────────────────────────────── + +function makeHeadingBlock (level: number, text: string, id = 'blk'): DocxBlock { + const headingKey = `heading${level}`; + return { + block_id: id, + block_type: level + 2, // heading1=3, heading2=4, ... + [headingKey]: { elements: [{ text_run: { content: text } }] }, + } as unknown as DocxBlock; +} + +function makeTextBlock (id = 'txt'): DocxBlock { + return { + block_id: id, + block_type: 2, // text block + text: { elements: [{ text_run: { content: 'some text' } }] }, + } as unknown as DocxBlock; +} + +function makePageBlock (id = 'page'): DocxBlock { + return { block_id: id, block_type: 1 } as unknown as DocxBlock; +} + +// ─── getHeadingLevel ──────────────────────────────────────────────────────── + +describe('getHeadingLevel', () => { + it('returns level for heading blocks (block_type 3~11)', () => { + expect(getHeadingLevel({ block_type: 3 } as DocxBlock)).toBe(1); + expect(getHeadingLevel({ block_type: 5 } as DocxBlock)).toBe(3); + expect(getHeadingLevel({ block_type: 11 } as DocxBlock)).toBe(9); + }); + + it('returns null for non-heading blocks', () => { + expect(getHeadingLevel({ block_type: 1 } as DocxBlock)).toBeNull(); + expect(getHeadingLevel({ block_type: 2 } as DocxBlock)).toBeNull(); + expect(getHeadingLevel({ block_type: 12 } as DocxBlock)).toBeNull(); + }); +}); + +// ─── extractHeadingText ───────────────────────────────────────────────────── + +describe('extractHeadingText', () => { + it('extracts and trims heading text', () => { + expect(extractHeadingText(makeHeadingBlock(1, ' Hello World '))).toBe('Hello World'); + }); + + it('joins multiple elements', () => { + const block = { + block_id: 'b', + block_type: 3, + heading1: { elements: [{ text_run: { content: 'Hello ' } }, { text_run: { content: 'World' } }] }, + } as unknown as DocxBlock; + expect(extractHeadingText(block)).toBe('Hello World'); + }); + + it('returns null for non-heading block', () => { + expect(extractHeadingText(makeTextBlock())).toBeNull(); + }); +}); + +// ─── createTitleFilter ────────────────────────────────────────────────────── + +describe('createTitleFilter', () => { + it('collects matching heading and its children', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Target' }); + + const blocks = [ + makeHeadingBlock(1, 'Intro'), + makeTextBlock('t1'), + makeHeadingBlock(1, 'Target'), + makeTextBlock('t2'), + makeTextBlock('t3'), + makeHeadingBlock(1, 'Next Section'), + ]; + + pageHandler(blocks); + const result = getResult(); + expect(result.matched).toBe(true); + expect(result.blocks).toHaveLength(3); // heading + t2 + t3 + expect(result.blocks[0]).toBe(blocks[2]); // Target heading + }); + + it('stops collecting at same-level heading', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'A' }); + + pageHandler([ + makeHeadingBlock(2, 'A'), + makeTextBlock('child1'), + makeHeadingBlock(2, 'B'), // same level → stop + makeTextBlock('child2'), + ]); + + const result = getResult(); + expect(result.matched).toBe(true); + expect(result.blocks).toHaveLength(2); // A heading + child1 + }); + + it('stops collecting at higher-level heading', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Sub' }); + + pageHandler([ + makeHeadingBlock(2, 'Sub'), + makeTextBlock('c1'), + makeHeadingBlock(1, 'Top'), // higher level → stop + ]); + + const result = getResult(); + expect(result.blocks).toHaveLength(2); + }); + + it('includes lower-level headings as children', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Parent' }); + + pageHandler([ + makeHeadingBlock(1, 'Parent'), + makeHeadingBlock(2, 'Child'), + makeTextBlock('t1'), + makeHeadingBlock(1, 'Sibling'), + ]); + + const result = getResult(); + expect(result.blocks).toHaveLength(3); // Parent + Child heading + t1 + }); + + it('always keeps page blocks', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'X' }); + + pageHandler([makePageBlock(), makeHeadingBlock(1, 'X'), makeTextBlock()]); + + const result = getResult(); + expect(result.blocks[0]!.block_type).toBe(1); // page block + }); + + it('returns false from pageHandler when done', () => { + const { pageHandler } = createTitleFilter({ title: 'A' }); + + const cont = pageHandler([ + makeHeadingBlock(1, 'A'), + makeHeadingBlock(1, 'B'), + ]); + expect(cont).toBe(false); + }); + + it('returns matched=false when title not found', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Missing' }); + + pageHandler([makeHeadingBlock(1, 'Something')]); + expect(getResult().matched).toBe(false); + }); + + it('subsequent pageHandler calls after done still return false (covers done state)', () => { + const { pageHandler } = createTitleFilter({ title: 'A' }); + + // 第一次调用:collecting 状态,遇到同级 heading 进入 done + pageHandler([makeHeadingBlock(1, 'A'), makeHeadingBlock(1, 'B')]); + + // 第二次调用:state 已是 'done',进入 switch 的 done 分支 + const cont = pageHandler([makeTextBlock('ignored')]); + expect(cont).toBe(false); + }); + + it('handles multi-page scanning', () => { + const { pageHandler, getResult } = createTitleFilter({ title: 'Found' }); + + // Page 1: not found + const cont1 = pageHandler([makeHeadingBlock(1, 'Other')]); + expect(cont1).toBe(true); + + // Page 2: found + const cont2 = pageHandler([makeHeadingBlock(1, 'Found'), makeTextBlock()]); + expect(cont2).toBe(true); // still collecting (no end marker) + + const result = getResult(); + expect(result.matched).toBe(true); + }); +}); diff --git a/tests/whiteboard/__snapshots__/render.test.ts.snap b/tests/whiteboard/__snapshots__/render.test.ts.snap new file mode 100644 index 0000000..97e54b1 --- /dev/null +++ b/tests/whiteboard/__snapshots__/render.test.ts.snap @@ -0,0 +1,141 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderers - composite_shape > renders ellipse shape 1`] = ` +" + + + +" +`; + +exports[`renderers - composite_shape > renders rectangle shape with text 1`] = ` +" + + +Rect + +" +`; + +exports[`renderers - connector > renders connector with two attached endpoints 1`] = ` +" + + + + + + + + + + +" +`; + +exports[`renderers - simple > renders group wrapping children 1`] = ` +" + + +x + + +" +`; + +exports[`renderers - simple > renders image with token 1`] = ` +" + +" +`; + +exports[`renderers - simple > renders paint (multiple points → polyline) 1`] = ` +" + +" +`; + +exports[`renderers - simple > renders paint (single point → circle) 1`] = ` +" + +" +`; + +exports[`renderers - simple > renders section with title and children 1`] = ` +" + + +Group A + +inside + + +" +`; + +exports[`renderers - simple > renders sticky_note with custom color 1`] = ` +" + + +Note + +" +`; + +exports[`renderers - simple > renders text_shape 1`] = ` +" + +Hello + +" +`; + +exports[`renderers - table > renders 2x2 table with meta and cells 1`] = ` +" + + + + + + +A + + +B + + + + +C + + +D + + + +" +`; + +exports[`whiteboardNodesToSvg - top-level > calculates viewBox considering node bounds + padding 1`] = ` +" + +" +`; + +exports[`whiteboardNodesToSvg - top-level > includes background color rect 1`] = ` +" + + +" +`; + +exports[`whiteboardNodesToSvg - top-level > produces default viewBox for empty input 1`] = ` +" + +" +`; + +exports[`whiteboardNodesToSvg - top-level > viewBox accounts for node rotation 1`] = ` +" + +" +`; diff --git a/tests/whiteboard/context.test.ts b/tests/whiteboard/context.test.ts new file mode 100644 index 0000000..4347f6e --- /dev/null +++ b/tests/whiteboard/context.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { whiteboardNodesToSvg } from '../../src/whiteboard/index.js'; +import { DefaultRenderContext } from '../../src/whiteboard/render-context.js'; +import { getAllRenderers, getRenderer } from '../../src/whiteboard/node-renderer.js'; +import type { WNode } from '../../src/whiteboard/types.js'; + +describe('DefaultRenderContext', () => { + it('renderNode falls back for unknown type', () => { + // 构造一个未注册的 type,触发 renderFallback + const nodes = [ + { + id: 'u', + type: 'totally_unknown_type', + x: 0, + y: 0, + width: 120, + height: 40, + text: { text: 'fallback' } as any, + style: { fill_color: '#fafafa', border_color: '#999' } as any, + } as unknown as WNode, + ]; + const svg = whiteboardNodesToSvg(nodes); + // fallback 会输出 和文本 + expect(svg).toContain('rx="2"'); + expect(svg).toContain('fallback'); + // fallback 包裹为 ,包含 和 + expect(svg).toMatch(/]*>/); + }); + + it('renderFallback renders children recursively', () => { + const nodes = [ + { + id: 'p', + type: 'mystery_parent', + x: 0, y: 0, width: 200, height: 100, + children: ['c1', 'c2'], + } as unknown as WNode, + { id: 'c1', type: 'text_shape', parent_id: 'p', x: 10, y: 10, width: 60, height: 20, text: { text: 'kid1' } as any } as WNode, + { id: 'c2', type: 'text_shape', parent_id: 'p', x: 10, y: 40, width: 60, height: 20, text: { text: 'kid2' } as any } as WNode, + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('kid1'); + expect(svg).toContain('kid2'); + }); + + it('renderChildren skips missing ids silently', () => { + const ctx = new DefaultRenderContext({ + nodeMap: new Map(), + usedMarkers: new Set(), + mindMapChildrenMap: new Map(), + referencedIds: new Set(), + }); + const node = { id: 'x', type: 'group', children: ['nonexistent'] } as unknown as WNode; + const out = ctx.renderChildren(node); + expect(out).toBe(''); + }); + + it('renderChildren returns empty string when node has no children', () => { + const ctx = new DefaultRenderContext({ + nodeMap: new Map(), + usedMarkers: new Set(), + mindMapChildrenMap: new Map(), + referencedIds: new Set(), + }); + expect(ctx.renderChildren({ id: 'x', type: 'group' } as unknown as WNode)).toBe(''); + }); + + it('exposes readonly context fields', () => { + const nodeMap = new Map(); + const usedMarkers = new Set(); + const mindMapChildrenMap = new Map(); + const referencedIds = new Set(); + const ctx = new DefaultRenderContext({ nodeMap, usedMarkers, mindMapChildrenMap, referencedIds }); + expect(ctx.nodeMap).toBe(nodeMap); + expect(ctx.usedMarkers).toBe(usedMarkers); + expect(ctx.mindMapChildrenMap).toBe(mindMapChildrenMap); + expect(ctx.referencedIds).toBe(referencedIds); + }); +}); + +describe('node-renderer registry', () => { + it('getAllRenderers returns a map of all registered renderers', () => { + const all = getAllRenderers(); + expect(all).toBeInstanceOf(Map); + // 11 个内置 renderer(registerAllRenderers 中注册) + expect(all.size).toBeGreaterThanOrEqual(11); + // 确认至少包含这些核心类型 + const types = [...all.keys()]; + expect(types).toEqual(expect.arrayContaining([ + 'text_shape', + 'image', + 'group', + 'sticky_note', + 'section', + 'paint', + 'svg', + 'composite_shape', + 'connector', + 'table', + 'mind_map', + ])); + }); + + it('getRenderer returns undefined for unknown type', () => { + expect(getRenderer('never_registered_type_xyz')).toBeUndefined(); + }); + + it('getRenderer returns the registered renderer for known type', () => { + const r = getRenderer('text_shape'); + expect(r).toBeDefined(); + expect(r!.type).toBe('text_shape'); + }); +}); diff --git a/tests/whiteboard/plain-text.test.ts b/tests/whiteboard/plain-text.test.ts new file mode 100644 index 0000000..a8f90d5 --- /dev/null +++ b/tests/whiteboard/plain-text.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { extractPlainText } from '../../src/whiteboard/plain-text.js'; + +describe('extractPlainText', () => { + it('returns empty string for undefined', () => { + expect(extractPlainText(undefined)).toBe(''); + }); + + it('returns plain text field when no rich_text', () => { + expect(extractPlainText({ text: 'hello' } as any)).toBe('hello'); + }); + + it('returns empty string when neither text nor rich_text present', () => { + expect(extractPlainText({} as any)).toBe(''); + }); + + it('prefers rich_text over plain text', () => { + const input = { + text: 'plain', + rich_text: { + paragraphs: [{ elements: [{ element_type: 0, text_element: { text: 'rich' } }] }], + }, + }; + expect(extractPlainText(input as any)).toBe('rich'); + }); + + it('joins multiple elements in a paragraph', () => { + const input = { + rich_text: { + paragraphs: [ + { + elements: [ + { element_type: 0, text_element: { text: 'Hello ' } }, + { element_type: 0, text_element: { text: 'World' } }, + ], + }, + ], + }, + }; + expect(extractPlainText(input as any)).toBe('Hello World'); + }); + + it('joins multiple paragraphs with newlines', () => { + const input = { + rich_text: { + paragraphs: [ + { elements: [{ element_type: 0, text_element: { text: 'line1' } }] }, + { elements: [{ element_type: 0, text_element: { text: 'line2' } }] }, + ], + }, + }; + expect(extractPlainText(input as any)).toBe('line1\nline2'); + }); + + it('handles link_element (element_type=1) preferring text, falling back to href', () => { + const input1 = { + rich_text: { + paragraphs: [{ elements: [{ element_type: 1, link_element: { text: 'Click', herf: 'http://x' } }] }], + }, + }; + expect(extractPlainText(input1 as any)).toBe('Click'); + + const input2 = { + rich_text: { + paragraphs: [{ elements: [{ element_type: 1, link_element: { herf: 'http://x' } }] }], + }, + }; + expect(extractPlainText(input2 as any)).toBe('http://x'); + }); + + it('handles mention_doc_element (element_type=3)', () => { + const input = { + rich_text: { + paragraphs: [{ elements: [{ element_type: 3, mention_doc_element: { doc_url: 'doc://abc' } }] }], + }, + }; + expect(extractPlainText(input as any)).toBe('doc://abc'); + }); + + it('falls back to [doc] when mention_doc has no url', () => { + const input = { + rich_text: { + paragraphs: [{ elements: [{ element_type: 3, mention_doc_element: {} }] }], + }, + }; + expect(extractPlainText(input as any)).toBe('[doc]'); + }); + + it('ignores unknown element types', () => { + const input = { + rich_text: { + paragraphs: [{ elements: [{ element_type: 99, text_element: { text: 'ignored' } }] }], + }, + }; + expect(extractPlainText(input as any)).toBe(''); + }); + + it('uses plain text when rich_text.paragraphs is empty', () => { + const input = { text: 'fallback', rich_text: { paragraphs: [] } }; + expect(extractPlainText(input as any)).toBe('fallback'); + }); +}); diff --git a/tests/whiteboard/render.test.ts b/tests/whiteboard/render.test.ts new file mode 100644 index 0000000..1967ccf --- /dev/null +++ b/tests/whiteboard/render.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from 'vitest'; +import { whiteboardNodesToSvg } from '../../src/whiteboard/index.js'; +import type { WNode } from '../../src/whiteboard/types.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function mk (partial: Partial): WNode { + return { type: 'text_shape', x: 0, y: 0, width: 100, height: 40, ...partial } as WNode; +} + +// ─── Top-level SVG structure ──────────────────────────────────────────────── + +describe('whiteboardNodesToSvg - top-level', () => { + it('produces default viewBox for empty input', () => { + const svg = whiteboardNodesToSvg([]); + expect(svg).toMatchSnapshot(); + }); + + it('includes background color rect', () => { + const nodes: WNode[] = [mk({ id: 't', x: 0, y: 0, width: 50, height: 50 })]; + const svg = whiteboardNodesToSvg(nodes, '#f0f0f0'); + expect(svg).toContain('fill="#f0f0f0"'); + expect(svg).toMatchSnapshot(); + }); + + it('includes dot-pattern background', () => { + const nodes: WNode[] = [mk({ id: 't', x: 0, y: 0, width: 50, height: 50 })]; + const svg = whiteboardNodesToSvg(nodes, 'dot'); + expect(svg).toContain(' { + const nodes: WNode[] = [mk({ id: 't', x: 0, y: 0, width: 50, height: 50 })]; + const svg = whiteboardNodesToSvg(nodes, 'none'); + expect(svg).not.toContain(']*fill="#fff"/)).toBeNull(); + }); + + it('calculates viewBox considering node bounds + padding', () => { + const nodes: WNode[] = [ + mk({ id: 'a', x: 100, y: 50, width: 200, height: 100 }), + ]; + const svg = whiteboardNodesToSvg(nodes); + // 期望 viewBox 包围 [100,50]~[300,150],padding=24 → [76,26,248,148] + expect(svg).toMatch(/viewBox="76 26 248 148"/); + expect(svg).toMatchSnapshot(); + }); + + it('viewBox accounts for node rotation', () => { + const nodes: WNode[] = [ + mk({ id: 'r', x: 0, y: 0, width: 100, height: 100, angle: 45 }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toMatchSnapshot(); + }); +}); + +// ─── Simple renderers ─────────────────────────────────────────────────────── + +describe('renderers - simple', () => { + it('renders text_shape', () => { + const nodes: WNode[] = [ + mk({ id: 't', type: 'text_shape', x: 0, y: 0, width: 120, height: 30, text: { text: 'Hello' } as any }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toMatchSnapshot(); + }); + + it('renders image with token', () => { + const nodes: WNode[] = [ + mk({ id: 'i', type: 'image', x: 10, y: 20, width: 80, height: 60, image: { token: 'TOK_X' } as any }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain(' { + const nodes: WNode[] = [ + mk({ + id: 's', + type: 'sticky_note', + x: 0, + y: 0, + width: 100, + height: 100, + text: { text: 'Note' } as any, + style: { fill_color: '#f5f5dc' } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('fill="#f5f5dc"'); + expect(svg).toMatchSnapshot(); + }); + + it('renders section with title and children', () => { + const nodes: WNode[] = [ + mk({ + id: 'sec', + type: 'section', + x: 0, + y: 0, + width: 200, + height: 150, + section: { title: 'Group A' } as any, + children: ['c1'], + }), + mk({ id: 'c1', type: 'text_shape', parent_id: 'sec', x: 10, y: 30, width: 80, height: 20, text: { text: 'inside' } as any }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('data-title="Group A"'); + expect(svg).toContain('Group A'); + expect(svg).toContain('inside'); + expect(svg).toMatchSnapshot(); + }); + + it('renders group wrapping children', () => { + const nodes: WNode[] = [ + mk({ id: 'g', type: 'group', x: 0, y: 0, width: 100, height: 100, children: ['c1'] }), + mk({ id: 'c1', type: 'text_shape', parent_id: 'g', x: 10, y: 10, width: 40, height: 20, text: { text: 'x' } as any }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('data-type="group"'); + expect(svg).toMatchSnapshot(); + }); + + it('renders paint (single point → circle)', () => { + const nodes: WNode[] = [ + mk({ + id: 'p', + type: 'paint' as any, + x: 0, + y: 0, + width: 50, + height: 50, + paint: { type: 'marker', width: 4, color: '#f00', lines: [{ x: 10, y: 10 }] } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain(' { + const nodes: WNode[] = [ + mk({ + id: 'p', + type: 'paint' as any, + x: 0, + y: 0, + width: 50, + height: 50, + paint: { type: 'highlight', color: '#ff0', lines: [{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 20, y: 5 }] } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain(' { + const nodes: WNode[] = [ + mk({ + id: 'sv', + type: 'svg' as any, + x: 0, + y: 0, + width: 100, + height: 80, + svg: { svg_code: '' } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('width="100"'); + expect(svg).toContain('height="80"'); + expect(svg).toContain('viewBox="0 0 50 40"'); + }); +}); + +// ─── Composite Shape ──────────────────────────────────────────────────────── + +describe('renderers - composite_shape', () => { + it('renders rectangle shape with text', () => { + const nodes: WNode[] = [ + mk({ + id: 'r', + type: 'composite_shape', + x: 0, + y: 0, + width: 100, + height: 60, + composite_shape: { type: 'rect' } as any, + text: { text: 'Rect' } as any, + style: { fill_color: '#e0e0e0', border_color: '#333' } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('Rect'); + expect(svg).toMatchSnapshot(); + }); + + it('renders ellipse shape', () => { + const nodes: WNode[] = [ + mk({ + id: 'e', + type: 'composite_shape', + x: 0, + y: 0, + width: 80, + height: 60, + composite_shape: { type: 'ellipse' } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toMatchSnapshot(); + }); +}); + +// ─── Connector ────────────────────────────────────────────────────────────── + +describe('renderers - connector', () => { + it('renders connector with two attached endpoints', () => { + const nodes: WNode[] = [ + mk({ id: 'a', type: 'composite_shape', x: 0, y: 0, width: 60, height: 40, composite_shape: { type: 'rect' } as any }), + mk({ id: 'b', type: 'composite_shape', x: 200, y: 100, width: 60, height: 40, composite_shape: { type: 'rect' } as any }), + mk({ + id: 'c', + type: 'connector', + x: 0, + y: 0, + width: 0, + height: 0, + connector: { + shape: 'straight', + start: { attached_object: { id: 'a' }, arrow_style: 'none' }, + end: { attached_object: { id: 'b' }, arrow_style: 'triangle_arrow' }, + } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain(''); // markers + expect(svg).toMatchSnapshot(); + }); + + it('renders connector with caption label', () => { + const nodes: WNode[] = [ + mk({ id: 'a', type: 'composite_shape', x: 0, y: 0, width: 60, height: 40, composite_shape: { type: 'rect' } as any }), + mk({ id: 'b', type: 'composite_shape', x: 200, y: 0, width: 60, height: 40, composite_shape: { type: 'rect' } as any }), + mk({ + id: 'c', + type: 'connector', + x: 0, y: 0, width: 0, height: 0, + connector: { + shape: 'curve', + start: { attached_object: { id: 'a' } }, + end: { attached_object: { id: 'b' } }, + captions: { data: [{ text: 'flow' }] }, + } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('flow'); + }); +}); + +// ─── Table ────────────────────────────────────────────────────────────────── + +describe('renderers - table', () => { + it('renders 2x2 table with meta and cells', () => { + const nodes: WNode[] = [ + mk({ + id: 't', + type: 'table', + x: 0, + y: 0, + width: 200, + height: 100, + table: { + meta: { row_num: 2, col_num: 2, row_heights: [50, 50], col_widths: [100, 100] }, + cells: [ + { row_index: 1, col_index: 1, text: { text: 'A' } }, + { row_index: 1, col_index: 2, text: { text: 'B' } }, + { row_index: 2, col_index: 1, text: { text: 'C' } }, + { row_index: 2, col_index: 2, text: { text: 'D' } }, + ], + } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('A'); + expect(svg).toContain('D'); + expect(svg).toContain('data-type="table"'); + expect(svg).toMatchSnapshot(); + }); +}); + +// ─── Mind Map ─────────────────────────────────────────────────────────────── + +describe('renderers - mind_map', () => { + it('renders mind map root with child branches', () => { + const nodes: WNode[] = [ + mk({ + id: 'root', + type: 'mind_map', + x: 0, + y: 0, + width: 120, + height: 40, + mind_map_root: { right_children: ['c1'] } as any, + text: { text: 'Topic' } as any, + }), + mk({ + id: 'c1', + type: 'mind_map', + x: 200, + y: 0, + width: 100, + height: 30, + parent_id: 'root', + mind_map_node: { parent_id: 'root' } as any, + text: { text: 'Sub' } as any, + }), + ]; + const svg = whiteboardNodesToSvg(nodes); + expect(svg).toContain('Topic'); + expect(svg).toContain('Sub'); + expect(svg).toContain('data-type="mind_map"'); + }); +}); + +// ─── Mix / ordering ───────────────────────────────────────────────────────── + +describe('whiteboardNodesToSvg - ordering', () => { + it('renders nodes ordered by z_index', () => { + const nodes: WNode[] = [ + mk({ id: 'top', type: 'composite_shape', x: 0, y: 0, width: 50, height: 50, z_index: 10, composite_shape: { type: 'rect' } as any, text: { text: 'TOP' } as any }), + mk({ id: 'bot', type: 'composite_shape', x: 0, y: 0, width: 50, height: 50, z_index: 1, composite_shape: { type: 'rect' } as any, text: { text: 'BOT' } as any }), + ]; + const svg = whiteboardNodesToSvg(nodes); + // BOT 在前(z_index 小) + expect(svg.indexOf('BOT')).toBeLessThan(svg.indexOf('TOP')); + }); + + it('skips child nodes at top level (renders via parent)', () => { + const nodes: WNode[] = [ + mk({ id: 'p', type: 'group', x: 0, y: 0, width: 100, height: 100, children: ['c'] }), + mk({ id: 'c', type: 'text_shape', parent_id: 'p', x: 0, y: 0, width: 50, height: 20, text: { text: 'child' } as any }), + ]; + const svg = whiteboardNodesToSvg(nodes); + // child should appear exactly once + const matches = svg.match(/child/g) ?? []; + expect(matches.length).toBe(1); + }); +}); diff --git a/tests/whiteboard/shape-paths.test.ts b/tests/whiteboard/shape-paths.test.ts new file mode 100644 index 0000000..d63fa6a --- /dev/null +++ b/tests/whiteboard/shape-paths.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from 'vitest'; +import { getShapePath, registerShapePath, defaultShapePath } from '../../src/whiteboard/shape-paths.js'; +// 触发形状注册的 side-effect +import '../../src/whiteboard/shape-paths.js'; +import type { WNode } from '../../src/whiteboard/types.js'; + +const SA = 'fill="#eee" stroke="#333"'; +const W = 100; +const H = 60; + +function blankNode (overrides: Partial = {}): WNode { + return { type: 'composite_shape', ...overrides } as unknown as WNode; +} + +// 形状 → 期望包含的 SVG 片段/标签 +const SHAPE_CASES: Array<[string, RegExp | string]> = [ + // rect 类 + ['rect', /^ { + it('cylinder outputs top + body + side lines + bottom', () => { + const fn = getShapePath('cylinder')!; + const out = fn(100, 80, SA, blankNode({ style: { border_color: '#444', border_width: 'normal' } as any })); + // 顶椭圆 + 中间矩形 + 两条 line + 底椭圆 + expect(out.match(/ { + const fn = getShapePath('cylinder')!; + const out = fn(100, 80, SA, blankNode()); + expect(out).toContain('stroke="#333"'); + }); + + it('flow_chart_cylinder and data_base are aliases of cylinder', () => { + expect(getShapePath('flow_chart_cylinder')).toBe(getShapePath('cylinder')); + expect(getShapePath('data_base')).toBe(getShapePath('cylinder')); + }); + + it('bubble renders body rect + tail polygon (large corner radius)', () => { + const fn = getShapePath('bubble')!; + const n = blankNode({ composite_shape: { type: 'bubble' } as any }); + const out = fn(100, 80, SA, n); + expect(out).toContain(' { + const fn = getShapePath('rect_bubble')!; + const n = blankNode({ composite_shape: { type: 'rect_bubble' } as any }); + const out = fn(100, 80, SA, n); + expect(out).toContain('rx="4"'); + }); + + it('actor renders head circle + body/limb lines', () => { + const fn = getShapePath('actor')!; + const out = fn(100, 100, SA, blankNode()); + expect(out).toContain(' { + const fn = getShapePath('actor')!; + const out = fn(100, 100, SA, blankNode({ + style: { border_color: '#f00', border_width: 'extra_bold' } as any, + })); + expect(out).toContain('stroke="#f00"'); + }); + + it('circular_ring renders outer circle + inner cut-out', () => { + const fn = getShapePath('circular_ring')!; + const out = fn(100, 100, SA, blankNode({ + composite_shape: { circular_ring: { sector_ratio: 0.3 } } as any, + })); + const circles = out.match(/ { + const fn = getShapePath('circular_ring')!; + const out = fn(100, 100, SA, blankNode()); + expect(out).toContain('r="25"'); // 50 * (1 - 0.5) + }); + + it('pie without composite_shape.pie falls back to ellipse', () => { + const fn = getShapePath('pie')!; + const out = fn(100, 100, SA, blankNode()); + expect(out).toContain(' { + const fn = getShapePath('pie')!; + const out = fn(100, 100, SA, blankNode({ + composite_shape: { pie: { start_radial_line_angle: 0, central_angle: 90, radius: 50 } } as any, + })); + expect(out).toContain(' { + it('star renders a 10-point polygon (5-pointed star)', () => { + const out = getShapePath('star')!(100, 100, SA, blankNode()); + expect(out).toContain(' { + const out = getShapePath('star2')!(100, 100, SA, blankNode()); + const points = out.match(/points="([^"]+)"/)![1]!.split(' '); + expect(points.length).toBe(16); + }); + + it('star3 renders 8 vertices (4-pointed)', () => { + const out = getShapePath('star3')!(100, 100, SA, blankNode()); + const points = out.match(/points="([^"]+)"/)![1]!.split(' '); + expect(points.length).toBe(8); + }); + + it('star4 renders 12 vertices (6-pointed)', () => { + const out = getShapePath('star4')!(100, 100, SA, blankNode()); + const points = out.match(/points="([^"]+)"/)![1]!.split(' '); + expect(points.length).toBe(12); + }); +}); + +describe('shape-paths: registry', () => { + it('getShapePath returns undefined for unknown shape', () => { + expect(getShapePath('totally-missing')).toBeUndefined(); + }); + + it('registerShapePath supports string and array keys', () => { + registerShapePath('custom_single', () => ''); + registerShapePath(['custom_a', 'custom_b'], () => ''); + expect(getShapePath('custom_single')!(1, 1, '', blankNode())).toBe(''); + expect(getShapePath('custom_a')!(1, 1, '', blankNode())).toBe(''); + expect(getShapePath('custom_b')!(1, 1, '', blankNode())).toBe(''); + }); + + it('defaultShapePath produces rounded rect fallback', () => { + const out = defaultShapePath(100, 60, SA, blankNode()); + expect(out).toContain(' { + it('returns empty string when text is undefined', () => { + expect(renderTextContent(undefined, 100, 50)).toBe(''); + }); + + it('returns empty string when text.text is empty', () => { + expect(renderTextContent({ text: '' } as WText, 100, 50)).toBe(''); + }); + + it('uses default font-size 14 and color #333', () => { + const out = renderTextContent({ text: 'hi' } as WText, 100, 50); + expect(out).toContain('font-size="14"'); + expect(out).toContain('fill="#333"'); + }); + + it('applies custom font-size, color, bold, italic', () => { + const out = renderTextContent( + { text: 'hi', font_size: 20, text_color: '#f00', font_weight: 'bold', italic: true } as WText, + 100, 50, + ); + expect(out).toContain('font-size="20"'); + expect(out).toContain('fill="#f00"'); + expect(out).toContain('font-weight="bold"'); + expect(out).toContain('font-style="italic"'); + }); + + it('applies underline + line_through decorations', () => { + const out = renderTextContent( + { text: 'hi', underline: true, line_through: true } as WText, + 100, 50, + ); + expect(out).toContain('text-decoration="underline line-through"'); + }); +}); + +// ─── horizontal/vertical alignment ──────────────────────────────────────── + +describe('renderTextContent - alignment', () => { + it('horizontal center sets text-anchor=middle and x=w/2', () => { + const out = renderTextContent({ text: 'x', horizontal_align: 'center' } as WText, 100, 40); + expect(out).toContain('text-anchor="middle"'); + expect(out).toContain('x="50"'); + }); + + it('horizontal right sets text-anchor=end and x=w', () => { + const out = renderTextContent({ text: 'x', horizontal_align: 'right' } as WText, 120, 40); + expect(out).toContain('text-anchor="end"'); + expect(out).toContain('x="120"'); + }); + + it('vertical bottom places y near bottom', () => { + const out = renderTextContent({ text: 'x', vertical_align: 'bottom' } as WText, 100, 100); + expect(out).toMatch(/y="96"/); + }); + + it('vertical mid centers vertically', () => { + const out = renderTextContent({ text: 'x', vertical_align: 'mid' } as WText, 100, 100); + // mid 单行: h/2 + fontSize/3 ≈ 54.67 → "54.67" 或经 r() 格式化 + expect(out).toMatch(/y="5\d(\.\d+)?"/); + }); +}); + +// ─── text background color ──────────────────────────────────────────────── + +describe('renderTextContent - background rect', () => { + it('emits before when text_background_color set', () => { + const out = renderTextContent( + { text: 'bg', text_background_color: '#ffff00' } as WText, + 100, 40, + ); + expect(out.indexOf(' { + const out = renderTextContent( + { text: 'abc', text_background_color: '#ff0', horizontal_align: 'center' } as WText, + 100, 40, + ); + // anchor middle → bg rect x 应比 text x 小(减去半宽) + const rectX = Number(out.match(/]*x="([\d.]+)"/)![1]); + expect(rectX).toBeLessThan(50); + }); + + it('background rect position honors end anchor', () => { + const out = renderTextContent( + { text: 'abc', text_background_color: '#ff0', horizontal_align: 'right' } as WText, + 100, 40, + ); + // anchor end → bg rect x 应 < w + const rectX = Number(out.match(/]*x="([\d.]+)"/)![1]); + expect(rectX).toBeLessThan(100); + }); +}); + +// ─── rotation ───────────────────────────────────────────────────────────── + +describe('renderTextContent - rotation', () => { + it('emits transform=rotate(...) when angle set', () => { + const out = renderTextContent({ text: 'r', angle: 45 } as WText, 100, 40); + expect(out).toMatch(/transform="rotate\(45/); + }); + + it('no transform when angle = 0', () => { + const out = renderTextContent({ text: 'r', angle: 0 } as WText, 100, 40); + expect(out).not.toContain('transform='); + }); +}); + +// ─── wrapping (multi-line via tspan) ───────────────────────────────────── + +describe('renderTextContent - auto-wrap & tspan', () => { + it('splits on explicit \\n using tspans', () => { + const out = renderTextContent({ text: 'line1\nline2' } as WText, 200, 80); + const tspans = out.match(/line1<'); + expect(out).toContain('>line2<'); + }); + + it('auto-wraps long ASCII text that exceeds maxWidth', () => { + // 字符宽 ≈ fontSize * 0.55 = 7.7,maxWidth = 50-8=42 → ~5 chars/line + const out = renderTextContent({ text: 'abcdefghijklmnop' } as WText, 50, 60); + const tspans = out.match(/ { + // CJK 字符宽 ≈ fontSize * 0.9 = 12.6,maxWidth = 50-8=42 → ~3 chars/line + const out = renderTextContent({ text: '一二三四五六七八九十' } as WText, 50, 60); + const tspans = out.match(/ { + const out = renderTextContent( + { text: 'a\nb\nc', vertical_align: 'bottom' } as WText, + 200, 200, + ); + const ys = [...out.matchAll(/y="(\d+(?:\.\d+)?)"/g)].map(m => Number(m[1])); + // 至少 3 行,y 递增 + expect(ys.length).toBeGreaterThanOrEqual(3); + expect(ys[1]!).toBeGreaterThan(ys[0]!); + }); + + it('multiline with vertical mid centers the block', () => { + const out = renderTextContent( + { text: 'a\nb\nc', vertical_align: 'mid' } as WText, + 200, 200, + ); + expect(out).toMatch(/ { + // wrapTextLines 分支: maxWidth <= 0 时直接 push 整行 + const out = renderTextContent({ text: 'hello' } as WText, 5, 40); + expect(out).toContain('hello'); + }); +}); + +// ─── rich_text 路径 ─────────────────────────────────────────────────────── + +describe('renderTextContent - rich_text', () => { + it('uses rich_text when paragraphs present', () => { + const out = renderTextContent({ + text: 'plain', + rich_text: { + paragraphs: [{ + elements: [ + { element_type: 0, text_element: { text: 'Hello ', text_style: { font_weight: 'bold' } } }, + { element_type: 0, text_element: { text: 'World', text_style: { text_color: '#f00' } } }, + ], + }], + }, + } as WText, 300, 40); + expect(out).toContain('Hello'); + expect(out).toContain('World'); + expect(out).toContain('font-weight="bold"'); + expect(out).toContain('fill="#f00"'); + }); + + it('rich_text link_element wraps in ', () => { + const out = renderTextContent({ + rich_text: { + paragraphs: [{ + elements: [ + { element_type: 1, link_element: { text: 'open', herf: 'https://example.com' } }, + ], + }], + }, + } as WText, 300, 40); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain('>open<'); + }); + + it('rich_text link_element without text falls back to href', () => { + const out = renderTextContent({ + rich_text: { + paragraphs: [{ + elements: [ + { element_type: 1, link_element: { herf: 'https://x.io' } }, + ], + }], + }, + } as WText, 300, 40); + expect(out).toContain('>https://x.io<'); + }); + + it('rich_text mention_doc_element wraps in with [doc] text', () => { + const out = renderTextContent({ + rich_text: { + paragraphs: [{ + elements: [ + { element_type: 3, mention_doc_element: { doc_url: 'https://feishu/doc' } }, + ], + }], + }, + } as WText, 300, 40); + expect(out).toContain(''); + expect(out).toContain('[doc]'); + }); + + it('rich_text wraps long segments across multiple visual lines', () => { + const out = renderTextContent({ + rich_text: { + paragraphs: [{ + elements: [ + { element_type: 0, text_element: { text: 'line-1\nline-2\nline-3' } }, + ], + }], + }, + } as WText, 200, 80); + // 显式 \n 触发 flush,应分成多个 行 + const texts = out.match(/', () => { + // flowSegmentsIntoLines 对空 segments 返回 [''],产生一个空 text + const out = renderTextContent({ + rich_text: { paragraphs: [{ elements: [] }] }, + } as unknown as WText, 300, 40); + expect(out).toContain(']*><\/text>/); + }); + + it('rich_text honors horizontal center alignment', () => { + const out = renderTextContent({ + horizontal_align: 'center', + rich_text: { + paragraphs: [{ elements: [{ element_type: 0, text_element: { text: 'x' } }] }], + }, + } as WText, 200, 40); + expect(out).toContain('text-anchor="middle"'); + }); + + it('rich_text inherits parent bold/italic/underline/line_through', () => { + const out = renderTextContent({ + font_weight: 'bold', + italic: true, + underline: true, + line_through: true, + rich_text: { + paragraphs: [{ elements: [{ element_type: 0, text_element: { text: 'x' } }] }], + }, + } as WText, 300, 40); + expect(out).toContain('font-weight="bold"'); + expect(out).toContain('font-style="italic"'); + expect(out).toContain('text-decoration="underline line-through"'); + }); +}); + +// ─── renderCellText ─────────────────────────────────────────────────────── + +describe('renderCellText', () => { + it('wraps rendered text in translate ', () => { + const out = renderCellText( + { text: 'cell' } as WText, + undefined, + 10, 20, 80, 30, + ); + expect(out).toContain(''); + expect(out).toContain('cell'); + expect(out.endsWith('')).toBe(true); + }); + + it('merges meta defaults when cell lacks fields', () => { + const out = renderCellText( + { text: 'txt' } as WText, + { font_size: 18, text_color: '#00f', font_weight: 'bold' } as WText, + 0, 0, 100, 40, + ); + expect(out).toContain('font-size="18"'); + expect(out).toContain('fill="#00f"'); + expect(out).toContain('font-weight="bold"'); + }); + + it('cell fields take precedence over meta', () => { + const out = renderCellText( + { text: 't', font_size: 22 } as WText, + { font_size: 10 } as WText, + 0, 0, 100, 40, + ); + expect(out).toContain('font-size="22"'); + }); + + it('returns empty string when cell text renders empty', () => { + const out = renderCellText( + { text: '' } as WText, + undefined, + 0, 0, 100, 40, + ); + expect(out).toBe(''); + }); +}); diff --git a/tests/whiteboard/utils.test.ts b/tests/whiteboard/utils.test.ts new file mode 100644 index 0000000..b1f5623 --- /dev/null +++ b/tests/whiteboard/utils.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from 'vitest'; +import { + r, esc, styleAttrs, transformAttr, walkNodes, filterNodes, gWrap, nodeDataAttrs, + BORDER_WIDTH_MAP, DASH_ARRAY_MAP, +} from '../../src/whiteboard/utils.js'; + +// ─── r / esc ──────────────────────────────────────────────────────────────── + +describe('r (round to 2 decimals)', () => { + it('rounds to at most 2 decimals', () => { + expect(r(1.234)).toBe(1.23); + expect(r(1.235)).toBe(1.24); + expect(r(1)).toBe(1); + expect(r(0)).toBe(0); + }); + + it('handles negative numbers', () => { + expect(r(-1.234)).toBe(-1.23); + }); +}); + +describe('esc', () => { + it('escapes &, <, >, "', () => { + expect(esc('&<>"')).toBe('&<>"'); + }); + + it('escapes & first to avoid double-escaping', () => { + expect(esc('<')).toBe('&lt;'); + }); + + it('returns unchanged for safe strings', () => { + expect(esc('hello world')).toBe('hello world'); + }); +}); + +// ─── styleAttrs ───────────────────────────────────────────────────────────── + +describe('styleAttrs', () => { + it('returns default when style is undefined', () => { + expect(styleAttrs(undefined)).toBe('fill="none" stroke="#333"'); + }); + + it('applies fill_color', () => { + expect(styleAttrs({ fill_color: '#f00' } as any)).toContain('fill="#f00"'); + }); + + it('uses fill="none" when fill_color missing', () => { + expect(styleAttrs({} as any)).toContain('fill="none"'); + }); + + it('omits fill-opacity when equal to 100', () => { + expect(styleAttrs({ fill_opacity: 100 } as any)).not.toContain('fill-opacity'); + }); + + it('includes fill-opacity when != 100', () => { + expect(styleAttrs({ fill_opacity: 50 } as any)).toContain('fill-opacity="0.5"'); + }); + + it('applies border_color as stroke', () => { + expect(styleAttrs({ border_color: '#00f' } as any)).toContain('stroke="#00f"'); + }); + + it('sets stroke="none" when border_style is "none"', () => { + expect(styleAttrs({ border_style: 'none' } as any)).toContain('stroke="none"'); + }); + + it('omits stroke-width when == 1 (narrow default)', () => { + expect(styleAttrs({ border_width: 'narrow' } as any)).not.toContain('stroke-width'); + }); + + it('includes stroke-width when != 1', () => { + expect(styleAttrs({ border_width: 'bold' } as any)).toContain('stroke-width="3"'); + }); + + it('applies dash array for dashed border', () => { + expect(styleAttrs({ border_style: 'dash' } as any)).toContain('stroke-dasharray="8,4"'); + expect(styleAttrs({ border_style: 'dot' } as any)).toContain('stroke-dasharray="2,2"'); + }); +}); + +// ─── transformAttr ────────────────────────────────────────────────────────── + +describe('transformAttr', () => { + it('returns empty string for untransformed node', () => { + expect(transformAttr({ type: 'x' } as any)).toBe(''); + }); + + it('includes translate for x/y', () => { + expect(transformAttr({ type: 'x', x: 10, y: 20 } as any)).toContain('translate(10,20)'); + }); + + it('includes rotate when angle present', () => { + const result = transformAttr({ type: 'x', x: 0, y: 0, angle: 45, width: 100, height: 50 } as any); + expect(result).toContain('rotate(45,50,25)'); + }); + + it('includes scale for h_flip / v_flip', () => { + const result = transformAttr({ type: 'x', width: 100, height: 50, style: { h_flip: true } } as any); + expect(result).toContain('scale(-1,1)'); + }); +}); + +// ─── walkNodes ────────────────────────────────────────────────────────────── + +describe('walkNodes', () => { + it('visits all nodes in DFS order from roots', () => { + const nodes = [ + { id: 'a', type: 't', children: ['b', 'c'] }, + { id: 'b', type: 't', parent_id: 'a' }, + { id: 'c', type: 't', parent_id: 'a', children: ['d'] }, + { id: 'd', type: 't', parent_id: 'c' }, + ] as any[]; + + const visited: string[] = []; + walkNodes(nodes, (n) => { visited.push(n.id!); }); + expect(visited).toEqual(['a', 'b', 'c', 'd']); + }); + + it('passes depth correctly', () => { + const nodes = [ + { id: 'a', type: 't', children: ['b'] }, + { id: 'b', type: 't', parent_id: 'a', children: ['c'] }, + { id: 'c', type: 't', parent_id: 'b' }, + ] as any[]; + const depths: Record = {}; + walkNodes(nodes, (n, _p, d) => { depths[n.id!] = d; }); + expect(depths).toEqual({ a: 0, b: 1, c: 2 }); + }); + + it('skips children when visitor returns false', () => { + const nodes = [ + { id: 'a', type: 't', children: ['b'] }, + { id: 'b', type: 't', parent_id: 'a', children: ['c'] }, + { id: 'c', type: 't', parent_id: 'b' }, + ] as any[]; + const visited: string[] = []; + walkNodes(nodes, (n) => { + visited.push(n.id!); + if (n.id === 'b') return false; + }); + expect(visited).toEqual(['a', 'b']); // c skipped + }); + + it('walks mind_map_root direction children', () => { + const nodes = [ + { id: 'root', type: 'mm', mind_map_root: { up_children: ['u'], down_children: ['d'], left_children: [], right_children: [] } }, + { id: 'u', type: 'x', parent_id: 'root' }, + { id: 'd', type: 'x', parent_id: 'root' }, + ] as any[]; + const visited: string[] = []; + walkNodes(nodes, (n) => { visited.push(n.id!); }); + expect(visited).toContain('u'); + expect(visited).toContain('d'); + }); + + it('walks table cell children', () => { + const nodes = [ + { id: 'tbl', type: 'table', table: { cells: [{ children: ['c1'] }] } }, + { id: 'c1', type: 'x', parent_id: 'tbl' }, + ] as any[]; + const visited: string[] = []; + walkNodes(nodes, (n) => { visited.push(n.id!); }); + expect(visited).toEqual(['tbl', 'c1']); + }); +}); + +// ─── filterNodes ──────────────────────────────────────────────────────────── + +describe('filterNodes', () => { + it('keeps all nodes when predicate is always true', () => { + const nodes = [ + { id: 'a', type: 't', children: ['b'] }, + { id: 'b', type: 't', parent_id: 'a' }, + ] as any[]; + const result = filterNodes(nodes, () => true); + expect(result.map(n => n.id)).toEqual(['a', 'b']); + }); + + it('removes nodes failing predicate', () => { + const nodes = [ + { id: 'a', type: 'keep', children: ['b', 'c'] }, + { id: 'b', type: 'drop', parent_id: 'a' }, + { id: 'c', type: 'keep', parent_id: 'a' }, + ] as any[]; + const result = filterNodes(nodes, (n) => (n.type as string) !== 'drop'); + expect(result.map(n => n.id).sort()).toEqual(['a', 'c']); + // children reference updated + expect(result.find(n => n.id === 'a')!.children).toEqual(['c']); + }); + + it('preserves structure when only leaf nodes are excluded', () => { + const nodes = [ + { id: 'a', type: 'keep', children: ['b'] }, + { id: 'b', type: 'drop', parent_id: 'a' }, + ] as any[]; + const result = filterNodes(nodes, (n) => (n.type as string) !== 'drop'); + expect(result.map(n => n.id)).toEqual(['a']); + // a's children list is updated to exclude b + expect(result[0]!.children).toEqual([]); + }); +}); + +// ─── gWrap / nodeDataAttrs ────────────────────────────────────────────────── + +describe('gWrap', () => { + it('wraps content in tag', () => { + expect(gWrap('', [''])).toBe('\n\n'); + }); + + it('returns empty g when all parts empty', () => { + expect(gWrap('', ['', ''])).toBe(''); + }); + + it('includes transform attribute', () => { + expect(gWrap(' transform="translate(1,2)"', [''])).toContain('transform="translate(1,2)"'); + }); + + it('filters out empty parts', () => { + const result = gWrap('', ['', '', '', '']); + expect(result).toContain('\n'); + }); +}); + +describe('nodeDataAttrs', () => { + it('adds data-type for typed nodes', () => { + expect(nodeDataAttrs({ id: 'x', type: 'table' } as any)).toContain('data-type="table"'); + expect(nodeDataAttrs({ id: 'x', type: 'group' } as any)).toContain('data-type="group"'); + }); + + it('omits data-type for non-typed nodes', () => { + expect(nodeDataAttrs({ id: 'x', type: 'shape' } as any)).not.toContain('data-type'); + }); + + it('omits data-id when id not referenced', () => { + expect(nodeDataAttrs({ id: 'x', type: 'shape' } as any, new Set())).not.toContain('data-id'); + }); + + it('includes data-id when id is referenced', () => { + expect(nodeDataAttrs({ id: 'x', type: 'shape' } as any, new Set(['x']))).toContain('data-id="x"'); + }); +}); + +// ─── Constants ────────────────────────────────────────────────────────────── + +describe('constants', () => { + it('exposes BORDER_WIDTH_MAP values', () => { + expect(BORDER_WIDTH_MAP.narrow).toBe(1); + expect(BORDER_WIDTH_MAP.bold).toBe(3); + }); + + it('exposes DASH_ARRAY_MAP values', () => { + expect(DASH_ARRAY_MAP.solid).toBe(''); + expect(DASH_ARRAY_MAP.dash).toBe('8,4'); + expect(DASH_ARRAY_MAP.dot).toBe('2,2'); + }); +}); diff --git a/tests/whiteboard/yaml/index.test.ts b/tests/whiteboard/yaml/index.test.ts new file mode 100644 index 0000000..0e13b99 --- /dev/null +++ b/tests/whiteboard/yaml/index.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { whiteboardNodesToYaml } from '../../../src/whiteboard/yaml/index.js'; +import type { WNode } from '../../../src/whiteboard/types.js'; + +// ─── Helper: 创建最小化的 WNode ────────────────────────────────────────── + +function mk (partial: Partial): WNode { + return { type: 'text_shape', ...partial } as WNode; +} + +describe('whiteboardNodesToYaml', () => { + it('handles empty input', () => { + const { yaml, imageTokens } = whiteboardNodesToYaml([]); + expect(yaml).toContain('whiteboard:'); + expect(yaml).toContain('nodes:'); + expect(imageTokens).toEqual([]); + }); + + it('discards paint and svg nodes', () => { + const nodes: WNode[] = [ + mk({ id: 'p1', type: 'paint' as any }), + mk({ id: 's1', type: 'svg' as any }), + mk({ id: 't1', type: 'text_shape', text: { text: 'keep' } as any }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('text: keep'); + expect(yaml).not.toContain('paint'); + expect(yaml).not.toContain('svg'); + }); + + it('converts text_shape with text', () => { + const nodes: WNode[] = [mk({ id: 'a', type: 'text_shape', text: { text: 'Hello' } as any })]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: text_shape'); + expect(yaml).toContain('text: Hello'); + }); + + it('converts composite_shape with shape type', () => { + const nodes: WNode[] = [ + mk({ id: 'c', type: 'composite_shape', composite_shape: { type: 'ellipse' } as any, text: { text: 'T' } as any }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('shape: ellipse'); + expect(yaml).toContain('text: T'); + }); + + it('defaults composite_shape to rect when type missing', () => { + const nodes: WNode[] = [mk({ id: 'c', type: 'composite_shape' })]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('shape: rect'); + }); + + it('collects image tokens', () => { + const nodes: WNode[] = [ + mk({ id: 'i1', type: 'image', image: { token: 'TOK1' } as any }), + mk({ id: 'i2', type: 'image', image: { token: 'TOK2' } as any }), + ]; + const { yaml, imageTokens } = whiteboardNodesToYaml(nodes); + expect(imageTokens).toEqual(['TOK1', 'TOK2']); + expect(yaml).toContain('token: TOK1'); + expect(yaml).toContain('token: TOK2'); + }); + + it('converts connector with from/to and labels', () => { + const nodes: WNode[] = [ + mk({ id: 'a', type: 'text_shape', text: { text: 'A' } as any }), + mk({ id: 'b', type: 'text_shape', text: { text: 'B' } as any }), + mk({ + id: 'c', + type: 'connector', + connector: { + shape: 'curve', + start: { attached_object: { id: 'a' }, arrow_style: 'filled' }, + end: { attached_object: { id: 'b' }, arrow_style: 'none' }, + captions: { data: [{ text: 'label' }] }, + } as any, + }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('from: a'); + expect(yaml).toContain('to: b'); + expect(yaml).toContain('connector_shape: curve'); + expect(yaml).toContain('label: label'); + expect(yaml).toContain('start_arrow: filled'); + expect(yaml).not.toContain('end_arrow'); // 'none' is stripped + }); + + it('marks connector endpoints as referenced (adds data-id)', () => { + const nodes: WNode[] = [ + mk({ id: 'a', type: 'text_shape', text: { text: 'A' } as any }), + mk({ + id: 'c', + type: 'connector', + connector: { + start: { attached_object: { id: 'a' } }, + end: { attached_object: { id: 'a' } }, + } as any, + }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + // Referenced nodes include their id field + expect(yaml).toContain('id: a'); + }); + + it('drops connector without connector field', () => { + const nodes: WNode[] = [mk({ id: 'c', type: 'connector' })]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).not.toContain('type: connector'); + }); + + it('converts group with children', () => { + const nodes: WNode[] = [ + mk({ id: 'g', type: 'group', children: ['c1', 'c2'] }), + mk({ id: 'c1', type: 'text_shape', parent_id: 'g', text: { text: 'child1' } as any }), + mk({ id: 'c2', type: 'text_shape', parent_id: 'g', text: { text: 'child2' } as any }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: group'); + expect(yaml).toContain('child1'); + expect(yaml).toContain('child2'); + }); + + it('converts section with title', () => { + const nodes: WNode[] = [ + mk({ id: 's', type: 'section' as any, section: { title: 'My Section' } as any, children: ['c1'] }), + mk({ id: 'c1', type: 'text_shape', parent_id: 's', text: { text: 'inside' } as any }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('title: My Section'); + expect(yaml).toContain('inside'); + }); + + it('converts sticky_note with color', () => { + const nodes: WNode[] = [ + mk({ + id: 's', + type: 'sticky_note', + text: { text: 'note' } as any, + style: { fill_color: '#ff0' } as any, + }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: sticky_note'); + expect(yaml).toContain('text: note'); + expect(yaml).toContain('color: "#ff0"'); + }); + + it('converts table with cells and merges', () => { + const nodes: WNode[] = [ + mk({ + id: 't', + type: 'table', + table: { + meta: { row_num: 2, col_num: 2 }, + cells: [ + { row_index: 1, col_index: 1, text: { text: 'A' }, merge_info: { row_span: 1, col_span: 2 } }, + { row_index: 2, col_index: 1, text: { text: 'C' } }, + ], + } as any, + }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: table'); + expect(yaml).toContain('rows: 2'); + expect(yaml).toContain('cols: 2'); + expect(yaml).toContain('text: A'); + expect(yaml).toContain('col_span: 2'); + expect(yaml).toContain('text: C'); + }); + + it('table without meta falls back to base', () => { + const nodes: WNode[] = [mk({ id: 't', type: 'table', table: {} as any })]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: table'); + expect(yaml).not.toContain('rows:'); + }); + + it('converts mind_map tree structure', () => { + const nodes: WNode[] = [ + mk({ + id: 'root', + type: 'mind_map', + mind_map_root: {} as any, + text: { text: 'Root Topic' } as any, + }), + mk({ + id: 'c1', + type: 'mind_map', + parent_id: 'root', + mind_map_node: { parent_id: 'root' } as any, + text: { text: 'Child 1' } as any, + }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: mind_map'); + expect(yaml).toContain('mind_map:'); + expect(yaml).toContain('text: Root Topic'); + expect(yaml).toContain('text: Child 1'); + }); + + it('mind_map without mind_map_root is dropped at top level', () => { + const nodes: WNode[] = [mk({ id: 'n', type: 'mind_map' })]; + const { yaml } = whiteboardNodesToYaml(nodes); + // No root → no mind_map node in output + expect(yaml).not.toContain('mind_map:'); + }); + + it('fallback handles unknown types', () => { + const nodes: WNode[] = [ + mk({ id: 'u', type: 'life_line' as any, text: { text: 'custom' } as any }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + expect(yaml).toContain('type: life_line'); + expect(yaml).toContain('text: custom'); + }); + + it('collects tokens from nested images in groups', () => { + const nodes: WNode[] = [ + mk({ id: 'g', type: 'group', children: ['i1'] }), + mk({ id: 'i1', type: 'image', parent_id: 'g', image: { token: 'NESTED' } as any }), + ]; + const { imageTokens } = whiteboardNodesToYaml(nodes); + expect(imageTokens).toContain('NESTED'); + }); + + it('collects tokens from images inside table cells', () => { + const nodes: WNode[] = [ + mk({ + id: 't', + type: 'table', + table: { + meta: { row_num: 1, col_num: 1 }, + cells: [{ row_index: 1, col_index: 1, children: ['ic'] }], + } as any, + }), + mk({ id: 'ic', type: 'image', parent_id: 't', image: { token: 'TABLE_IMG' } as any }), + ]; + const { imageTokens } = whiteboardNodesToYaml(nodes); + expect(imageTokens).toContain('TABLE_IMG'); + }); + + it('sorts root nodes by z_index', () => { + const nodes: WNode[] = [ + mk({ id: 'a', type: 'text_shape', text: { text: 'high' } as any, z_index: 10 }), + mk({ id: 'b', type: 'text_shape', text: { text: 'low' } as any, z_index: 1 }), + ]; + const { yaml } = whiteboardNodesToYaml(nodes); + // 'low' (z_index=1) should come before 'high' (z_index=10) + const lowIdx = yaml.indexOf('low'); + const highIdx = yaml.indexOf('high'); + expect(lowIdx).toBeLessThan(highIdx); + }); +}); diff --git a/tests/whiteboard/yaml/serialize.test.ts b/tests/whiteboard/yaml/serialize.test.ts new file mode 100644 index 0000000..fb508c4 --- /dev/null +++ b/tests/whiteboard/yaml/serialize.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; +import { serializeYaml } from '../../../src/whiteboard/yaml/serialize.js'; + +describe('serializeYaml', () => { + // ─── Scalars ────────────────────────────────────────────────────────────── + + describe('scalars', () => { + it('serializes numbers', () => { + expect(serializeYaml(42)).toBe('42\n'); + expect(serializeYaml(3.14)).toBe('3.14\n'); + }); + + it('serializes booleans', () => { + expect(serializeYaml(true)).toBe('true\n'); + expect(serializeYaml(false)).toBe('false\n'); + }); + + it('serializes plain string without quotes', () => { + expect(serializeYaml('hello')).toBe('hello\n'); + }); + + it('quotes empty string', () => { + expect(serializeYaml('')).toBe('""\n'); + }); + + it('null / undefined become empty output', () => { + expect(serializeYaml(null)).toBe('\n'); + expect(serializeYaml(undefined)).toBe('\n'); + }); + }); + + // ─── String Quoting ─────────────────────────────────────────────────────── + + describe('string quoting', () => { + it('quotes strings containing colon', () => { + expect(serializeYaml('key: value').trim()).toBe('"key: value"'); + }); + + it('quotes strings containing #', () => { + expect(serializeYaml('a#b').trim()).toBe('"a#b"'); + }); + + it('quotes strings that look like booleans/null', () => { + expect(serializeYaml('true').trim()).toBe('"true"'); + expect(serializeYaml('null').trim()).toBe('"null"'); + expect(serializeYaml('yes').trim()).toBe('"yes"'); + }); + + it('quotes strings that look like numbers', () => { + expect(serializeYaml('123').trim()).toBe('"123"'); + expect(serializeYaml('1.5e3').trim()).toBe('"1.5e3"'); + }); + + it('quotes strings with leading/trailing spaces', () => { + expect(serializeYaml(' padded ').trim()).toBe('" padded "'); + }); + + it('quotes strings starting with special chars', () => { + expect(serializeYaml('- item').trim()).toBe('"- item"'); + expect(serializeYaml('#hash').trim()).toBe('"#hash"'); + expect(serializeYaml('{brace').trim()).toBe('"{brace"'); + }); + + it('escapes backslash and double-quote inside quoted string (when other quoting trigger is present)', () => { + // 需要有触发引用的字符(如冒号),才会走到引号+转义分支 + expect(serializeYaml('a"b: c').trim()).toBe('"a\\"b: c"'); + expect(serializeYaml('a\\b: c').trim()).toBe('"a\\\\b: c"'); + }); + }); + + // ─── Arrays ─────────────────────────────────────────────────────────────── + + describe('arrays', () => { + it('serializes empty array as []', () => { + expect(serializeYaml([]).trim()).toBe('[]'); + }); + + it('serializes array of scalars', () => { + expect(serializeYaml(['a', 'b', 'c'])).toBe('- a\n- b\n- c\n'); + }); + + it('serializes array of numbers', () => { + expect(serializeYaml([1, 2, 3])).toBe('- 1\n- 2\n- 3\n'); + }); + + it('serializes multi-line string with literal block', () => { + const out = serializeYaml(['line1\nline2']); + expect(out).toContain('- |'); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + }); + }); + + // ─── Objects ────────────────────────────────────────────────────────────── + + describe('objects', () => { + it('serializes empty object as {}', () => { + expect(serializeYaml({}).trim()).toBe('{}'); + }); + + it('serializes flat scalar object inline', () => { + expect(serializeYaml({ x: 1, y: 2 }).trim()).toBe('{x: 1, y: 2}'); + }); + + it('does not inline objects with >4 keys', () => { + const out = serializeYaml({ a: 1, b: 2, c: 3, d: 4, e: 5 }); + expect(out).not.toContain('{'); + expect(out).toContain('a: 1'); + expect(out).toContain('e: 5'); + }); + + it('does not inline object with long string value', () => { + const longStr = 'x'.repeat(61); + const out = serializeYaml({ k: longStr }); + expect(out).not.toContain('{'); + expect(out).toContain(`k: ${longStr}`); + }); + + it('omits null/undefined values', () => { + const out = serializeYaml({ a: 1, b: null, c: undefined, d: 2 }); + expect(out).not.toContain('b:'); + expect(out).not.toContain('c:'); + expect(out).toContain('a: 1'); + expect(out).toContain('d: 2'); + }); + + it('serializes nested object (inner inlined when small)', () => { + const out = serializeYaml({ outer: { inner: 'value' } }); + expect(out).toContain('outer:'); + // small inner object gets inlined on next line + expect(out).toContain('{inner: value}'); + }); + + it('serializes nested non-inlinable object with indentation', () => { + // 5 keys → not inlinable, uses multi-line block with indent + const out = serializeYaml({ outer: { a: 1, b: 2, c: 3, d: 4, e: 5 } }); + expect(out).toContain('outer:'); + expect(out).toContain(' a: 1'); + expect(out).toContain(' e: 5'); + }); + + it('serializes multi-line string as literal block in non-inlinable object', () => { + // 包含非标量值时对象无法 inline,走多行路径,才会识别多行字符串 + const out = serializeYaml({ items: [1, 2], text: 'line1\nline2\nline3' }); + expect(out).toContain('text: |'); + expect(out).toContain(' line1'); + expect(out).toContain(' line2'); + }); + }); + + // ─── Arrays of Objects (core whiteboard shape) ──────────────────────────── + + describe('arrays of objects', () => { + it('first key on same line as -, scalars', () => { + const out = serializeYaml([{ type: 'shape', text: 'hi' }]); + expect(out).toContain('- type: shape'); + expect(out).toContain(' text: hi'); + }); + + it('first key with object value opens new block', () => { + const out = serializeYaml([{ children: [{ type: 'x' }] }]); + expect(out).toContain('- children:'); + expect(out).toContain('type: x'); + }); + + it('first key with multi-line string uses literal block', () => { + const out = serializeYaml([{ text: 'a\nb' }]); + expect(out).toContain('- text: |'); + }); + + it('skips empty objects in arrays', () => { + const out = serializeYaml([{ a: null }, { b: 1 }]); + expect(out).toContain('b: 1'); + expect(out).not.toContain('a:'); + }); + }); + + // ─── Snapshot: realistic whiteboard output ──────────────────────────────── + + describe('integration', () => { + it('produces expected yaml for whiteboard-like structure', () => { + const data = { + whiteboard: { + nodes: [ + { type: 'text_shape', text: 'Hello' }, + { type: 'group', children: [{ type: 'image', token: 'IMG1' }] }, + ], + }, + }; + const out = serializeYaml(data); + expect(out).toContain('whiteboard:'); + expect(out).toContain(' nodes:'); + expect(out).toContain('- type: text_shape'); + expect(out).toContain('text: Hello'); + expect(out).toContain('- type: group'); + expect(out).toContain('children:'); + expect(out).toContain('type: image'); + expect(out).toContain('token: IMG1'); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fa4c8b1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + globals: true, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/cli.ts'], + }, + }, +});