构建与发布 / 单测与集成测试 (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
1015 lines
59 KiB
JavaScript
1015 lines
59 KiB
JavaScript
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">&'`), '<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">我的笔记</);
|
||
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 消息的 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\)/);
|
||
});
|