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(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); } test('escapeHtml 覆盖全部危险字符', () => { const { escapeHtml } = loadUtil(); assert.strictEqual(escapeHtml(`&'`), '<a href="x">&''); 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('"'), '未做 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">我的笔记不关联书籍<\/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, /
{ 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, /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 消息的 contextRef,quote 取那条问题文本 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\)/); });