1045 lines
36 KiB
JavaScript
1045 lines
36 KiB
JavaScript
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: '<script>alert(1)</script>' }]
|
|
}
|
|
}),
|
|
/段落无效/
|
|
);
|
|
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');
|
|
});
|