const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const h = require('./helpers'); h.installFetchStub(); const storePath = require.resolve('../reader/store.js'); const cfgPath = require.resolve('../reader/ai-config.js'); const dirs = []; function tmp() { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-reader-')); dirs.push(d); return d; } test.after(() => { for (const d of dirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ } } }); function freshStore() { delete require.cache[storePath]; const s = require(storePath); s.init(tmp()); return s; } function storeAt(dir) { delete require.cache[storePath]; const s = require(storePath); s.init(dir); return s; } function fakeStorage(available = true) { return { isEncryptionAvailable: () => available, encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')), decryptString: (b) => { const s = b.toString(); if (!s.startsWith('ENC:')) throw new Error('bad'); return Buffer.from(s.slice(4), 'base64').toString('utf8'); } }; } // --- reader/store --- test('阅读进度可存取,百分比被夹在 0..1', () => { const s = freshStore(); assert.strictEqual(s.getLastReadAt('e1'), 0); s.setProgress('e1', { kind: 'pdf', page: 5 }, 2.5); const st = s.getState('e1'); assert.strictEqual(st.progress.locator.page, 5); assert.strictEqual(st.progress.percent, 1); assert.strictEqual(s.getLastReadAt('e1'), st.progress.at); s.setProgress('e1', { kind: 'pdf', page: 1 }, -3); assert.strictEqual(s.getState('e1').progress.percent, 0); }); test('书签与笔记的增删互不干扰', () => { const s = freshStore(); const b = s.addBookmark('e1', { locator: { kind: 'epub', chapter: 2, offset: 10 }, label: '第 3 章' }); const n = s.addNote('e1', { locator: { kind: 'epub', chapter: 2 }, text: '这是笔记', kind: 'ai' }); let st = s.getState('e1'); assert.strictEqual(st.bookmarks.length, 1); assert.strictEqual(st.notes.length, 1); assert.strictEqual(st.notes[0].kind, 'ai'); s.removeBookmark('e1', b.id); st = s.getState('e1'); assert.strictEqual(st.bookmarks.length, 0); assert.strictEqual(st.notes.length, 1, '删书签不该动笔记'); assert.strictEqual(s.removeNote('e1', n.id), true); }); test('缺少定位信息的书签被拒绝', () => { const s = freshStore(); assert.throws(() => s.addBookmark('e1', { label: 'x' }), /定位/); assert.throws(() => s.addNote('e1', { text: ' ' }), /内容为空/); }); test('不同条目的阅读数据互相隔离', () => { const s = freshStore(); s.addBookmark('a', { locator: { kind: 'pdf', page: 1 } }); s.addBookmark('b', { locator: { kind: 'pdf', page: 2 } }); assert.strictEqual(s.getState('a').bookmarks.length, 1); assert.strictEqual(s.getState('b').bookmarks[0].locator.page, 2); s.forget('a'); assert.strictEqual(s.getState('a').bookmarks.length, 0); assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目'); }); test('孤立阅读资料按笔记数报告并可批量回收', () => { const s = freshStore(); s.setProgress('kept', { kind: 'pdf', page: 2 }, 0.5); s.addNote('kept', { text: '保留的笔记' }); s.addNote('gone_a', { text: '甲一' }); s.addNote('gone_a', { text: '甲二' }); s.addBookmark('gone_b', { locator: { kind: 'pdf', page: 3 } }); s.addStandaloneNote({ text: '与书籍无关的独立笔记' }); const orphans = s.orphanReport(['kept']); assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']); assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').notes, 2); assert.strictEqual(orphans.find((o) => o.entryId === 'gone_b').bookmarks, 1); // 独立笔记本没有对应书籍,永远不算孤立 assert.ok(!orphans.some((o) => o.entryId === s.STANDALONE_ENTRY_ID)); assert.strictEqual(s.forgetMany(orphans.map((o) => o.entryId)), 2); assert.strictEqual(s.getState('kept').notes.length, 1); assert.strictEqual(s.getState('kept').progress.percent, 0.5); assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记')); assert.deepStrictEqual(s.orphanReport(['kept']), []); assert.strictEqual(s.forgetMany([]), 0); // 即使显式点名,也不能删掉独立笔记本:它没有对应书籍,永远不是可回收对象 assert.strictEqual(s.forgetMany([s.STANDALONE_ENTRY_ID]), 0); assert.ok(s.listNotes({}).some((n) => n.text === '与书籍无关的独立笔记')); }); test('getState 返回副本,外部改动不污染存储', () => { const s = freshStore(); s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } }); const st = s.getState('e1'); st.bookmarks.push({ id: 'fake' }); assert.strictEqual(s.getState('e1').bookmarks.length, 1); }); test('损坏的 reader.json 不会导致崩溃', () => { const d = tmp(); fs.writeFileSync(path.join(d, 'reader.json'), '{ 这不是 json'); delete require.cache[storePath]; const s = require(storePath); s.init(d); assert.deepStrictEqual(s.getState('x').bookmarks, []); s.setProgress('x', { kind: 'pdf', page: 1 }, 0.1); assert.ok(s.getState('x').progress); assert.strictEqual( fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')), true, '损坏原文件应被隔离保留' ); assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(d, 'reader.json'), 'utf8'))); }); test('同一条目的进度、书签和笔记按文档标识隔离', () => { const s = freshStore(); s.setProgress('e1', 'doc-a', { kind: 'pdf', page: 2 }, 0.2); s.setProgress('e1', 'doc-b', { kind: 'epub', chapter: 3, offset: 20 }, 0.7); s.addBookmark('e1', { documentKey: 'doc-a', locator: { kind: 'pdf', page: 2 }, label: 'PDF' }); s.addBookmark('e1', { documentKey: 'doc-b', locator: { kind: 'epub', chapter: 3, offset: 20 }, label: 'EPUB' }); s.addNote('e1', { documentKey: 'doc-a', text: 'PDF 笔记' }); s.addNote('e1', { documentKey: 'doc-b', text: 'EPUB 笔记' }); assert.strictEqual(s.getState('e1', 'doc-a').progress.locator.page, 2); assert.strictEqual(s.getState('e1', 'doc-b').progress.locator.chapter, 3); assert.deepStrictEqual(s.getState('e1', 'doc-a').bookmarks.map((item) => item.label), ['PDF']); assert.deepStrictEqual(s.getState('e1', 'doc-b').notes.map((item) => item.text), ['EPUB 笔记']); }); test('主文件损坏时隔离原件并从有效备份恢复阅读资料', () => { const d = tmp(); const file = path.join(d, 'reader.json'); fs.writeFileSync(file, '{ broken'); fs.writeFileSync(`${file}.bak`, JSON.stringify({ version: 2, collections: [], entries: { restored: { progress: null, bookmarks: [], notes: [{ id: 'note-1', text: '已恢复', kind: 'user', at: 10 }] } } })); const s = storeAt(d); assert.strictEqual(s.getState('restored').notes[0].text, '已恢复'); assert.strictEqual(fs.existsSync(file), true); assert.strictEqual( fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')), true ); }); test('迁移会丢弃合法 JSON 中结构损坏的进度和书签', () => { const d = tmp(); fs.writeFileSync(path.join(d, 'reader.json'), JSON.stringify({ version: 2, collections: [], entries: { broken: { progress: { locator: null, percent: 2 }, progressByDocument: { bad: { locator: null }, good: { locator: { kind: 'pdf', page: 2 }, percent: 2 } }, bookmarks: [ null, { id: 'bad', locator: null }, { id: 'good', locator: { kind: 'pdf', page: 3 }, label: '有效书签' } ], notes: [] } } })); const s = storeAt(d); assert.deepStrictEqual(s.getState('broken').bookmarks.map((item) => item.label), ['有效书签']); assert.doesNotThrow(() => s.bindDocument('broken', 'doc-current')); const state = s.getState('broken', 'good'); assert.strictEqual(state.progress.percent, 1); }); test('首次绑定文档把旧版进度和书签安全迁移到该文档', () => { const s = freshStore(); s.setProgress('legacy', { kind: 'pdf', page: 6 }, 0.5); s.addBookmark('legacy', { locator: { kind: 'pdf', page: 6 }, label: '旧书签' }); assert.strictEqual(s.bindDocument('legacy', 'doc-key'), true); const state = s.getState('legacy', 'doc-key'); assert.strictEqual(state.progress.locator.page, 6); assert.strictEqual(state.bookmarks[0].documentKey, 'doc-key'); }); test('结构化笔记支持纯引用、来源、标签和上下文字段', () => { const s = freshStore(); const note = s.addNote('e1', { title: '重点', quote: '只保存引用也可以', context: '第二章', source: 'selection', documentKey: 'doc-1', fileIndex: 2, tags: ['方法', '方法', '研究'], pinned: true, locator: { kind: 'pdf', page: 3 } }); assert.strictEqual(note.text, ''); assert.strictEqual(note.source, 'selection'); assert.strictEqual(note.kind, 'user', '保留旧渲染器使用的兼容别名'); assert.deepStrictEqual(note.tags, ['方法', '研究']); assert.strictEqual(note.createdAt, note.updatedAt); note.tags.push('外部修改'); note.locator.page = 99; const stored = s.getState('e1').notes[0]; assert.deepStrictEqual(stored.tags, ['方法', '研究']); assert.strictEqual(stored.locator.page, 3); }); test('富文本笔记保存格式、纯文本索引和内嵌图片', () => { const s = freshStore(); const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; const richContent = { version: 1, blocks: [ { type: 'text', style: 'heading1', runs: [{ text: '富文本标题', bold: true }] }, { type: 'text', style: 'paragraph', runs: [{ text: '正文' }, { text: '强调', italic: true, underline: true }] }, { type: 'image', dataUrl: image, alt: '示例图片' } ] }; const note = s.addNote('e1', { richContent, source: 'manual' }); assert.strictEqual(note.noteType, 'reading'); assert.strictEqual(note.text, '富文本标题\n正文强调'); assert.deepStrictEqual(note.richContent, { version: 2, ops: [ { insert: '富文本标题', attributes: { bold: true } }, { insert: '\n', attributes: { header: 1 } }, { insert: '正文' }, { insert: '强调', attributes: { italic: true, underline: true } }, { insert: '\n' }, { insert: { image } } ] }); assert.strictEqual(s.listNotes({ query: '正文强调' })[0].id, note.id); const stored = s.getState('e1').notes[0]; assert.strictEqual(stored.richContent.version, 2); richContent.blocks[0].runs[0].text = '外部污染'; assert.strictEqual(s.getState('e1').notes[0].text, '富文本标题\n正文强调'); const imageOnly = s.addNote('e1', { richContent: { version: 1, blocks: [{ type: 'image', dataUrl: image, alt: '' }] } }); assert.strictEqual(imageOnly.text, ''); assert.strictEqual(imageOnly.richContent.ops[0].insert.image, image); }); test('富文本笔记拒绝主动内容、远程图片和超限结构', () => { const s = freshStore(); assert.throws( () => s.addNote('e1', { richContent: { version: 1, blocks: [{ type: 'image', dataUrl: 'https://example.com/x.png', alt: '' }] } }), /图片格式无效/ ); assert.throws( () => s.addNote('e1', { richContent: { version: 1, blocks: [{ type: 'html', html: '' }] } }), /段落无效/ ); assert.throws( () => s.addNote('e1', { richContent: { version: 1, blocks: Array.from({ length: 501 }, () => ({ type: 'text', style: 'paragraph', runs: [{ text: 'x' }] })) } }), /内容过多/ ); }); test('Quill Delta 仅保留受支持格式并拒绝主动嵌入', () => { const s = freshStore(); const note = s.addNote('e1', { richContent: { version: 2, ops: [ { insert: '一级标题' }, { insert: '\n', attributes: { header: 1 } }, { insert: '正文', attributes: { bold: true, italic: true } }, { insert: '\n', attributes: { list: 'bullet' } }, { insert: '代码' }, { insert: '\n', attributes: { 'code-block': 'plain' } } ] } }); assert.strictEqual(note.text, '一级标题\n正文\n代码'); assert.strictEqual(note.richContent.version, 2); assert.deepStrictEqual( note.richContent.ops.at(-1), { insert: '\n', attributes: { 'code-block': 'plain' } } ); assert.throws(() => s.addNote('e1', { richContent: { version: 2, ops: [{ insert: '外链', attributes: { link: 'https://example.com' } }] } }), /不支持的格式/); assert.throws(() => s.addNote('e1', { richContent: { version: 2, ops: [{ insert: { video: 'https://example.com/video' } }] } }), /嵌入内容无效/); assert.throws(() => s.addNote('e1', { richContent: { version: 2, ops: [{ retain: 1, attributes: { bold: true } }] } }), /操作无效/); }); test('画布笔记保存分页画布、PDF 底版并支持类型筛选和文本搜索', () => { const s = freshStore(); const assetId = `pdf_${'a'.repeat(64)}`; const canvasContent = { version: 2, flow: { version: 1, ops: [ { insert: '全局文本关键词', attributes: { bold: true } }, { insert: '\n' }, { insert: { canvasPageBreak: 'pg_two' } } ] }, pages: [ { id: 'pg_one', width: 794, height: 1123, background: { type: 'template', template: 'grid' }, objects: [{ type: 'IText', canvasKind: 'text', text: '画布关键词', left: 10, top: 20, fill: '#222222', fontSize: 18 }] }, { id: 'pg_two', width: 612, height: 792, background: { type: 'pdf', assetId, page: 2 }, objects: [] } ] }; const note = s.addStandaloneNote({ noteType: 'canvas', canvasContent }); assert.strictEqual(note.noteType, 'canvas'); assert.strictEqual(note.text, '全局文本关键词\n画布关键词'); assert.deepStrictEqual(note.canvasContent, canvasContent); assert.deepStrictEqual(s.noteAssetIds(), [assetId]); assert.strictEqual(s.listNotes({ query: '画布关键词' })[0].id, note.id); assert.strictEqual(s.listNotes({ query: '全局文本关键词' })[0].id, note.id); assert.deepStrictEqual(s.listNotes({ noteType: 'canvas' }).map((item) => item.id), [note.id]); assert.deepStrictEqual(s.listNotes({ noteType: 'reading' }), []); assert.strictEqual(s.removeNote(s.STANDALONE_ENTRY_ID, note.id), true); assert.deepStrictEqual(s.noteAssetIds(), []); }); test('读书笔记和画布笔记创建后保持独立且类型不可更改', () => { const s = freshStore(); const canvasContent = { version: 1, pages: [{ id: 'pg_type', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] }] }; assert.throws(() => s.addNote('e1', { noteType: 'unknown', text: '正文' }), /笔记类型无效/); assert.throws(() => s.addNote('e1', { noteType: 'reading', richContent: { version: 2, ops: [{ insert: '正文\n' }] }, canvasContent }), /读书笔记不能包含画布内容/); assert.throws(() => s.addNote('e1', { noteType: 'canvas', richContent: { version: 2, ops: [{ insert: '正文\n' }] }, canvasContent }), /画布笔记不能包含富文本内容/); const reading = s.addNote('e1', { noteType: 'reading', text: '正文' }); assert.throws(() => s.updateNote('e1', reading.id, { noteType: 'canvas' }), /不能更改/); const canvas = s.addNote('e1', { noteType: 'canvas', canvasContent }); assert.throws(() => s.updateNote('e1', canvas.id, { richContent: { version: 2, ops: [{ insert: '正文\n' }] } }), /不能包含富文本内容/); }); test('v4 混合笔记迁移为画布笔记并保留两类旧内容', () => { const root = tmp(); fs.writeFileSync(path.join(root, 'reader.json'), JSON.stringify({ version: 4, collections: [], entries: { e1: { notes: [{ id: 'nt_legacy_mixed', title: '旧混合笔记', richContent: { version: 2, ops: [{ insert: '旧正文\n' }] }, canvasContent: { version: 1, pages: [{ id: 'pg_legacy', width: 794, height: 1123, background: { type: 'template', template: 'grid' }, objects: [] }] }, source: 'manual', tags: [], createdAt: 1, updatedAt: 1 }, { id: 'nt_legacy_blank_canvas', richContent: { version: 2, ops: [{ insert: '仅正文\n' }] }, canvasContent: { version: 1, pages: [{ id: 'pg_legacy_blank', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] }] }, source: 'manual', tags: [], createdAt: 2, updatedAt: 2 }] } } })); const s = storeAt(root); const notes = s.getState('e1').notes; const note = notes.find((item) => item.id === 'nt_legacy_mixed'); assert.strictEqual(note.noteType, 'canvas'); assert.strictEqual(note.richContent.ops[0].insert, '旧正文\n'); assert.strictEqual(note.canvasContent.version, 2); assert.strictEqual(note.canvasContent.pages[0].background.template, 'grid'); const updated = s.updateNote('e1', note.id, { noteType: 'canvas', canvasContent: note.canvasContent }); assert.strictEqual(updated.richContent.ops[0].insert, '旧正文\n'); const blankCanvas = notes.find((item) => item.id === 'nt_legacy_blank_canvas'); assert.strictEqual(blankCanvas.noteType, 'reading'); assert.strictEqual(blankCanvas.richContent.ops[0].insert, '仅正文\n'); assert.strictEqual(blankCanvas.canvasContent.pages[0].background.template, 'blank'); }); test('画布笔记拒绝未知对象、主动属性、远程图片和非法 PDF 引用', () => { const s = freshStore(); const page = (object, background = { type: 'template', template: 'blank' }) => ({ version: 1, pages: [{ id: 'pg_safe', width: 794, height: 1123, background, objects: object ? [object] : [] }] }); assert.throws(() => s.addNote('e1', { canvasContent: page({ type: 'Circle', canvasKind: 'circle' }) }), /对象类型无效/); assert.throws(() => s.addNote('e1', { canvasContent: page({ type: 'Path', canvasKind: 'pen', path: [['M', 0, 0], ['L', 1, 1]], clipPath: {} }) }), /不支持的属性/); assert.throws(() => s.addNote('e1', { canvasContent: page({ type: 'Path', canvasKind: 'pen', path: [['M', 0, 0]], arbitraryPayload: { type: 'Image' } }) }), /不支持的属性/); assert.throws(() => s.addNote('e1', { canvasContent: page({ type: 'Path', canvasKind: 'pen', path: [['M', 0, 0]], scaleX: 1000 }) }), /缩放无效/); assert.throws(() => s.addNote('e1', { canvasContent: page({ type: 'Image', canvasKind: 'image', src: 'https://example.com/image.png' }) }), /图片格式无效/); assert.throws(() => s.addNote('e1', { canvasContent: page(null, { type: 'pdf', assetId: '../outside', page: 1 }) }), /PDF 底版资源无效/); const flowPage = { version: 2, pages: [{ id: 'pg_flow', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] }, { id: 'pg_flow_two', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [], flowAuto: true }] }; assert.throws(() => s.addNote('e1', { noteType: 'canvas', canvasContent: { ...flowPage, flow: { version: 1, ops: [{ insert: { canvasPageBreak: '../outside' } }] } } }), /分页符无效/); assert.throws(() => s.addNote('e1', { noteType: 'canvas', canvasContent: { ...flowPage, flow: { version: 1, ops: [{ insert: { image: 'https://example.com/a.png' } }] } } }), /嵌入内容无效/); const flowNote = s.addNote('e1', { noteType: 'canvas', canvasContent: { ...flowPage, flow: { version: 1, ops: [ { insert: '跨页正文\n', attributes: { header: 1 } }, { insert: { canvasPageBreak: 'pg_flow_two' } }, { insert: '第二页正文\n' } ] } } }); assert.strictEqual(flowNote.text, '跨页正文\n第二页正文'); assert.strictEqual(flowNote.canvasContent.pages[1].flowAuto, true); }); test('笔记可更新且必需保留正文或引用', () => { const s = freshStore(); const note = s.addNote('e1', { text: '原文', source: 'manual' }); const updated = s.updateNote('e1', note.id, { text: '', quote: '新引用', source: 'ai', aiTask: '总结', tags: ['AI'], pinned: true }); assert.strictEqual(updated.quote, '新引用'); assert.strictEqual(updated.source, 'ai'); assert.strictEqual(updated.kind, 'ai'); assert.strictEqual(updated.aiTask, '总结'); assert.strictEqual(updated.at, updated.updatedAt); assert.throws( () => s.updateNote('e1', note.id, { quote: '', text: '' }), /内容为空/ ); assert.strictEqual(s.getState('e1').notes[0].quote, '新引用', '失败更新必须回滚'); assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null); }); test('普通读书笔记新增与更新均完整保留超长正文', () => { const d = tmp(); let s = storeAt(d); const addedText = '新增正文'.repeat(6000); const updatedText = '更新正文'.repeat(6500); const note = s.addNote('e1', { text: addedText, source: 'manual' }); assert.strictEqual(note.text, addedText); assert.strictEqual(s.getState('e1').notes[0].text, addedText); s = storeAt(d); assert.strictEqual(s.getState('e1').notes[0].text, addedText); const updated = s.updateNote('e1', note.id, { text: updatedText }); assert.strictEqual(updated.text, updatedText); s = storeAt(d); assert.strictEqual(s.getState('e1').notes[0].text, updatedText); }); test('富文本派生的普通笔记正文不截断', () => { const s = freshStore(); const addedText = '富文本新增'.repeat(5000); const updatedText = '富文本更新'.repeat(5500); const note = s.addNote('e1', { richContent: { version: 2, ops: [{ insert: `${addedText}\n` }] } }); assert.strictEqual(note.text, addedText); assert.strictEqual(note.richContent.ops[0].insert, `${addedText}\n`); const updated = s.updateNote('e1', note.id, { richContent: { version: 2, ops: [{ insert: `${updatedText}\n` }] } }); assert.strictEqual(updated.text, updatedText); assert.strictEqual(updated.richContent.ops[0].insert, `${updatedText}\n`); }); test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => { const s = freshStore(); const collection = s.addCollection({ name: 'Research' }); assert.throws(() => s.addCollection({ name: ' research ' }), /已存在/); const note = s.addNote('e1', { text: '归档笔记', collectionId: collection.id }); assert.strictEqual(s.listCollections()[0].name, 'Research'); assert.strictEqual(s.updateCollection(collection.id, { name: 'Inbox' }).name, 'Inbox'); assert.strictEqual(s.removeCollection(collection.id), true); assert.strictEqual(s.listCollections().length, 0); assert.strictEqual(s.getState('e1').notes[0].id, note.id); assert.strictEqual(s.getState('e1').notes[0].collectionId, null); assert.strictEqual(s.listNotes({ collectionId: null }).length, 1); }); test('聚合笔记按置顶与更新时间排序并支持全部筛选', () => { const s = freshStore(); const work = s.addCollection('Work'); s.setBookSnapshot('book-a', { title: 'Alpha Handbook', authors: ['A. One'] }); s.setBookSnapshot('book-b', { title: 'Beta Notes', authors: ['B. Two'] }); const first = s.addNote('book-a', { title: 'Ordinary', text: 'needle in text', source: 'manual', tags: ['Blue'], collectionId: work.id }); const pinned = s.addNote('book-b', { quote: 'selected passage', source: 'selection', tags: ['Green'], pinned: true }); s.updateNote('book-a', first.id, { context: 'changed' }); const all = s.listNotes(); assert.deepStrictEqual(all.map((note) => note.id), [pinned.id, first.id]); assert.strictEqual(all[0].entryId, 'book-b'); assert.strictEqual(all[0].bookSnapshot.title, 'Beta Notes'); assert.deepStrictEqual(s.listNotes({ entryId: 'book-a' }).map((n) => n.id), [first.id]); assert.deepStrictEqual(s.listNotes({ collectionId: work.id }).map((n) => n.id), [first.id]); assert.deepStrictEqual(s.listNotes({ source: 'selection' }).map((n) => n.id), [pinned.id]); assert.deepStrictEqual(s.listNotes({ tag: 'blue' }).map((n) => n.id), [first.id]); assert.deepStrictEqual(s.listNotes({ query: 'alpha hand' }).map((n) => n.id), [first.id]); assert.deepStrictEqual(s.listNotes({ query: 'NEEDLE' }).map((n) => n.id), [first.id]); assert.deepStrictEqual(s.getNoteCounts(), { 'book-a': 1, 'book-b': 1 }); all[0].bookSnapshot.title = '污染'; all[0].tags.push('污染'); assert.strictEqual(s.listNotes()[0].bookSnapshot.title, 'Beta Notes'); assert.deepStrictEqual(s.listNotes()[0].tags, ['Green']); }); test('无关联笔记独立持久化且不计入书库卡片笔记数', () => { const s = freshStore(); const note = s.addStandaloneNote({ title: '独立想法', text: '不关联任何书籍', source: 'manual', tags: ['随想'] }); const listed = s.listNotes().find((item) => item.id === note.id); assert.strictEqual(listed.entryId, s.STANDALONE_ENTRY_ID); assert.strictEqual(listed.associated, false); assert.strictEqual(listed.bookSnapshot, null); assert.deepStrictEqual(s.getNoteCounts(), {}); assert.strictEqual( s.updateNote(listed.entryId, note.id, { text: '已编辑' }).text, '已编辑' ); assert.strictEqual(s.removeNote(listed.entryId, note.id), true); }); test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => { const d = tmp(); const file = path.join(d, 'reader.json'); const legacyText = '旧版超长正文'.repeat(4000); const legacy = { entries: { e1: { progress: { locator: { page: 8 }, percent: 0.4, at: 10 }, bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }], notes: [{ id: 'nt_old', text: legacyText, quote: '旧引用', kind: 'ai', at: 123, locator: { page: 8 } }] } } }; fs.writeFileSync(file, JSON.stringify(legacy), 'utf8'); let s = storeAt(d); const state = s.getState('e1'); assert.deepStrictEqual(state.progress, legacy.entries.e1.progress); assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks); assert.strictEqual(state.notes[0].id, 'nt_old'); assert.strictEqual(state.notes[0].text, legacyText); assert.strictEqual(state.notes[0].source, 'ai'); assert.strictEqual(state.notes[0].createdAt, 123); assert.strictEqual(state.notes[0].updatedAt, 123); assert.strictEqual(state.notes[0].collectionId, null); const migrated = JSON.parse(fs.readFileSync(file, 'utf8')); assert.strictEqual(migrated.version, 6); assert.deepStrictEqual(migrated.collections, []); const bytes = fs.readFileSync(file, 'utf8'); s = storeAt(d); assert.strictEqual(s.getState('e1').notes.length, 1); assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes, 'v6 再加载不应重复迁移'); }); test('字段限制、来源校验和安全 ID 校验生效', () => { const s = freshStore(); assert.throws(() => s.getState('../reader'), /ID无效/); assert.throws( () => s.setProgress('e1', 'toString', { kind: 'pdf', page: 1 }, 0.1), /文档标识无效/ ); assert.throws(() => s.addNote('e1', { text: 'x', source: 'robot' }), /来源无效/); assert.throws( () => s.addNote('e1', { text: 'x', collectionId: 'col_missing' }), /不存在/ ); const note = s.addNote('e1', { text: 'x'.repeat(25000), tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`) }); assert.strictEqual(note.text.length, 25000); assert.strictEqual(note.tags.length, 30); }); test('写盘失败时内存和磁盘状态都回滚', () => { const d = tmp(); let s = storeAt(d); s.addNote('e1', { text: '已保存' }); const file = path.join(d, 'reader.json'); const before = fs.readFileSync(file, 'utf8'); const originalRename = fs.renameSync; fs.renameSync = (from, to) => { if (from === `${file}.tmp` && to === file) throw new Error('模拟写盘失败'); return originalRename(from, to); }; try { assert.throws(() => s.addNote('e1', { text: '不应保存' }), /模拟写盘失败/); } finally { fs.renameSync = originalRename; } assert.strictEqual(s.getState('e1').notes.length, 1); assert.strictEqual(fs.readFileSync(file, 'utf8'), before); s = storeAt(d); assert.strictEqual(s.getState('e1').notes.length, 1); }); // --- reader/ai-config --- function freshCfg(storage = fakeStorage()) { delete require.cache[cfgPath]; const c = require(cfgPath); c.init(tmp(), storage); return c; } test('AI Key 加密落盘,磁盘无明文', () => { const d = tmp(); delete require.cache[cfgPath]; const c = require(cfgPath); c.init(d, fakeStorage()); c.save({ baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', apiKey: 'sk-SECRET-123' }); for (const f of fs.readdirSync(d)) { const content = fs.readFileSync(path.join(d, f)).toString(); assert.ok(!content.includes('sk-SECRET-123'), `${f} 出现明文 Key`); } assert.strictEqual(c.get().apiKey, 'sk-SECRET-123'); assert.strictEqual(c.status().hasKey, true); }); test('只改模型时不传 apiKey,不会清掉已存的 Key', () => { const c = freshCfg(); c.save({ protocol: 'anthropic', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini', apiKey: 'sk-keep', vision: true }); c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' }); assert.strictEqual(c.get().apiKey, 'sk-keep'); assert.strictEqual(c.get().model, 'gpt-4o'); assert.strictEqual(c.status().protocol, 'anthropic'); assert.strictEqual(c.status().vision, true); }); test('AI 接口类型显式持久化,旧配置默认使用 Chat Completions', () => { const d = tmp(); fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({ baseUrl: 'https://api.openai.com/v1', model: 'legacy' })); delete require.cache[cfgPath]; const c = require(cfgPath); c.init(d, fakeStorage()); assert.strictEqual(c.status().protocol, 'chat-completions'); c.save({ protocol: 'openai-responses', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1' }); assert.strictEqual(c.status().protocol, 'openai-responses'); assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).protocol, 'openai-responses'); assert.throws( () => c.save({ protocol: 'unknown', baseUrl: 'https://api.openai.com/v1', model: 'm' }), /接口类型/ ); }); test('AI 状态区分模型已配置、缺少 Key 与 Key 无法读取', () => { const d = tmp(); delete require.cache[cfgPath]; let c = require(cfgPath); c.init(d, fakeStorage()); let status = c.status(); assert.strictEqual(status.modelConfigured, false); assert.strictEqual(status.ready, false); assert.strictEqual(status.keyState, 'missing'); c.save({ protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet', vision: true }); status = c.status(); assert.strictEqual(status.modelConfigured, true); assert.strictEqual(status.ready, false); assert.strictEqual(status.keyState, 'missing'); c.save({ protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet', apiKey: 'sk-anthropic' }); assert.strictEqual(c.status().ready, true); delete require.cache[cfgPath]; c = require(cfgPath); c.init(d, { isEncryptionAvailable: () => true, decryptString: () => { throw new Error('cannot decrypt'); } }); status = c.status(); assert.strictEqual(status.modelConfigured, true); assert.strictEqual(status.hasKey, false); assert.strictEqual(status.ready, false); assert.strictEqual(status.keyState, 'unreadable'); }); test('图像输入能力必须显式配置并持久化', () => { const d = tmp(); delete require.cache[cfgPath]; const c = require(cfgPath); c.init(d, fakeStorage()); c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm' }); assert.strictEqual(c.status().vision, false); c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', vision: true }); assert.strictEqual(c.status().vision, true); assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).vision, true); }); test('图像输入能力拒绝配置文件中的非布尔真值', () => { const d = tmp(); fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({ baseUrl: 'https://api.openai.com/v1', model: 'm', vision: 'true' })); delete require.cache[cfgPath]; const c = require(cfgPath); c.init(d, fakeStorage()); assert.strictEqual(c.status().vision, false); assert.strictEqual(c.get().vision, false); }); test('显式传空字符串才清除 Key', () => { const c = freshCfg(); c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: 'sk-x' }); c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: '' }); assert.strictEqual(c.status().hasKey, false); }); test('切换 AI 接口类型或服务来源时不会复用旧 API Key', () => { const c = freshCfg(); c.save({ protocol: 'chat-completions', baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: 'sk-openai' }); c.save({ protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude' }); assert.strictEqual(c.status().hasKey, false); c.save({ protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude', apiKey: 'sk-anthropic' }); c.save({ protocol: 'anthropic', baseUrl: 'https://proxy.example.com/v1', model: 'claude' }); assert.strictEqual(c.status().hasKey, false); }); test('非法接口地址被拒绝', () => { const c = freshCfg(); assert.throws(() => c.save({ baseUrl: 'ftp://x/v1', model: 'm' }), /http/); assert.throws(() => c.save({ baseUrl: '', model: 'm' }), /不能为空/); assert.throws(() => c.save({ baseUrl: 'https://a/v1', model: '' }), /模型/); assert.throws(() => c.save({ baseUrl: 'https://a/v1#fragment', model: 'm' }), /片段标识/); }); test('本地端点识别为无需 Key', () => { const c = freshCfg(); c.save({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen' }); assert.strictEqual(c.status().isLocal, true); c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt' }); assert.strictEqual(c.status().isLocal, false); }); test('加密不可用时不落盘 Key', () => { const d = tmp(); delete require.cache[cfgPath]; const c = require(cfgPath); c.init(d, fakeStorage(false)); c.save({ baseUrl: 'https://a.com/v1', model: 'm', apiKey: 'sk-plain' }); for (const f of fs.readdirSync(d)) { assert.ok(!fs.readFileSync(path.join(d, f)).toString().includes('sk-plain'), `${f} 落了明文`); } assert.strictEqual(c.get().apiKey, 'sk-plain'); assert.strictEqual(c.status().persistent, false); }); test('baseUrl 末尾斜杠被规范化', () => { const c = freshCfg(); c.save({ baseUrl: 'https://api.openai.com/v1///', model: 'm' }); assert.strictEqual(c.status().baseUrl, 'https://api.openai.com/v1'); });