Files
peoplelib/src/_test/ui.test.js
T
lofyer d6078098d6
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
feat: 分类显示书籍数量并发布 v2.1.2
2026-08-06 18:27:47 +08:00

1015 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const utilFile = path.join(__dirname, '..', 'ui', 'util.js');
const libFile = path.join(__dirname, '..', 'ui', 'views', 'library.js');
const utilSrc = fs.readFileSync(utilFile, 'utf8');
// util.js 只做 window.X = ... 赋值,没有加载期副作用,
// 因此可以整体求值拿到真实实现(DOM 依赖都在调用时才触发)。
function loadUtil() {
const win = {};
const store = new Map();
win.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v))
};
new Function('window', 'localStorage', 'document', utilSrc)(win, win.localStorage, undefined);
return win;
}
// style="..." 里的值会先被 HTML 解码,再交给 CSS 解析
function htmlDecode(s) {
return s.replace(/'/g, "'").replace(/"/g, '"')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
test('escapeHtml 覆盖全部危险字符', () => {
const { escapeHtml } = loadUtil();
assert.strictEqual(escapeHtml(`<a href="x">&'`), '&lt;a href=&quot;x&quot;&gt;&amp;&#39;');
assert.strictEqual(escapeHtml(null), '');
assert.strictEqual(escapeHtml(undefined), '');
assert.strictEqual(escapeHtml(0), '0');
});
test('coverStyle 阻断 style 属性逃逸', () => {
const { coverStyle } = loadUtil();
const out = coverStyle('https://evil/a.jpg") onerror="alert(1)');
assert.ok(!out.includes('"'), '裸引号泄漏: ' + out);
assert.ok(out.includes('&quot;'), '未做 HTML 转义: ' + out);
});
test('coverStyle 阻断 CSS 串逃逸', () => {
const { coverStyle } = loadUtil();
// 浏览器会先 HTML 解码属性值,再按 CSS 解析,这里模拟同样的两步
const css = htmlDecode(coverStyle("https://evil/a.jpg'); background:url('x"));
const inner = css.replace(/^background-image:url\('/, '').replace(/'\)$/, '');
assert.ok(!/(^|[^\\])'/.test(inner), 'CSS 单引号未转义,可提前闭合 url(): ' + css);
assert.ok(!/(^|[^\\])\)/.test(inner), 'CSS 右括号未转义: ' + css);
});
test('coverStyle 拒绝换行注入', () => {
const { coverStyle } = loadUtil();
assert.strictEqual(coverStyle('https://x/a.jpg\n background:red'), '');
});
test('coverStyle 正常输入仍可用', () => {
const { coverStyle } = loadUtil();
assert.strictEqual(coverStyle(''), '');
// 转义后浏览器实际解析到的地址才是关注点
const remote = htmlDecode(coverStyle('https://x/a.jpg')).replace(/\\(.)/g, '$1');
assert.strictEqual(remote, "background-image:url('https://x/a.jpg')");
const local = htmlDecode(coverStyle('C:\\books\\c.jpg')).replace(/\\([('")])/g, '$1');
assert.ok(local.includes('file:///C:/books/c.jpg'), local);
});
test('formatDate 补零', () => {
const { formatDate } = loadUtil();
assert.strictEqual(formatDate(0), '');
assert.strictEqual(formatDate(new Date(2024, 0, 5).getTime()), '2024-01-05');
});
test('enabledSources 存取;损坏数据回退为 null', () => {
const win = loadUtil();
assert.strictEqual(win.getEnabledSources(), null);
win.setEnabledSources(['arxiv', 'pmc']);
assert.deepStrictEqual(win.getEnabledSources(), ['arxiv', 'pmc']);
win.localStorage.setItem('enabledSources', '{坏json');
assert.strictEqual(win.getEnabledSources(), null, '损坏数据应回退而不是抛错');
});
test('添加本地内容支持文件、文件夹和上级目录分类选项', () => {
const src = fs.readFileSync(libFile, 'utf8');
assert.match(src, /name="localImportSource" value="files"/);
assert.match(src, /name="localImportSource" value="folder"/);
assert.match(src, /window\.api\.library\.pickLocal\(source\)/);
assert.match(src, /value="shelf"[\s\S]+上一级目录作为书架/);
assert.match(src, /value="tag"[\s\S]+上一级目录作为标签/);
assert.match(src, /window\.api\.library\.importLocal\(selection\.selectionId,\s*options\)/);
});
test('写进 HTML 的字段插值都过 escapeHtml', () => {
for (const f of ['views/browse.js', 'views/library.js']) {
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', f), 'utf8');
// 只看真正拼 HTML 的行(含标签),DOM 选择器之类的插值不在此列
const bad = [];
src.split('\n').forEach((line, i) => {
if (!/<[a-z]/i.test(line)) return;
for (const m of line.match(/\$\{(?!escapeHtml|coverStyle)[^}]*\}/g) || []) {
if (/^\$\{(it|d|f|l|s|e|b)\.[a-zA-Z_]+\}$/.test(m)) bad.push(`${f}:${i + 1} ${m}`);
}
});
assert.deepStrictEqual(bad, [], `存在未转义的 HTML 插值:\n${bad.join('\n')}`);
}
});
test('index.html 保留 CSP 且未开启 nodeIntegration', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
assert.ok(/Content-Security-Policy/.test(html), '缺少 CSP');
assert.ok(/default-src 'self'/.test(html));
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
assert.ok(/contextIsolation:\s*true/.test(main));
assert.ok(/nodeIntegration:\s*false/.test(main));
});
test('我的笔记页可新建关联或无关联笔记', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
assert.match(html, /data-tab="notes">我的笔记</);
assert.match(html, /id="addGlobalNoteBtn"/);
assert.match(notes, /window\.api\.library\.list\(\)/);
assert.match(notes, /source:\s*'manual'/);
assert.match(notes, /<option value="">不关联书籍<\/option>/);
assert.match(notes, /window\.api\.reader\.addNote\(entryId,\s*note\)/);
assert.match(notes, /window\.api\.reader\.addStandaloneNote\(note\)/);
assert.match(notes, /note\.associated === false\s*\?\s*'未关联书籍'/);
});
test('书库支持标题作者模糊搜索、侧栏滚动和稳定封面占位卡', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(html, /id="librarySearchInput"[^>]+搜索标题或作者/);
assert.match(html, /id="librarySearchBtn"/);
assert.match(html, /id="libraryClearSearchBtn"/);
assert.match(html, /id="sortSelect"[\s\S]*value="recent">最近阅读/);
assert.match(library, /recent:\s*\(a,\s*b\)[\s\S]*lastReadAt/);
assert.match(library, /function matchesSearch\(item, query\)/);
assert.match(library, /item\.authors/);
assert.match(library, /isSubsequence/);
assert.match(library, /function reconcileCards/);
assert.doesNotMatch(library, /grid\.innerHTML\s*=\s*items\.map/);
const sidebarRule = css.match(/\.library-sidebar\s*\{([^}]*)\}/);
assert.ok(sidebarRule);
assert.match(sidebarRule[1], /max-height:\s*calc\(100vh - 84px\)/);
assert.match(sidebarRule[1], /overflow-y:\s*auto/);
assert.match(css, /\.card:hover \.card-cover:not\(\[data-cover-state="pending"\]\)/);
assert.match(library, /data-cover-state="\$\{it\.cover \? 'ready' : 'pending'\}"/);
});
test('书库页提供可管理标签目录和整理多选下拉', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
assert.match(html, /id="libraryShelfList"/);
assert.match(html, /id="libraryTagList"/);
assert.match(html, /id="addTagBtn"/);
assert.match(library, /window\.api\.library\.listShelves\(\)/);
assert.match(library, /window\.api\.library\.addTag\(\{ name \}\)/);
assert.match(library, /window\.api\.library\.updateTag\(tag\.id/);
assert.match(library, /window\.api\.library\.removeTag\(tag\.id\)/);
assert.match(library, /cardAction\('organize'/);
assert.match(library, /<details id="libraryBookTags"/);
assert.match(library, /#libraryBookTags input\[type="checkbox"\]:checked/);
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
});
test('全部书籍、未分类和每个书架都显示实时书籍数量', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
assert.match(
html,
/data-shelf="">[\s\S]*全部书籍[\s\S]*class="library-filter-count">0<\/span>/
);
assert.match(
html,
/data-shelf="__uncategorized__">[\s\S]*未分类[\s\S]*class="library-filter-count">0<\/span>/
);
assert.match(library, /items\.filter\(\(item\) => !item\.shelfId\)\.length/);
assert.match(library, /count\.textContent = String\(shelf\.count \|\| 0\)/);
assert.match(library, /filter\.append\(name, count\)/);
});
test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
const center = fs.readFileSync(path.join(__dirname, '..', 'ui', 'download-center.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(html, /id="taskCenterBtn"/);
assert.match(html, /id="taskCenterPanel"/);
assert.ok(html.indexOf('download-center.js') < html.indexOf('views/browse.js'));
assert.match(app, /DownloadCenter\.init\(\)/);
assert.match(browse, /window\.DownloadCenter\.start\(\{/);
assert.doesNotMatch(browse, /await window\.api\.downloadFile/);
assert.match(center, /window\.api\.downloads\.run\(/);
assert.match(center, /window\.api\.downloads\.pause\(/);
assert.match(center, /window\.api\.downloads\.delete\(/);
assert.match(center, /data-task-action="pause"/);
assert.match(center, /data-task-action="resume"/);
assert.match(center, /data-task-action="delete"/);
assert.match(preload, /pause:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:pause'/);
assert.match(preload, /delete:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:delete'/);
assert.match(center, /task\.status = 'complete'/);
assert.match(center, /task\.status = 'failed'/);
assert.match(center, /data-task-action="open"/);
assert.match(css, /\.task-center-panel\s*\{/);
assert.match(css, /\.task-center-badge\s*\{/);
assert.match(browse, /createDownloadProgress/);
assert.match(browse, /updateDownloadProgress/);
assert.match(browse, /classList\.add\('downloaded'\)/);
const rule = css.match(/\.dl-btn\.downloaded\s*\{([^}]*)\}/);
assert.ok(rule, '缺少下载完成按钮样式');
assert.match(rule[1], /background:\s*var\(--green\)/);
assert.doesNotMatch(rule[1], /background:\s*var\(--accent\)/);
assert.match(rule[1], /color:\s*#07130b/);
});
test('主窗口在设置旁提供持久化明暗主题切换', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const themeAt = html.indexOf('id="uiThemeBtn"');
const settingsAt = html.indexOf('data-tab="settings"');
assert.ok(themeAt >= 0 && themeAt < settingsAt, '主题按钮不在设置按钮旁边');
assert.match(app, /window\.api\.ui\.getTheme\(\)/);
assert.match(app, /window\.api\.ui\.setTheme\(next\)/);
assert.match(css, /:root\[data-ui-theme="light"\]/);
assert.match(css, /--bg:\s*#f4f7fb/);
});
test('人民阅读器品牌与主题图标显示在界面左上角', () => {
for (const file of ['index.html', 'reader.html']) {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', file), 'utf8');
assert.match(html, /<title>PeopleLib<\/title>/);
assert.match(html, /人民阅读器/);
assert.match(html, /brand-logo-dark[^>]+icons\/dist\/dark\/icon-32\.png/);
assert.match(html, /brand-logo-light[^>]+icons\/dist\/light\/icon-32\.png/);
}
});
test('阅读器控件隔离正文选择并提供 PDF 适宽、拖拽和文本选择工具', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
const pdfWorker = fs.readFileSync(path.join(__dirname, '..', 'ui', 'vendor', 'pdf.worker.range.mjs'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
assert.match(html, /id="fitWidthBtn"[\s\S]*aria-label="适应内容宽度"/);
assert.match(html, /data-annotation-tool="pan"[\s\S]*data-annotation-tool="text-select"/);
assert.match(css, /button,[\s\S]*\.statusbar,[\s\S]*user-select:\s*none/);
assert.match(shell, /isReaderControlTarget/);
assert.match(shell, /fitPdfWidth/);
assert.match(pdf, /function fitWidthScale/);
assert.match(pdf, /className = 'endOfContent'/);
assert.match(pdf, /const endPage = pageOfNode\(range\.endContainer\)/);
assert.match(pdf, /pdfx-tool-pan/);
assert.match(pdf, /extends pdfjs\.PDFDataRangeTransport/);
assert.match(pdf, /pdf\.worker\.range\.mjs/);
assert.match(pdf, /disableAutoFetch\s*=\s*true/);
assert.match(pdfWorker, /super\(new Uint8Array\(0\), 0, length, null\)/);
assert.doesNotMatch(pdfWorker, /super\(new Uint8Array\(length\), 0, length, null\)/);
assert.match(pdfWorker, /MAX_SPARSE_PDF_CACHE_BYTES = 256 \* 1024 \* 1024/);
assert.match(pdfWorker, /MAX_GROUPED_RANGE_CHUNKS = 4/);
assert.match(pdfWorker, /offset = offset \* 256 \+ offsetByte/);
assert.match(pdfWorker, /_loadedChunks\.delete\(chunk\)/);
// 稀疏基础缓冲区是空的,字体哈希不能再直接按 stream.bytes.buffer 建视图,
// 否则字体会静默变成不可见的 ErrorFont
assert.match(pdfWorker, /stream\.getByteRange\(stream\.start, stream\.end\)/);
assert.doesNotMatch(pdfWorker, /new Uint8Array\(stream\.bytes\.buffer, stream\.start, stream\.end - stream\.start\)/);
// 256 MB 以内仍用官方 worker,只有超出才启用稀疏 worker
assert.match(pdf, /STANDARD_WORKER_MAX_BYTES/);
assert.match(pdf, /SPARSE_WORKER_URL/);
assert.match(pdf, /this\.active < 8/);
assert.match(pdf, /Promise\.race\(\[task\.promise, rangeFailurePromise\]\)/);
assert.match(shell, /openPdfRangeSource/);
assert.match(shell, /api\.reader\.rangeRead/);
assert.match(preload, /rangeOpen:[\s\S]*reader:rangeOpen/);
assert.match(preload, /rangeRead:[\s\S]*reader:rangeRead/);
assert.match(preload, /rangeClose:[\s\S]*reader:rangeClose/);
});
test('AI 助手提供受限图像上下文和可扩展 OCR 契约', () => {
const index = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
const ocr = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'ocr-provider.mjs'), 'utf8');
const contract = fs.readFileSync(path.join(__dirname, '..', 'reader', 'visual-context.js'), 'utf8');
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
assert.match(index, /id="aiProtocol"[\s\S]*value="anthropic"[\s\S]*value="openai-responses"[\s\S]*value="chat-completions"/);
assert.match(index, /id="aiVision"[^>]*type="checkbox"/);
assert.match(app, /protocol:\s*\$\('aiProtocol'\)\.value/);
assert.match(reader, /value="page-image"[\s\S]*value="region-image"/);
assert.match(reader, /id="aiVisualCard"[\s\S]*id="aiOcrBtn"[\s\S]*disabled/);
assert.deepStrictEqual(
[...reader.matchAll(/data-ai-task="([^"]+)"/g)].map((match) => match[1]),
['summarize']
);
assert.match(shell, /function beginVisualSelection/);
assert.match(shell, /function confirmVisualSelection/);
assert.match(shell, /toAiVisualContext/);
assert.match(pdf, /async function captureVisual/);
assert.match(pdf, /function visualPageAtPoint/);
assert.match(epub, /function visualViewportRect/);
assert.match(ocr, /function registerOcrProvider/);
assert.match(ocr, /function recognizeOcr/);
assert.match(ocr, /signal:\s*options\.signal/);
assert.match(contract, /MAX_IMAGE_BYTES\s*=\s*3\s*\*\s*1024\s*\*\s*1024/);
assert.match(contract, /MAX_VISUAL_CONTEXTS\s*=\s*1/);
assert.match(contract, /图像内容与声明尺寸不匹配/);
assert.match(client, /type:\s*'image_url'/);
assert.match(client, /type:\s*'image'[\s\S]*type:\s*'base64'[\s\S]*media_type:/);
assert.match(client, /type:\s*'input_image'/);
assert.match(client, /当前模型配置未启用图像输入/);
assert.match(app, /模型已配置[\s\S]*尚缺 API Key/);
assert.match(app, /已保存的 API Key 无法读取,请重新输入/);
assert.match(shell, /模型已配置,但尚缺 API Key/);
assert.match(shell, /模型已配置,但已保存的 API Key 无法读取/);
assert.match(preload, /onChanged:\s*\(cb\)[\s\S]*ipcRenderer\.on\('ai:changed'/);
assert.match(shell, /api\.ai\.onChanged\(\(\)\s*=>\s*refreshAiStatus\(\)\)/);
assert.match(preload, /function captureReaderRect\(rect\)/);
assert.match(preload, /document\.getElementById\('docArea'\)/);
assert.match(preload, /captureRect:\s*\(rect\)\s*=>\s*captureReaderRect\(rect\)/);
assert.match(main, /ipcMain\.handle\('reader:captureRect'/);
assert.match(main, /截图区域无效或超出阅读器窗口/);
});
test('AI 上下文提供无需选中的当前页与全文范围', () => {
const reader = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const pdf = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs'), 'utf8');
const epub = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'epub-adapter.mjs'), 'utf8');
assert.match(reader, /<option value="document">全文<\/option>/);
assert.doesNotMatch(reader, /value="chapter"/);
// 只有 selection 需要选区,page/document 直接走 textOf
assert.match(shell, /if \(scope === 'selection'\)[\s\S]{0,400}请先在正文中选中文本/);
assert.match(shell, /scope === 'page' \? 'page' : 'document'/);
// 全文必须提示可能超限,并且始终弹确认框
assert.match(shell, /可能超过模型限制/);
assert.match(shell, /scope !== 'document' && chars <= CONFIRM_CHARS/);
// 正文不再本地截断,文案必须如实说明"完整发送 + 超限由接口报错",
// 否则界面显示的字数与实际外发字数不一致(实测 40 页只发出 8 页)
assert.match(shell, /全文将完整发送/);
assert.doesNotMatch(shell, /保留首尾并截断/);
const client = fs.readFileSync(path.join(__dirname, '..', 'reader', 'ai-client.js'), 'utf8');
assert.doesNotMatch(client, /let body = clipContext\(text\)/);
assert.match(client, /上下文超出模型窗口/);
// 旧设置迁移,避免升级后回落成 selection
assert.match(shell, /storedScope === 'chapter' \? 'document' : storedScope/);
assert.match(shell, /api\.settings\.set\('reader\.aiScope', 'document'\)/);
// 适配器真的取整本,而不是当前页 ±1
assert.match(pdf, /if \(span !== 'document'\) return pageText\(page\)/);
assert.match(pdf, /for \(let i = 1; i <= pageCount; i\+\+\)/);
assert.match(epub, /if \(span === 'document'\)[\s\S]{0,400}chapter < spine\.length/);
});
test('AI 图像上下文按更小的目标体积压缩且只用 JPEG', () => {
const visual = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'visual-context.mjs'), 'utf8');
assert.match(visual, /MAX_CAPTURE_DIMENSION = 1600/);
assert.match(visual, /TARGET_CAPTURE_BYTES = 400 \* 1024/);
assert.match(visual, /const qualities = \[0\.82, 0\.74, 0\.66, 0\.58\]/);
assert.match(visual, /bytes <= TARGET_CAPTURE_BYTES/);
// 缩到 800px 就停手,避免文字页被压糊
assert.match(visual, /<= 800\) break/);
// 只保留一条 JPEG 编码路径,不做格式回退
assert.deepStrictEqual([...visual.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]), ['image/jpeg']);
});
test('AI 回答使用固定版本 Markdown-it 和 DOMPurify 安全渲染', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const renderer = fs.readFileSync(path.join(__dirname, '..', 'ui', 'ai-markdown.js'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
assert.strictEqual(pkg.devDependencies['markdown-it'], '15.0.0');
assert.strictEqual(pkg.devDependencies.dompurify, '3.4.12');
assert.match(html, /vendor\/purify\.min\.js[\s\S]*vendor\/markdown-it\.min\.js[\s\S]*ai-markdown\.js/);
assert.match(renderer, /html:\s*false/);
assert.match(renderer, /purifier\.sanitize/);
assert.match(renderer, /renderer\.rules\.image/);
assert.match(renderer, /data-external-url/);
assert.match(renderer, /MAX_MARKDOWN_LENGTH\s*=\s*256\s*\*\s*1024/);
assert.match(shell, /scheduleAiOutput/);
assert.match(shell, /window\.AiMarkdown\.externalUrl/);
assert.match(shell, /addEventListener\('auxclick'/);
assert.match(css, /\.ai-output pre[\s\S]*overflow:\s*auto/);
for (const file of [
'vendor/markdown-it.min.js',
'vendor/markdown-it.LICENSE.txt',
'vendor/purify.min.js',
'vendor/DOMPurify.LICENSE.txt'
]) {
assert.ok(fs.existsSync(path.join(__dirname, '..', 'ui', file)), `${file} 未随应用提供`);
}
});
test('安装包和可执行文件保留 PeopleLib 产品名', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
assert.strictEqual(pkg.build.productName, 'PeopleLib');
assert.strictEqual(pkg.build.portable.artifactName, 'PeopleLib-${version}.exe');
assert.match(main, /app\.setName\('PeopleLib'\)/);
assert.match(build, /const PRODUCT = pkg\.productName \|\| 'PeopleLib'/);
});
test('设置关于页与 README 列出书库和内置阅读格式', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const readme = fs.readFileSync(path.join(__dirname, '..', '..', 'README.md'), 'utf8');
assert.match(html, /关于 PeopleLib/);
assert.match(html, /内置阅读[\s\S]*PDF、EPUB、MOBI、AZW、AZW3、TXT、MD/);
assert.match(html, /书库导入与管理[\s\S]*TXT、MD、DJVU、FB2、CBZ、CBR/);
assert.match(html, /Foliate[\s\S]*MOBI\/KF7\/KF8/);
assert.match(readme, /## 支持格式/);
assert.match(readme, /MOBI \/ AZW \/ AZW3[\s\S]*Foliate/);
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
});
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const adapter = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'mobi-adapter.mjs'), 'utf8');
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
assert.strictEqual(pkg.dependencies['foliate-js'], '1.0.1');
assert.match(main, /READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/);
assert.match(shell, /mobi:\s*mobi\.createMobiAdapter/);
assert.match(shell, /azw3:\s*mobi\.createMobiAdapter/);
assert.match(adapter, /from '\.\.\/\.\.\/\.\.\/node_modules\/foliate-js\/mobi\.js'/);
assert.match(adapter, /该 MOBI\/AZW 图书有 DRM 保护/);
assert.match(shell, /使用系统应用打开/);
assert.match(build, /node_modules', 'foliate-js'/);
});
test('书库卡片操作使用带悬浮提示的纯图标按钮', () => {
const library = fs.readFileSync(libFile, 'utf8');
assert.match(library, /const CARD_ICONS =/);
assert.match(library, /class="\$\{primary \? 'open-btn ' : ''\}icon-action"/);
assert.match(library, /title="\$\{label\}" aria-label="\$\{label\}"/);
for (const action of ['read', 'open', 'reveal', 'page', 'organize', 'remove']) {
assert.match(library, new RegExp(`cardAction\\('${action}'`));
}
});
test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版', () => {
const notes = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'notes.js'), 'utf8');
const rich = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.js'), 'utf8');
const mixed = fs.readFileSync(path.join(__dirname, '..', 'ui', 'mixed-note.js'), 'utf8');
const canvas = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-note.mjs'), 'utf8');
const canvasFlow = fs.readFileSync(path.join(__dirname, '..', 'ui', 'canvas-flow.mjs'), 'utf8');
const richCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'rich-note.css'), 'utf8');
const appCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const readerCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
const readerHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const indexHtml = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const store = fs.readFileSync(path.join(__dirname, '..', 'reader', 'store.js'), 'utf8');
const assets = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-assets.js'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
assert.match(notes, /id="newNoteRich"/);
assert.match(notes, /id="noteEditRich"/);
assert.match(notes, /window\.MixedNote\.mount/);
assert.match(notes, /选择笔记类型/);
assert.match(notes, /noteType/);
assert.match(readerHtml, /id="noteRichEditor"/);
assert.match(indexHtml, /vendor\/quill\/quill\.js/);
assert.match(indexHtml, /vendor\/quill\/quill\.snow\.css/);
assert.match(readerHtml, /vendor\/jspdf\.umd\.min\.js/);
assert.match(readerHtml, /<script src="rich-note\.js"><\/script>/);
assert.match(rich, /new window\.Quill/);
assert.match(rich, /version:\s*2,\s*ops/);
assert.doesNotMatch(rich, /document\.execCommand/);
assert.ok(
rich.indexOf("header.className = 'ql-header'") < rich.indexOf("['bold', '加粗']"),
'段落类型必须位于 B/I 等格式按钮之前'
);
assert.match(richCss, /\.ql-toolbar \.ql-picker-options/);
assert.match(richCss, /background:\s*var\(--bg-card\)/);
assert.match(richCss, /color:\s*var\(--text\)/);
assert.match(rich, /image\/jpeg,image\/png,image\/gif,image\/webp/);
assert.match(rich, /单张图片不能超过 2 MB/);
assert.match(mixed, /function mountTyped/);
assert.match(mixed, /options\.noteType/);
assert.match(indexHtml, /id="notesTypeTabs"/);
assert.match(indexHtml, />全部</);
assert.match(indexHtml, />画布笔记</);
assert.match(indexHtml, />读书笔记</);
assert.match(appCss, /\.notes-list[\s\S]*grid-template-columns/);
assert.match(canvas, /Import PDF|导入 PDF/);
assert.match(canvas, /Export PDF|导出 PDF/);
assert.match(canvas, /canvasKind/);
assert.match(canvas, /MAX_PAGES = 50/);
assert.match(canvas, /const BUTTON_ICONS =/);
assert.match(canvas, /canvas-note-icon/);
assert.match(canvas, /canvas-note-tool-group/);
assert.match(canvas, /\['flow-text', '全局文本'\]/);
assert.match(canvas, /mountFlowText/);
assert.match(canvasFlow, /canvasPageBreak/);
assert.match(canvasFlow, /columnWidth/);
assert.match(canvasFlow, /onPageCount/);
assert.match(canvasFlow, /renderPage/);
assert.match(canvasFlow, /suppressUserFollowSelection/);
assert.match(canvas, /await flowEditor\.flush\(\)/);
assert.match(canvas, /insertedPageId/);
assert.match(richCss, /\.canvas-flow-toolbar/);
assert.match(richCss, /\.canvas-flow-layer/);
const toolbarRule = richCss.match(/\.canvas-note-toolbar\s*\{([^}]*)\}/);
const viewportRule = richCss.match(/\.canvas-note-viewport\s*\{([^}]*)\}/);
const mainCanvasBodyRule = appCss.match(/\.canvas-note-modal \.modal-body\s*\{([^}]*)\}/);
const readerCanvasFieldsRule = readerCss.match(
/\.canvas-note-modal \.note-editor-fields\s*\{([^}]*)\}/
);
assert.ok(toolbarRule && viewportRule && mainCanvasBodyRule && readerCanvasFieldsRule);
assert.match(toolbarRule[1], /flex-wrap:\s*wrap/);
assert.match(toolbarRule[1], /overflow:\s*visible/);
assert.match(viewportRule[1], /overflow:\s*auto/);
assert.match(mainCanvasBodyRule[1], /overflow:\s*hidden/);
assert.match(readerCanvasFieldsRule[1], /overflow:\s*hidden/);
assert.match(store, /richImageTotalBytes:\s*8 \* 1024 \* 1024/);
assert.match(store, /normalizeCanvasContent/);
assert.match(store, /const VERSION = 6/);
assert.match(store, /normalizeCanvasFlow/);
assert.match(store, /NOTE_TYPES/);
assert.match(assets, /reader-note-assets/);
assert.match(assets, /senderId/);
assert.doesNotMatch(notes, /id="newNoteText"|id="noteEditText"/);
const functionAt = browse.indexOf('async function downloadFile');
const awaitAt = browse.indexOf('await window.api.library.findBySource', functionAt);
const snapshotAt = browse.indexOf('const meta = entryMeta()', functionAt);
assert.ok(snapshotAt > functionAt && snapshotAt < awaitAt, '下载元数据未在首次 await 前快照');
});
test('笔记表单控件样式不外溢到工具栏,关联下拉框限宽', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const noteWindowCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
const fieldRule = css.match(/\.note-edit-form select[^{]*\{([^}]*margin-top[^}]*)\}/);
assert.ok(fieldRule, '找不到笔记表单控件规则');
// 画布工具栏与 Quill 工具栏都是 .note-edit-form 的后代,
// 漏掉任一 :not() 就会把 margin-top / width:100% 灌进工具栏,
// 表现为工具栏凭空高出一截、分组高度对不齐
assert.match(fieldRule[0], /:not\(\.canvas-note-root select\)/);
assert.match(fieldRule[0], /:not\(\.ql-toolbar select\)/);
const capRule = css.match(/\.note-edit-form select[^{]*\{([^}]*max-width[^}]*)\}/);
assert.ok(capRule, '关联书籍下拉框没有限宽');
assert.match(capRule[1], /max-width:\s*320px/);
assert.match(capRule[1], /min-width:\s*0/);
assert.match(capRule[0], /:not\(\.canvas-note-root select\)/);
const metaRule = noteWindowCss.match(/\.note-window-meta select[\s\S]*?\{([^}]*)\}/);
assert.ok(metaRule, '笔记窗口下拉框没有限宽规则');
assert.match(metaRule[1], /max-width:\s*260px/);
assert.match(metaRule[1], /min-width:\s*0/);
});
test('笔记窗口标题栏只有品牌名,没有副标题', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
const titlebar = html.match(/<div class="titlebar">[\s\S]*?<div class="titlebar-spacer">/);
assert.ok(titlebar, '找不到笔记窗口标题栏');
// style.css 的 .titlebar-left 不是 flex(只有 reader.css 是),
// 放同级的 brand-sub 会掉到品牌名下面一行,把标题栏顶高
assert.doesNotMatch(titlebar[0], /brand-sub/);
assert.doesNotMatch(html, /noteWindowSubtitle/);
assert.doesNotMatch(shell, /noteWindowSubtitle|subtitle/);
assert.match(titlebar[0], /<span>笔记<\/span>/);
});
test('笔记窗口多标签:自带标签条样式,非激活视图隐藏,存活编辑器有上限', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note.html'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'note-window.css'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'note-shell.js'), 'utf8');
assert.match(html, /class="doctabs"/, '缺少标签条');
assert.match(html, /id="noteDirtyModal"/, '缺少未保存确认框');
// note.html 不加载 reader.css,标签条样式必须在 note-window.css 里自带一份
assert.doesNotMatch(html, /reader\.css/);
assert.match(css, /\.doctabs\s*\{/, '标签条样式缺失,标签会退化成竖排文字');
assert.match(css, /\.note-tab-view\.inactive[\s\S]*?display:\s*none/);
// 只留一个可见视图,否则多个 Quill/画布实例同时可见会互相抢焦点
assert.match(shell, /MAX_LIVE_EDITORS\s*=\s*\d+/);
// 取消关闭必须真的把 cancelClose 发出去:主进程的 closePending 不复位,
// 下次点关闭会被当成"正在处理"忽略,而看门狗仍会销毁带未保存内容的窗口
const abortAt = shell.indexOf('async function abortClose');
assert.ok(abortAt > 0, '缺少 abortClose');
const abortBody = shell.slice(abortAt, shell.indexOf('\n}', abortAt));
assert.match(abortBody, /await api\.notes\.cancelClose\(\)/);
assert.doesNotMatch(abortBody, /if\s*\(\s*true\s*\)\s*return/);
assert.match(abortBody, /closing = false/);
// 脏判定必须比对序列化内容:靠 keydown/pointerdown 之类的交互事件会误报,
// 画布加载时的 1→2 版本归一化本身就会改一次内容
assert.match(shell, /baselineKey/);
assert.doesNotMatch(shell, /addEventListener\('pointerdown'[\s\S]{0,120}dirty/);
});
test('书架操作对键盘焦点可见', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(css, /\.library-shelf-row:focus-within \.library-shelf-actions/);
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*none/);
});
test('侧栏行内操作不占据布局,选中高亮与静态筛选项等宽', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
// 留在文档流里会占掉约 47px,使书架/标签的选中亮条比「全部书籍」窄一截
const actionsRule = css.match(
/\.library-shelf-actions,\s*\n\.library-tag-actions\s*\{([^}]*)\}/
);
assert.ok(actionsRule, '书架与标签操作区应共用同一条规则');
assert.match(actionsRule[1], /position:\s*absolute/);
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*flex;\s*flex:\s*none/);
assert.match(css, /\.library-shelf-row \.library-filter,\s*\n\.library-tag-row \.library-filter\s*\{[^}]*padding-right:\s*48px/);
// 书架与标签共用同一个列表容器类,行间距不会一边有一边没有
assert.match(css, /\.library-filter-list\s*\{[^}]*gap:\s*1px/);
assert.match(html, /id="libraryShelfList" class="library-filter-list"/);
assert.match(html, /id="libraryTagList" class="library-filter-list"/);
assert.doesNotMatch(css, /\.library-tag-list\s*\{/);
});
test('书库多选提供全选、批量整理与批量移除', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
assert.match(html, /id="librarySelectModeBtn"[^>]*aria-pressed="false"/);
assert.match(html, /id="librarySelectAll"/);
assert.match(html, /id="libraryBulkOrganizeBtn"[^>]*disabled/);
assert.match(html, /id="libraryBulkRemoveBtn"[^>]*disabled/);
// 全选只覆盖当前筛选结果,且被筛掉的条目要从选中集合里剔除,
// 否则会对用户看不见的书执行批量操作
assert.match(library, /visibleIds = items\.map\(\(item\) => String\(item\.id\)\)/);
assert.match(library, /if \(!visible\.has\(id\)\) selectedIds\.delete\(id\)/);
assert.match(library, /visibleIds\.forEach\(\(id\) => selectedIds\.add\(id\)\)/);
// 多选时封面不能再触发阅读,否则勾选途中会误开阅读器
assert.match(library, /const coverActs = readable && !selectMode/);
assert.match(library, /\$\{coverActs \? 'data-act="read"/);
// 批量标签是增量语义:indeterminate 表示部分持有,跳过即保持原样
assert.match(library, /if \(box\.indeterminate\) return;/);
assert.match(library, /else if \(box\.dataset\.state !== 'none'\) strip\.push/);
assert.match(library, /box\.indeterminate = next === 'some'/);
assert.doesNotMatch(library, /#libraryBulkTags input\[type="checkbox"\]:checked/);
// 书架不一致时默认保持不变,不能把所选书籍统一挪走
assert.match(library, /if \(shelfValue !== '__keep__'\) patch\.shelfId/);
assert.match(library, /value="__keep__" selected>保持不变/);
// 批量移除沿用单本移除的两个可选项
assert.match(library, /id="bulkDelFiles"/);
assert.match(library, /id="bulkDelReadingData"/);
// 一次 IPC 提交整批,不再逐条 remove:书库索引是整体重写的,循环会造成写放大
assert.match(library, /window\.api\.library\.removeMany\(targets\.map\(\(item\) => item\.id\), choice\)/);
assert.match(library, /window\.api\.library\.updateMany\(patches\)/);
});
test('重新扫描发现文件缺失后提示清理书库条目', () => {
const library = fs.readFileSync(libFile, 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
assert.match(library, /const missing = Math\.max\(0, Number\(\(r\.data \|\| \{\}\)\.missing\)/);
assert.match(library, /confirmModal\(\s*'发现文件缺失'/);
assert.match(library, /对应分类和标签也会一并清理/);
assert.match(library, /笔记、书签、进度和标注会保留/);
assert.match(library, /window\.api\.library\.removeMissing\(\)/);
assert.match(preload, /removeMissing: \(\) => ipcRenderer\.invoke\('library:removeMissing'\)/);
});
test('卡片定位上下文不随多选状态消失', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立,
// 残留的复选框会按视口定位飞到左上角标题上闪一下
assert.match(css, /\n\.card\s*\{[^}]*position:\s*relative/);
assert.doesNotMatch(css, /#libraryTab\.select-mode \.card\s*\{\s*position:\s*relative/);
assert.match(css, /#libraryTab:not\(\.select-mode\) \.card-select\s*\{\s*display:\s*none/);
});
test('多选复选框用原生外观,不套衬底色块', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const wrap = css.match(/\.card-select\s*\{([^}]*)\}/);
assert.ok(wrap, '缺少复选框容器样式');
// padding + 背景板会让 13px 的原生复选框看起来套了一圈很粗的边框,
// 浅色封面上的可见性改用投影解决
assert.doesNotMatch(wrap[1], /padding:\s*[1-9]/);
assert.doesNotMatch(wrap[1], /background:\s*rgba/);
const input = css.match(/\.card-select input\s*\{([^}]*)\}/);
assert.ok(input, '缺少复选框样式');
assert.match(input[1], /drop-shadow/);
});
test('封面完整显示且比例一致,留白由模糊层填充', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const coverRule = css.match(/\n\.card-cover\s*\{([^}]*)\}/);
assert.ok(coverRule, '缺少封面样式');
// cover 会按各自比例裁掉不同的边,同一批封面看起来缩放程度不一致
assert.match(coverRule[1], /center\/contain/);
assert.doesNotMatch(coverRule[1], /center\/cover/);
// ::before 垫模糊底,::after 画清晰的完整封面。
// 只用 ::before 的话它会盖住父元素自己的背景,封面整张变成模糊的
// 共用规则里 ::after 也单独占一行,所以不能靠 \n 区分;
// 按"独占一条规则"来取:选择器后面直接跟 { 且规则里带 z-index
const rules = [...css.matchAll(
/\.card-cover\[data-cover-state="ready"\]::(before|after)\s*\{([^}]*)\}/g
)].filter((m) => /z-index/.test(m[2]));
const before = rules.find((m) => m[1] === 'before');
const after = rules.find((m) => m[1] === 'after');
assert.ok(before && after, '缺少封面双层背景规则');
assert.match(before[2], /background-size:\s*cover/);
assert.match(before[2], /blur\(/);
assert.match(after[2], /background-size:\s*contain/);
// 模糊层必须在下、清晰层在上
assert.ok(
Number(before[2].match(/z-index:\s*(\d+)/)[1]) < Number(after[2].match(/z-index:\s*(\d+)/)[1]),
'模糊层盖住了清晰封面'
);
// 角标与占位文字要浮在两层背景之上
assert.match(css, /\.card-cover > \*\s*\{[^}]*z-index:\s*2/);
});
test('可内置阅读的格式在渲染层与 main.js 保持一致', () => {
const mainSrc = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const readable = mainSrc.match(/const READABLE_EXT = new Set\(\[([^\]]*)\]\)/);
assert.ok(readable, '找不到 READABLE_EXT');
const exts = readable[1].match(/\.\w+/g).map((s) => s.slice(1)).sort();
// 渲染层漏掉格式不会报错,只是「阅读」按钮和封面点击静默消失,
// 用户看到的现象就是"内置阅读器打不开 txt"
for (const file of [
path.join(__dirname, '..', 'ui', 'views', 'library.js'),
path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs')
]) {
const src = fs.readFileSync(file, 'utf8');
const re = src.match(/READABLE_RE\s*=\s*\/\\\.\(([^)]*)\)\$\/i/);
assert.ok(re, `${path.basename(file)} 缺少 READABLE_RE`);
assert.deepStrictEqual(re[1].split('|').sort(), exts, `${path.basename(file)} 的可阅读格式与 main.js 不一致`);
}
});
test('状态、笔记与标签标识叠在封面上,不占用封面下方行', () => {
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 三种标识都必须在 .card-cover 内部,否则又会各占一行
const cover = library.match(/<div class="card-cover\$\{[\s\S]*?\n\s*<\/div>/);
assert.ok(cover, '封面结构缺失');
assert.match(cover[0], /class="library-card-tags"/);
// 下载状态在左下角,批注与笔记在右下角
assert.match(cover[0], /class="card-cover-badges start">\$\{badge\}/);
assert.match(cover[0], /class="card-cover-badges end">\$\{annotationBadge\}\$\{noteBadge\}/);
// 标识不能再出现在封面之后、标题周围
const afterCover = library.slice(library.indexOf('class="card-title"'));
assert.doesNotMatch(afterCover.slice(0, 400), /card-cover-badges|library-card-tags/);
const coverRule = css.match(/\.card-cover\s*\{([^}]*)\}/);
assert.match(coverRule[1], /position:\s*relative/);
assert.match(coverRule[1], /overflow:\s*hidden/);
const badgeWrap = css.match(/\.card-cover-badges\s*\{([^}]*)\}/);
assert.ok(badgeWrap, '缺少封面标识容器样式');
assert.match(badgeWrap[1], /position:\s*absolute/);
assert.match(badgeWrap[1], /bottom:\s*6px/);
// 标识浮在封面上,必须让点击穿透到封面的阅读入口
assert.match(badgeWrap[1], /pointer-events:\s*none/);
// 左右两组各占一半,避免笔记批注多时与左下角的下载状态叠在一起
assert.match(badgeWrap[1], /max-width:\s*calc\(50% - 8px\)/);
assert.match(css, /\.card-cover-badges\.start\s*\{\s*left:\s*6px/);
assert.match(css, /\.card-cover-badges\.end\s*\{\s*right:\s*6px/);
const tagsRule = css.match(/\.library-card-tags\s*\{([^}]*)\}/);
assert.match(tagsRule[1], /position:\s*absolute/);
assert.match(tagsRule[1], /top:\s*6px/);
assert.match(tagsRule[1], /pointer-events:\s*none/);
// 左上角留给多选复选框,标签宽度必须扣掉这块
assert.match(tagsRule[1], /max-width:\s*calc\(100% - 42px\)/);
// 允许换行会让三个长标签堆成三行盖住封面
assert.doesNotMatch(tagsRule[1], /flex-wrap:\s*wrap/);
assert.match(library, /class="library-card-tag" title="\$\{escapeHtml\(tag\)\}"/);
// 封面图案深浅不可控,衬底必须不透明
const badgeRule = css.match(/\n\.card-badge\s*\{([^}]*)\}/);
assert.match(badgeRule[1], /background:\s*var\(--bg-card\)/);
assert.doesNotMatch(badgeRule[1], /margin-top/);
});
test('书库长标题保持单行省略并提供完整悬浮提示', () => {
const library = fs.readFileSync(libFile, 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const titleRule = css.match(/\.card-title\s*\{([^}]*)\}/);
assert.ok(titleRule, '缺少书库标题样式');
assert.match(titleRule[1], /white-space:\s*nowrap/);
assert.match(titleRule[1], /overflow:\s*hidden/);
assert.match(titleRule[1], /text-overflow:\s*ellipsis/);
assert.match(library, /class="card-title" title="\$\{escapeHtml\(it\.title\)\}"/);
});
test('AI 面板以线程容器承载多轮对话气泡', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.css'), 'utf8');
// 结构约定要写在 HTML 里,集成测试与 CSS 都按 ai-thread / ai-msg 定位
assert.match(html, /id="aiOutput" class="ai-output ai-thread"/);
assert.match(shell, /box\.className = `ai-msg ai-msg-\$\{role\}`/);
assert.match(shell, /box\.dataset\.messageId = String\(message\.id \|\| ''\)/);
assert.match(shell, /body\.className = 'ai-msg-body'/);
assert.match(shell, /meta\.className = 'ai-msg-meta'/);
assert.match(css, /\.ai-msg-body\s*\{/);
assert.match(css, /\.ai-thread\s*\{[^}]*flex-direction:\s*column/);
// 新回答必须追加而不是覆盖:整块重写等于回到一问一答
assert.match(shell, /function appendAiMessage\(message\)/);
assert.match(shell, /el\.aiOutput\.appendChild\(node\)/);
});
test('AI 流式增量只重渲染正在生成的那一条气泡', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 线程里有几十条消息时,每 80ms 重解析全部 Markdown 会把界面拖死,
// 因此增量渲染只允许写 aiStreamNode 里的 .ai-msg-body
const render = shell.match(/function renderAiOutput\([\s\S]*?\n\}/);
assert.ok(render, '缺少 renderAiOutput');
assert.match(render[0], /aiStreamNode\.querySelector\('\.ai-msg-body'\)/);
assert.match(render[0], /renderMessageBody\(body,/);
assert.doesNotMatch(render[0], /AiMarkdown\.mount\(el\.aiOutput/);
assert.doesNotMatch(render[0], /renderAiThread\(\)/);
// 整篇重建只发生在换会话时,不能出现在节流的增量路径上
const schedule = shell.match(/function scheduleAiOutput\([\s\S]*?\n\}/);
assert.ok(schedule, '缺少 scheduleAiOutput');
assert.doesNotMatch(schedule[0], /renderAiThread\(\)/);
assert.doesNotMatch(schedule[0], /AiMarkdown\.mount\(el\.aiOutput/);
// 80ms 节流与「贴底才自动滚动」都要保留
assert.match(shell, /aiRenderTimer = window\.setTimeout\([\s\S]{0,120}\}, 80\)/);
assert.match(shell, /el\.aiOutput\.scrollHeight - el\.aiOutput\.scrollTop - el\.aiOutput\.clientHeight < 48/);
});
test('AI 增量按 messageId 路由到对应气泡', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
const delta = shell.match(/unsubDelta = api\.ai\.onDelta\([\s\S]*?\n \}\);/);
assert.ok(delta, '缺少 onDelta 订阅');
// 只按 runId 过滤会让同一次运行里的旧气泡也收到增量,串成一团
assert.match(delta[0], /const messageId = d\.messageId \? String\(d\.messageId\) : ''/);
assert.match(delta[0], /messageId !== aiRun\.messageId\) return/);
assert.match(shell, /function adoptStreamingMessageId\(messageId\)/);
assert.match(shell, /aiRun\.messageId = String\(messageId\)/);
});
test('AI 会话管理提供新建、重命名、置顶、清空与删除', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSessionSelect"[^>]+title="切换当前书籍的对话会话"/);
assert.match(html, /id="aiSessionNewBtn"[^>]*>新建</);
assert.match(html, /id="aiSessionRenameBtn"[^>]*>重命名</);
assert.match(html, /id="aiSessionPinBtn"[^>]*>置顶</);
assert.match(html, /id="aiSessionClearBtn"[^>]*>清空</);
assert.match(html, /id="aiSessionDeleteBtn"[^>]*>删除</);
// 会话下拉按 pinned 优先、updatedAt 倒序,标题为空时显示「新会话」
assert.match(shell, /a\.pinned \? -1 : 1/);
assert.match(shell, /Number\(b\.updatedAt\) \|\| 0\) - \(Number\(a\.updatedAt\) \|\| 0\)/);
assert.match(shell, /return title \|\| '新会话'/);
// 一开书就建空会话会很快占满 100 个上限,必须延迟到首次提问
assert.match(shell, /async function ensureAiSession\(\)/);
assert.match(shell, /if \(aiSessionId\) return aiSessionId/);
const activateBlock = shell.match(/async function activate\(id\)[\s\S]*?\n\}/);
assert.ok(activateBlock);
assert.match(activateBlock[0], /refreshAiSessions\(\)/);
assert.doesNotMatch(activateBlock[0], /sessions\.create\(/);
// 生成中禁止切会话、删会话与发新问题
assert.match(shell, /el\.aiSessionSelect\.disabled = busy \|\| !hasEntry/);
assert.match(shell, /el\.aiSessionDeleteBtn\.disabled = busy \|\| !hasSession/);
assert.match(shell, /function aiBusy\(busy\)[\s\S]{0,400}syncAiSessionControls\(\)/);
});
test('删除与清空 AI 会话都要走应用内二次确认', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSessionDeleteModal"[\s\S]*id="aiSessionDeleteConfirmBtn"[^>]*>删除会话</);
assert.match(html, /id="aiSessionClearModal"[\s\S]*id="aiSessionClearConfirmBtn"[^>]*>清空消息</);
assert.match(html, /id="aiSessionRenameModal"[\s\S]*id="aiSessionTitleInput"/);
// 对话记录删掉找不回来,必须先确认再调 remove/clear
const remove = shell.match(/async function deleteAiSession\(\)[\s\S]*?\n\}/);
assert.ok(remove, '缺少 deleteAiSession');
const removeConfirmAt = remove[0].indexOf('await confirmAiSessionDelete(');
const removeCallAt = remove[0].indexOf('sessions.remove(');
assert.ok(removeConfirmAt >= 0, '删除会话缺少二次确认,会一键抹掉全部对话记录');
assert.ok(removeCallAt >= 0, '删除会话未调用 sessions.remove');
assert.ok(removeConfirmAt < removeCallAt, '删除会话必须先弹二次确认再调 sessions.remove');
const clear = shell.match(/async function clearAiSession\(\)[\s\S]*?\n\}/);
assert.ok(clear, '缺少 clearAiSession');
const clearConfirmAt = clear[0].indexOf('await confirmAiSessionClear(');
const clearCallAt = clear[0].indexOf('sessions.clear(');
assert.ok(clearConfirmAt >= 0, '清空会话缺少二次确认,消息删掉找不回来');
assert.ok(clearCallAt >= 0, '清空会话未调用 sessions.clear');
assert.ok(clearConfirmAt < clearCallAt, '清空会话必须先弹二次确认再调 sessions.clear');
assert.match(shell, /function confirmAiSessionDelete\(title\)[\s\S]{0,400}aiSessionDeleteModal\.classList\.remove\('hidden'\)/);
});
test('AI 会话调用 api.ai.sessions 契约并带上 sessionId 发送', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /api\.ai && api\.ai\.sessions \? api\.ai\.sessions : null/);
assert.match(shell, /sessions\.list\(\{ entryId \}\)/);
assert.match(shell, /sessions\.create\(\{ entryId, title: '', documentKey: tab\.documentKey \|\| null \}\)/);
assert.match(shell, /sessions\.rename\(aiSessionId, title\)/);
assert.match(shell, /sessions\.setPinned\(aiSessionId, next\)/);
assert.match(shell, /sessions\.remove\(removed\)/);
assert.match(shell, /sessions\.clear\(aiSessionId\)/);
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_THREAD_LIMIT \}\)/);
// sessionId 不下发就退化成不落盘的一次性提问,线程无法续接
assert.match(shell, /api\.ai\.run\(\{\s*\n\s*runId,\s*\n\s*sessionId,/);
assert.match(shell, /res\.data\.assistantMessageId|ids\.assistantMessageId/);
});
test('AI 每条回答各自提供保存此回答与复制', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /save\.className = 'tb-btn sm ai-msg-save'/);
assert.match(shell, /save\.textContent = '保存此回答'/);
assert.match(shell, /copy\.className = 'tb-btn ghost sm ai-msg-copy'/);
assert.match(shell, /saveAiNote\(aiResultOf\(box\.dataset\.messageId\)\)/);
assert.match(shell, /copyAiMessage\(box\.dataset\.messageId\)/);
// locator 取该条对应 user 消息的 contextRefquote 取那条问题文本
assert.match(shell, /if \(aiThread\[i\]\.role === 'user'\) \{ ask = aiThread\[i\]; break; \}/);
assert.match(shell, /locator: \(ref && ref\.locator\) \|\| null/);
assert.match(shell, /quote: ask \? String\(ask\.text \|\| ''\) : ''/);
assert.match(shell, /async function saveAiNote\(target\)/);
});
test('AI 会话可按最近轮数或全部保留消息保存为一条笔记', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader.html'), 'utf8');
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(html, /id="aiSaveBtn"[^>]+title="把当前会话保存为一条笔记"[^>]*>保存会话…</);
assert.match(html, /id="aiSessionSaveModal"[\s\S]*id="aiSessionSaveRecent"/);
assert.match(html, /id="aiSessionSaveRounds"[^>]+type="number"[^>]+min="1"[^>]+max="100"/);
assert.match(html, /id="aiSessionSaveAll"[\s\S]*当前保留会话全部/);
assert.match(html, /id="aiSessionSaveRoundCount"[\s\S]*id="aiSessionSaveCharCount"/);
// 保存时必须另取后端当前保留的全部 200 条,不能复用界面当前 60 条
assert.match(shell, /const AI_THREAD_LIMIT = 60/);
assert.match(shell, /const AI_SESSION_SAVE_MESSAGE_LIMIT = 200/);
assert.match(shell, /sessions\.messages\(wanted, \{ limit: AI_SESSION_SAVE_MESSAGE_LIMIT \}\)/);
assert.match(shell, /function aiConversationRounds\(messages\)/);
assert.match(shell, /message\.role === 'user'[\s\S]{0,220}message\.role === 'assistant' && user/);
assert.match(shell, /return all\.slice\(-count\)/);
// 范围和 N 记住上次选择,确认前展示真实轮数和最终正文字符数
assert.match(shell, /const AI_SESSION_SAVE_SETTING = 'reader\.aiSessionSave'/);
assert.match(shell, /api\.settings\.get\(AI_SESSION_SAVE_SETTING, aiSessionSavePreference\)/);
assert.match(shell, /api\.settings\.set\(AI_SESSION_SAVE_SETTING, preference\)/);
assert.match(shell, /aiSessionSaveRoundCount\.textContent = `\$\{selected\.length\.toLocaleString\(\)\} 轮`/);
assert.match(shell, /aiSessionSaveCharCount\.textContent = `\$\{text\.length\.toLocaleString\(\)\} 字`/);
const saveStart = shell.indexOf('async function saveAiSessionNote()');
const saveEnd = shell.indexOf('\nasync function renameAiSession()', saveStart);
assert.ok(saveStart >= 0 && saveEnd > saveStart, '缺少保存会话实现');
const saveBlock = shell.slice(saveStart, saveEnd);
assert.strictEqual((saveBlock.match(/api\.reader\.addNote\(/g) || []).length, 1, '一次会话保存只能调用一次 addNote');
assert.match(saveBlock, /title: state\.title/);
assert.match(saveBlock, /\n\s+text,/);
assert.match(saveBlock, /quote: ''/);
assert.match(saveBlock, /locator: null/);
assert.match(saveBlock, /已将 \$\{selected\.length\.toLocaleString\(\)\} 轮会话(\$\{text\.length\.toLocaleString\(\)\} 字)保存为一条笔记/);
});
test('AI 会话笔记正文标明问答结构与未保留的较早消息', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /`## 第 \$\{index \+ 1\} 轮\\n\\n### 问题\\n\\n\$\{question\}\\n\\n### 回答\\n\\n\$\{answer\}`/);
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不在当前保留会话中,因此未保存/);
assert.match(shell, /更早的 \$\{dropped\.toLocaleString\(\)\} 条消息已被会话存储上限淘汰,不会出现在笔记中/);
assert.match(shell, /会话正文将完整写入一条笔记,不会拆分,也不会在此处截断/);
});
test('AI 气泡标注上下文范围、停止、裁剪与省略轮次', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
assert.match(shell, /const parts = \[scopeName\(ref\.scope\)\]/);
assert.match(shell, /parts\.push\(`\$\{chars\.toLocaleString\(\)\} 字`\)/);
assert.match(shell, /`上下文:\$\{parts\.join\(' · '\)\}`/);
assert.match(shell, /if \(message\.truncated\) flags\.push\('内容已裁剪'\)/);
assert.match(shell, /if \(message\.cancelled\) flags\.push\('已停止生成'\)/);
assert.match(shell, /box\.classList\.add\('ai-msg-error'\)/);
assert.match(shell, /较早的 \$\{dropped\.toLocaleString\(\)\} 条消息已不再保留/);
});
test('AI 线程里的模型输出全部经 AiMarkdown 渲染,不直接写 innerHTML', () => {
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
// 渲染层 CSP 挡不住 DOM 注入,模型文本必须过 AiMarkdown 内的 DOMPurify
assert.match(shell, /function renderMessageBody\(body, source\)[\s\S]{0,300}window\.AiMarkdown\.mount\(body, source\)/);
assert.match(shell, /else renderMessageBody\(body, message\.text\)/);
assert.doesNotMatch(shell, /\.innerHTML\s*=/);
// 用户输入按纯文本落地,同样不经 HTML 解析
assert.match(shell, /if \(role === 'user'\) body\.textContent = String\(message\.text \|\| ''\)/);
// 链接拦截仍挂在整个线程容器上,新增气泡里的链接不会漏掉
assert.match(shell, /el\.aiOutput\.addEventListener\('click', activateAiLink\)/);
assert.match(shell, /el\.aiOutput\.addEventListener\('auxclick', activateAiLink\)/);
assert.match(shell, /if \(!link \|\| !el\.aiOutput\.contains\(link\)\) return/);
});
test('可阅读图书封面支持鼠标与键盘打开内置阅读器', () => {
const library = fs.readFileSync(libFile, 'utf8');
assert.match(library, /data-act="read" role="button" tabindex="0"/);
assert.match(library, /cover\.onclick[\s\S]*onAction\(id, 'read'\)/);
assert.match(library, /event\.key !== 'Enter' && event\.key !== ' '/);
assert.match(library, /window\.api\.reader\.open\(id, idx >= 0 \? idx : undefined\)/);
});