feat: 笔记独立窗口改为多标签,AI 多轮会话与 TXT/MD 阅读
笔记独立窗口从「一窗一条」改为单窗口多标签,与阅读器一致: 标签集在主进程侧为权威,notes:tabsChanged 只能收窄不能新增, 否则渲染层可以谎报持有某条笔记来越权读取。存活编辑器上限 3 并 LRU 回收,回收前序列化未保存内容。笔记没有自动保存,关标签与 关窗都做二次确认,取消关闭必须回报主进程复位 closePending, 否则窗口再也关不掉而看门狗仍会销毁未保存内容。 AI 助手支持多轮会话:会话独立落盘,先取历史再写提问, 历史只发文本不重发图像,失败与取消都保留已流出的残片。 新增 TXT/MD 内置阅读(转内存 EPUB 复用 epub 渲染管线), 补上渲染层遗漏的可阅读格式白名单:主进程本就放行 txt/md, 但渲染层另有两份白名单漏了,表现为卡片上没有「阅读」按钮。 书库卡片封面改用 contain 完整显示,留白由同图模糊层垫底, 修正不同比例封面被裁切程度不一导致的观感不一致;多选复选框 去掉衬底色块,恢复原生外观。 其余:PDF 画质档位与画布尺寸钳制、原子写入、笔记资源托管、 GitHub Pages 站点。
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const sessionsPath = require.resolve('../reader/ai-sessions.js');
|
||||
const imagesPath = require.resolve('../reader/ai-images.js');
|
||||
|
||||
const dirs = [];
|
||||
|
||||
function tmp(tag) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-ai-sessions-${tag}-`));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function fresh(tag = 'x') {
|
||||
const dir = tmp(tag);
|
||||
return at(dir);
|
||||
}
|
||||
|
||||
function at(dir) {
|
||||
delete require.cache[sessionsPath];
|
||||
delete require.cache[imagesPath];
|
||||
const images = require(imagesPath);
|
||||
const sessions = require(sessionsPath);
|
||||
images.init(dir);
|
||||
sessions.init(dir);
|
||||
return { sessions, images, dir };
|
||||
}
|
||||
|
||||
function sessionDir(dir) {
|
||||
return path.join(dir, 'reader-ai-sessions');
|
||||
}
|
||||
|
||||
function sessionFile(dir, id) {
|
||||
return path.join(sessionDir(dir), `${id}.json`);
|
||||
}
|
||||
|
||||
function imageDir(dir) {
|
||||
return path.join(dir, 'reader-ai-images');
|
||||
}
|
||||
|
||||
// 最小可解码 JPEG:SOI + APP0 + 一段填充 + EOI。put 只校验 SOI 魔数,内容差异即哈希差异。
|
||||
function jpeg(seed, size = 64) {
|
||||
const bytes = Buffer.alloc(size, seed & 0xff);
|
||||
bytes[0] = 0xff;
|
||||
bytes[1] = 0xd8;
|
||||
bytes[2] = 0xff;
|
||||
bytes[3] = 0xe0;
|
||||
bytes[size - 2] = 0xff;
|
||||
bytes[size - 1] = 0xd9;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// atomic-file 通过 fd 写入,因此统计 openSync('<file>.tmp','w') 而不是 writeFileSync 的路径
|
||||
function countWrites(matcher, fn) {
|
||||
const originalOpen = fs.openSync;
|
||||
let writes = 0;
|
||||
fs.openSync = function counting(target, flags, ...rest) {
|
||||
if (matcher(String(target)) && String(flags).startsWith('w')) writes++;
|
||||
return originalOpen.call(this, target, flags, ...rest);
|
||||
};
|
||||
try { fn(); } finally { fs.openSync = originalOpen; }
|
||||
return writes;
|
||||
}
|
||||
|
||||
function round(s, id, question, answer) {
|
||||
s.appendUser(id, { text: question, task: 'ask' });
|
||||
const placeholder = s.appendAssistant(id, {});
|
||||
return s.finishAssistant(id, placeholder.id, { text: answer });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
for (const dir of dirs) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// --- 基本读写 ---
|
||||
|
||||
test('会话按 per-session 文件落盘,索引可枚举', () => {
|
||||
const { sessions, dir } = fresh('basic');
|
||||
const a = sessions.create({ entryId: 'e1', title: '甲会话' });
|
||||
const b = sessions.create({ entryId: 'e2' });
|
||||
assert.match(a.id, /^chat_[a-z0-9]+_[a-z0-9]+$/, '会话 ID 必须是可安全拼进文件名的形状');
|
||||
assert.strictEqual(a.entryId, 'e1');
|
||||
assert.strictEqual(b.entryId, 'e2');
|
||||
const files = fs.readdirSync(sessionDir(dir)).sort();
|
||||
assert.deepStrictEqual(files, ['index.json', `${a.id}.json`, `${b.id}.json`].sort(),
|
||||
'每个会话应独占一个文件,加上一份索引');
|
||||
assert.deepStrictEqual(sessions.list().map((row) => row.id).sort(), [a.id, b.id].sort());
|
||||
assert.deepStrictEqual(sessions.list({ entryId: 'e1' }).map((row) => row.id), [a.id]);
|
||||
assert.deepStrictEqual(sessions.list({ entryId: 'e404' }), []);
|
||||
});
|
||||
|
||||
test('未指定 entryId 的会话落到 GLOBAL_ENTRY_ID,与独立笔记伪条目不同', () => {
|
||||
const { sessions } = fresh('global');
|
||||
const g = sessions.create({});
|
||||
assert.strictEqual(g.entryId, 'system:global-chat');
|
||||
assert.notStrictEqual(sessions.GLOBAL_ENTRY_ID, 'system:standalone-notes',
|
||||
'全局会话与独立笔记是两个伪条目,混用会让笔记与会话互相污染');
|
||||
});
|
||||
|
||||
test('list 按 pinned desc, updatedAt desc 排序', () => {
|
||||
const { sessions } = fresh('sort');
|
||||
const a = sessions.create({ title: 'a' });
|
||||
const b = sessions.create({ title: 'b' });
|
||||
const c = sessions.create({ title: 'c' });
|
||||
sessions.rename(b.id, 'b2');
|
||||
sessions.setPinned(a.id, true);
|
||||
const ids = sessions.list().map((row) => row.id);
|
||||
assert.strictEqual(ids[0], a.id, 'pinned 的会话必须排最前');
|
||||
assert.ok(ids.indexOf(b.id) < ids.indexOf(c.id), '未 pin 的按 updatedAt 倒序');
|
||||
});
|
||||
|
||||
test('一轮对话可完整读回,占位回答在 finish 时才成为消息', () => {
|
||||
const { sessions } = fresh('round');
|
||||
const meta = sessions.create({ entryId: 'e1' });
|
||||
sessions.appendUser(meta.id, { text: '这是问题', task: 'ask' });
|
||||
assert.strictEqual(sessions.messages(meta.id).messages.length, 1);
|
||||
const placeholder = sessions.appendAssistant(meta.id, {});
|
||||
assert.strictEqual(sessions.messages(meta.id).messages.length, 1,
|
||||
'占位回答不落盘,否则流式过程中每个增量都会整文件重写');
|
||||
const done = sessions.finishAssistant(meta.id, placeholder.id, { text: '这是回答' });
|
||||
assert.strictEqual(done.id, placeholder.id, 'finish 必须复用占位 ID,渲染层靠它对齐流式片段');
|
||||
const read = sessions.messages(meta.id);
|
||||
assert.deepStrictEqual(read.messages.map((m) => m.role), ['user', 'assistant']);
|
||||
assert.strictEqual(read.messages[1].text, '这是回答');
|
||||
assert.strictEqual(read.meta.messageCount, 2);
|
||||
assert.strictEqual(read.meta.title, '这是问题', '首轮问题应自动成为标题');
|
||||
});
|
||||
|
||||
test('finishAssistant 可记录取消与错误,且 cancelled 保留残片', () => {
|
||||
const { sessions } = fresh('finish');
|
||||
const meta = sessions.create({});
|
||||
sessions.appendUser(meta.id, { text: 'q' });
|
||||
const p1 = sessions.appendAssistant(meta.id, {});
|
||||
const cancelled = sessions.finishAssistant(meta.id, p1.id, { text: '半句', cancelled: true });
|
||||
assert.strictEqual(cancelled.cancelled, true);
|
||||
assert.strictEqual(cancelled.text, '半句', '中途停止时残片必须保留,用户看到的就是它');
|
||||
sessions.appendUser(meta.id, { text: 'q2' });
|
||||
const p2 = sessions.appendAssistant(meta.id, {});
|
||||
const failed = sessions.finishAssistant(meta.id, p2.id, { text: '', error: '接口超时' });
|
||||
assert.strictEqual(failed.error, '接口超时');
|
||||
assert.throws(() => sessions.finishAssistant(meta.id, 'msg_nope', { text: 'x' }), /会话消息不存在/);
|
||||
});
|
||||
|
||||
test('clear 清空消息但保留会话,remove 删除会话与索引行', () => {
|
||||
const { sessions, dir } = fresh('lifecycle');
|
||||
const meta = sessions.create({ entryId: 'e1', title: '保留' });
|
||||
round(sessions, meta.id, '问', '答');
|
||||
const cleared = sessions.clear(meta.id);
|
||||
assert.strictEqual(cleared.messageCount, 0);
|
||||
assert.strictEqual(cleared.droppedMessages, 0);
|
||||
assert.strictEqual(cleared.title, '保留', 'clear 不应丢标题');
|
||||
assert.strictEqual(sessions.list().length, 1);
|
||||
assert.strictEqual(sessions.remove(meta.id), true);
|
||||
assert.strictEqual(sessions.remove(meta.id), false);
|
||||
assert.strictEqual(sessions.list().length, 0);
|
||||
assert.ok(!fs.existsSync(sessionFile(dir, meta.id)));
|
||||
assert.throws(() => sessions.messages(meta.id), /会话不存在/);
|
||||
});
|
||||
|
||||
test('messages 支持 limit 与 before 游标翻页', () => {
|
||||
const { sessions } = fresh('paging');
|
||||
const meta = sessions.create({});
|
||||
for (let i = 0; i < 5; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||
const tail = sessions.messages(meta.id, { limit: 4 });
|
||||
assert.strictEqual(tail.messages.length, 4);
|
||||
assert.strictEqual(tail.hasMore, true);
|
||||
assert.strictEqual(tail.messages[3].text, '答4');
|
||||
const older = sessions.messages(meta.id, { limit: 4, before: tail.messages[0].id });
|
||||
assert.strictEqual(older.messages[older.messages.length - 1].id !== tail.messages[0].id, true,
|
||||
'before 必须是排他的,否则翻页会重复一条');
|
||||
assert.throws(() => sessions.messages(meta.id, { before: 'msg_missing' }), /会话消息不存在/);
|
||||
});
|
||||
|
||||
// --- 字段限长 ---
|
||||
|
||||
test('字段限长与 store.limitedString 一致:超长截断而不是抛错', () => {
|
||||
const { sessions } = fresh('limits');
|
||||
const L = sessions.LIMITS;
|
||||
const meta = sessions.create({ title: '标'.repeat(L.title + 50) });
|
||||
assert.strictEqual(meta.title.length, L.title, `title 应截到 ${L.title}`);
|
||||
const user = sessions.appendUser(meta.id, { text: '问'.repeat(L.question + 100) });
|
||||
assert.strictEqual(user.text.length, L.question, `user 文本应截到 question=${L.question}`);
|
||||
const p = sessions.appendAssistant(meta.id, {});
|
||||
const done = sessions.finishAssistant(meta.id, p.id, {
|
||||
text: '答'.repeat(L.messageText + 100),
|
||||
error: '错'.repeat(L.errorText + 100)
|
||||
});
|
||||
assert.strictEqual(done.text.length, L.messageText, `assistant 文本应截到 messageText=${L.messageText}`);
|
||||
assert.strictEqual(done.error.length, L.errorText, `error 应截到 ${L.errorText}`);
|
||||
const renamed = sessions.rename(meta.id, '新'.repeat(L.title + 10));
|
||||
assert.strictEqual(renamed.title.length, L.title);
|
||||
});
|
||||
|
||||
test('标题规范化去掉控制字符与换行,自动标题取首轮问题前 40 字', () => {
|
||||
const { sessions } = fresh('title');
|
||||
const meta = sessions.create({ title: ' 带\n换行\u0007和控制符 ' });
|
||||
assert.strictEqual(meta.title, '带 换行 和控制符', '标题会进索引 JSON,控制字符必须清掉');
|
||||
const auto = sessions.create({});
|
||||
sessions.appendUser(auto.id, { text: '标'.repeat(100) });
|
||||
assert.strictEqual(sessions.messages(auto.id).meta.title.length, 40, '自动标题只取前 40 字');
|
||||
});
|
||||
|
||||
test('非法上下文与任务被拒,contextRef 只在 user 消息上存在', () => {
|
||||
const { sessions } = fresh('shape');
|
||||
const meta = sessions.create({});
|
||||
assert.throws(() => sessions.appendUser(meta.id, {
|
||||
text: 'q', contextRef: { scope: 'whatever' }
|
||||
}), /会话上下文范围无效/);
|
||||
assert.throws(() => sessions.appendUser(meta.id, { text: 'q', task: 'hack' }), /会话任务类型无效/);
|
||||
const withContext = sessions.appendUser(meta.id, {
|
||||
text: 'q',
|
||||
contextRef: { scope: 'page', text: '正文内容', clipped: true, locator: { page: 3 }, documentKey: 'dk', fileIndex: 2 }
|
||||
});
|
||||
assert.strictEqual(withContext.contextRef.chars, 4);
|
||||
assert.strictEqual(withContext.contextRef.hash, sessions.hashContext('正文内容'));
|
||||
assert.strictEqual(withContext.contextRef.hash.length, 32, 'hash 固定 32 hex,用于判断上下文是否变了');
|
||||
assert.strictEqual(withContext.contextRef.clipped, true);
|
||||
assert.deepStrictEqual(withContext.contextRef.locator, { page: 3 });
|
||||
const p = sessions.appendAssistant(meta.id, { contextRef: { scope: 'page', text: 'x' } });
|
||||
assert.strictEqual(p.contextRef, null, 'assistant 消息不应带 contextRef');
|
||||
assert.throws(() => sessions.appendUser(meta.id, {
|
||||
text: 'q', contextRef: { scope: 'page', locator: { blob: 'x'.repeat(60000) } }
|
||||
}), /定位信息/);
|
||||
});
|
||||
|
||||
// --- 上限策略 ---
|
||||
|
||||
test('会话数达上限抛中文错误,且不删除已有会话', () => {
|
||||
const { sessions, dir } = fresh('total');
|
||||
const total = sessions.LIMITS.sessionsTotal;
|
||||
const ids = [];
|
||||
for (let i = 0; i < total; i++) ids.push(sessions.create({ title: `会话${i}` }).id);
|
||||
const before = fs.readdirSync(sessionDir(dir)).length;
|
||||
assert.throws(() => sessions.create({ title: '溢出' }), /会话数量已达上限/,
|
||||
'达到上限必须拒绝新建,绝不能静默删用户的旧会话');
|
||||
assert.strictEqual(fs.readdirSync(sessionDir(dir)).length, before, '拒绝新建不应改动磁盘');
|
||||
assert.strictEqual(sessions.list().length, total);
|
||||
sessions.remove(ids[0]);
|
||||
assert.ok(sessions.create({ title: '腾出位置后可建' }).id, '删掉一个后应能继续新建');
|
||||
});
|
||||
|
||||
test('单会话消息达上限成对淘汰,首轮保留且不出现孤立 assistant', () => {
|
||||
const { sessions } = fresh('evict');
|
||||
const max = sessions.LIMITS.messagesPerSession;
|
||||
const meta = sessions.create({});
|
||||
sessions.appendUser(meta.id, { text: '首轮问题', contextRef: { scope: 'document', text: '正文' } });
|
||||
const first = sessions.appendAssistant(meta.id, {});
|
||||
sessions.finishAssistant(meta.id, first.id, { text: '首轮回答' });
|
||||
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||
const read = sessions.messages(meta.id, { limit: max });
|
||||
assert.ok(read.meta.messageCount <= max, `消息数不得超过 ${max}`);
|
||||
assert.strictEqual(read.messages[0].text, '首轮问题', '承载正文的首轮必须永久保留');
|
||||
assert.ok(read.messages[0].contextRef, '首轮的 contextRef 不能被淘汰掉');
|
||||
assert.ok(read.meta.droppedMessages > 0, 'droppedMessages 必须累加,供历史里的省略标记使用');
|
||||
assert.strictEqual(read.meta.droppedMessages % 2, 0, '成对淘汰时丢弃数应为偶数');
|
||||
assert.strictEqual(
|
||||
read.meta.messageCount + read.meta.droppedMessages,
|
||||
(max + 1) * 2,
|
||||
'保留数加丢弃数应等于写入总数,说明没有额外丢失'
|
||||
);
|
||||
const roles = read.messages.map((m) => m.role);
|
||||
for (let i = 1; i < roles.length; i++) {
|
||||
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||
'成对淘汰后不允许出现连续同角色,否则历史里会有没有提问的孤立回答');
|
||||
}
|
||||
});
|
||||
|
||||
// appendUser 之后、finishAssistant 之前的这个中间态就是构造请求时 historyFor 看到的状态。
|
||||
// 逐条丢弃在整轮结束时会被第二次丢弃恰好补回来,只有在中间态才能看出角色错位。
|
||||
test('淘汰后的中间态也不出现连续同角色', () => {
|
||||
const { sessions } = fresh('evict-mid');
|
||||
const max = sessions.LIMITS.messagesPerSession;
|
||||
const meta = sessions.create({});
|
||||
for (let i = 0; i < max / 2; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||
assert.strictEqual(sessions.messages(meta.id, { limit: max }).meta.messageCount, max, '先填满到上限');
|
||||
sessions.appendUser(meta.id, { text: '新问题' });
|
||||
const read = sessions.messages(meta.id, { limit: max });
|
||||
assert.strictEqual(read.meta.droppedMessages, 2, '一次淘汰应成对丢弃两条,而不是单条');
|
||||
const roles = read.messages.map((m) => m.role);
|
||||
for (let i = 1; i < roles.length; i++) {
|
||||
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||
'中间态出现连续同角色,说明淘汰是逐条而非成对,这会让历史里出现两条相邻的回答');
|
||||
}
|
||||
assert.strictEqual(roles[0], 'user');
|
||||
assert.strictEqual(read.messages[0].text, '问0', '首轮提问必须还在');
|
||||
assert.strictEqual(roles[roles.length - 1], 'user');
|
||||
const out = sessions.historyFor(meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||||
assert.strictEqual(
|
||||
out.messages.length,
|
||||
read.messages.length,
|
||||
'中间态角色已经交替,historyFor 不该再需要合并任何消息;需要合并说明淘汰留下了错位'
|
||||
);
|
||||
});
|
||||
|
||||
test('图像数达上限时丢弃最早消息的图像引用但保留文本', () => {
|
||||
const { sessions, images } = fresh('img-evict');
|
||||
const max = sessions.LIMITS.imagesPerSession;
|
||||
const meta = sessions.create({});
|
||||
const ids = [];
|
||||
for (let i = 0; i <= max; i++) {
|
||||
const put = images.put(jpeg(i + 1), 'image/jpeg');
|
||||
ids.push(put.imageId);
|
||||
sessions.appendUser(meta.id, {
|
||||
text: `第 ${i} 张`,
|
||||
images: [{ imageId: put.imageId, mimeType: 'image/jpeg', width: 10, height: 10, bytes: put.bytes }]
|
||||
});
|
||||
const p = sessions.appendAssistant(meta.id, {});
|
||||
sessions.finishAssistant(meta.id, p.id, { text: `答 ${i}` });
|
||||
}
|
||||
const read = sessions.messages(meta.id, { limit: 500 });
|
||||
const totalImages = read.messages.reduce((n, m) => n + m.images.length, 0);
|
||||
assert.ok(totalImages <= max, `会话内图像引用不得超过 ${max}`);
|
||||
assert.strictEqual(read.messages[0].text, '第 0 张', '丢图像引用不应丢文本');
|
||||
assert.strictEqual(read.messages[0].images.length, 0, '最早的图像引用先被丢弃');
|
||||
assert.ok(!sessions.imageIds().includes(ids[0]), '被丢弃的引用不应再出现在 GC 白名单里');
|
||||
});
|
||||
|
||||
// --- 文件隔离与写放大 ---
|
||||
|
||||
test('追加消息只重写自己那个会话文件', () => {
|
||||
const { sessions, dir } = fresh('isolate');
|
||||
const a = sessions.create({ title: 'a' });
|
||||
const b = sessions.create({ title: 'b' });
|
||||
const bFile = sessionFile(dir, b.id);
|
||||
const before = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||||
round(sessions, a.id, '问', '答');
|
||||
const after = crypto.createHash('sha256').update(fs.readFileSync(bFile)).digest('hex');
|
||||
assert.strictEqual(after, before, '写 a 不应碰 b 的字节,否则 100 个会话就是 100 倍写放大');
|
||||
const writesToB = countWrites((t) => t === `${bFile}.tmp`, () => {
|
||||
round(sessions, a.id, '问2', '答2');
|
||||
});
|
||||
assert.strictEqual(writesToB, 0, '其他会话文件的写入次数必须是 0');
|
||||
});
|
||||
|
||||
test('一轮对话对会话文件只写两次,索引写入不随消息数增长', () => {
|
||||
const { sessions, dir } = fresh('writes');
|
||||
const meta = sessions.create({});
|
||||
const file = sessionFile(dir, meta.id);
|
||||
const isSession = (t) => t === `${file}.tmp`;
|
||||
const oneRound = countWrites(isSession, () => {
|
||||
sessions.appendUser(meta.id, { text: '问' });
|
||||
const p = sessions.appendAssistant(meta.id, {});
|
||||
for (let i = 0; i < 50; i++) sessions.appendAssistant; // 流式增量不经过存储层
|
||||
sessions.finishAssistant(meta.id, p.id, { text: '答'.repeat(500) });
|
||||
});
|
||||
assert.strictEqual(oneRound, 2, '一轮对话最多两次会话文件写入:appendUser 一次,finishAssistant 一次');
|
||||
// 对照:两轮就是四次,证明计数器没失灵
|
||||
const twoRounds = countWrites(isSession, () => {
|
||||
round(sessions, meta.id, '问2', '答2');
|
||||
round(sessions, meta.id, '问3', '答3');
|
||||
});
|
||||
assert.strictEqual(twoRounds, 4, '两轮应写四次,用于对照说明上面的 2 不是计数器失灵');
|
||||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||
const idxWrites = countWrites((t) => t === `${idxFile}.tmp`, () => {
|
||||
round(sessions, meta.id, '问4', '答4');
|
||||
});
|
||||
assert.strictEqual(idxWrites, 2, '索引跟着会话文件一起更新,一轮两次');
|
||||
});
|
||||
|
||||
test('会话文件解析结果按内容缓存,同 mtime 同体积但内容不同也不会读到旧值', () => {
|
||||
const { sessions, dir } = fresh('cache');
|
||||
const meta = sessions.create({});
|
||||
round(sessions, meta.id, '原始问题', '原始回答');
|
||||
const file = sessionFile(dir, meta.id);
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
// utimesSync 会丢掉 mtime 的小数毫秒,先钉成整秒再取基准,否则构造本身对不上
|
||||
const fixed = new Date(1700000000000);
|
||||
fs.utimesSync(file, fixed, fixed);
|
||||
const stat = fs.statSync(file);
|
||||
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '原始问题');
|
||||
// 等长替换 + 还原 mtime:按 mtime+size 判定的缓存在这里会返回旧内容
|
||||
raw.messages[0].text = '篡改问题';
|
||||
const rewritten = JSON.stringify(raw, null, 2);
|
||||
assert.strictEqual(Buffer.byteLength(rewritten, 'utf8'), stat.size, '构造用例要求体积不变');
|
||||
fs.writeFileSync(file, rewritten);
|
||||
fs.utimesSync(file, fixed, fixed);
|
||||
assert.strictEqual(fs.statSync(file).mtimeMs, stat.mtimeMs, '构造用例要求 mtime 不变');
|
||||
assert.strictEqual(fs.statSync(file).size, stat.size, '构造用例要求体积不变');
|
||||
assert.strictEqual(sessions.messages(meta.id).messages[0].text, '篡改问题',
|
||||
'缓存必须按内容哈希失效,按 mtime+size 会漏掉同毫秒内的改写');
|
||||
});
|
||||
|
||||
// --- 损坏自愈 ---
|
||||
|
||||
test('主文件损坏时从 .bak 恢复', () => {
|
||||
const { sessions, dir } = fresh('bak');
|
||||
const meta = sessions.create({ entryId: 'e1' });
|
||||
round(sessions, meta.id, '问', '答');
|
||||
const file = sessionFile(dir, meta.id);
|
||||
fs.copyFileSync(file, `${file}.bak`);
|
||||
fs.writeFileSync(file, '{ 这不是 JSON');
|
||||
const read = sessions.messages(meta.id);
|
||||
assert.strictEqual(read.messages.length, 2, '.bak 完好时必须恢复出完整对话');
|
||||
assert.strictEqual(read.messages[0].text, '问');
|
||||
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||||
assert.strictEqual(corrupt.length, 1, '损坏的主文件应被隔离留档而不是直接删除');
|
||||
});
|
||||
|
||||
test('主文件与 .bak 都损坏时隔离成 .corrupt- 并返回空会话', () => {
|
||||
const { sessions, dir } = fresh('corrupt');
|
||||
const meta = sessions.create({ entryId: 'e1', title: '标题' });
|
||||
round(sessions, meta.id, '问', '答');
|
||||
const file = sessionFile(dir, meta.id);
|
||||
fs.writeFileSync(file, 'broken');
|
||||
fs.writeFileSync(`${file}.bak`, 'also broken');
|
||||
const read = sessions.messages(meta.id);
|
||||
assert.deepStrictEqual(read.messages, [], '两份都坏只能返回空,不能抛错让界面卡死');
|
||||
assert.strictEqual(read.meta.entryId, 'e1', '空会话应从索引行恢复出归属,否则会变成孤立会话');
|
||||
const corrupt = fs.readdirSync(sessionDir(dir)).filter((n) => n.includes('.corrupt-'));
|
||||
assert.strictEqual(corrupt.length, 1);
|
||||
round(sessions, meta.id, '新问', '新答');
|
||||
assert.strictEqual(sessions.messages(meta.id).messages.length, 2, '自愈后应能继续写入');
|
||||
});
|
||||
|
||||
test('index.json 丢失后扫目录重建,list 结果正确', () => {
|
||||
const { sessions, dir } = fresh('reindex');
|
||||
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||||
const b = sessions.create({ entryId: 'e2', title: '乙' });
|
||||
round(sessions, a.id, '问', '答');
|
||||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||
fs.unlinkSync(idxFile);
|
||||
const reloaded = at(dir).sessions;
|
||||
const rows = reloaded.list();
|
||||
assert.strictEqual(rows.length, 2, '索引是派生缓存,丢失后必须能从会话文件重建');
|
||||
const rowA = rows.find((r) => r.id === a.id);
|
||||
assert.strictEqual(rowA.title, '甲');
|
||||
assert.strictEqual(rowA.entryId, 'e1');
|
||||
assert.strictEqual(rowA.messageCount, 2);
|
||||
assert.ok(rowA.bytes > 0, '重建的索引行应带上真实体积');
|
||||
assert.strictEqual(rows.find((r) => r.id === b.id).messageCount, 0);
|
||||
assert.ok(fs.existsSync(idxFile), '重建结果应回写磁盘');
|
||||
});
|
||||
|
||||
test('index.json 损坏时重建,且损坏的索引不会让会话消失', () => {
|
||||
const { sessions, dir } = fresh('reindex2');
|
||||
const a = sessions.create({ title: '甲' });
|
||||
fs.writeFileSync(path.join(sessionDir(dir), 'index.json'), '[[[not json');
|
||||
const reloaded = at(dir).sessions;
|
||||
assert.deepStrictEqual(reloaded.list().map((r) => r.id), [a.id]);
|
||||
});
|
||||
|
||||
test('索引里混入非法行时被丢弃,真实会话仍在', () => {
|
||||
const { sessions, dir } = fresh('reindex3');
|
||||
const a = sessions.create({ title: '甲' });
|
||||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||||
idx.sessions.push({ id: '../escape', title: '恶意' });
|
||||
idx.sessions.push({ id: '__proto__', title: '污染' });
|
||||
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||||
const rows = at(dir).sessions.list();
|
||||
assert.deepStrictEqual(rows.map((r) => r.id), [a.id], '索引里的非法 ID 必须被丢弃');
|
||||
});
|
||||
|
||||
test('会话文件里的坏消息被逐条丢弃,其余消息仍可读', () => {
|
||||
const { sessions, dir } = fresh('bad-msg');
|
||||
const meta = sessions.create({});
|
||||
round(sessions, meta.id, '好问题', '好回答');
|
||||
const file = sessionFile(dir, meta.id);
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
raw.messages.splice(1, 0, { role: 'nobody', text: '坏消息' }, null, 'not an object');
|
||||
raw.messages.push({ role: 'assistant', text: '尾部回答', images: 'not an array', task: '非法' });
|
||||
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||
const read = at(dir).sessions.messages(meta.id);
|
||||
assert.deepStrictEqual(read.messages.map((m) => m.text), ['好问题', '好回答', '尾部回答'],
|
||||
'会话内容部分来自模型输出,坏条目要丢弃而不是让整个会话读不出来');
|
||||
assert.deepStrictEqual(read.messages[2].images, []);
|
||||
assert.strictEqual(read.messages[2].task, null);
|
||||
});
|
||||
|
||||
// --- ID 校验 ---
|
||||
|
||||
test('非法会话 ID 被拒,不产生任何文件', () => {
|
||||
const { sessions, dir } = fresh('ids');
|
||||
const bad = ['', '../escape', 'a/b', 'a\\b', '__proto__', 'prototype', 'constructor',
|
||||
'.', '..', '-lead', 'x'.repeat(200), 'a.b', 'a:b', null, undefined, 'chat_ok\u0000'];
|
||||
for (const id of bad) {
|
||||
assert.throws(() => sessions.messages(id), /会话 ID 无效/, `会话 ID ${JSON.stringify(id)} 必须被拒`);
|
||||
assert.throws(() => sessions.remove(id), /会话 ID 无效/);
|
||||
assert.throws(() => sessions.appendUser(id, { text: 'x' }), /会话 ID 无效/);
|
||||
}
|
||||
assert.ok(!fs.existsSync(sessionDir(dir)) || !fs.readdirSync(sessionDir(dir)).some((n) => n !== 'index.json'),
|
||||
'被拒的 ID 不应在磁盘上留下文件');
|
||||
});
|
||||
|
||||
test('非法 entryId 与 messageId 被拒', () => {
|
||||
const { sessions } = fresh('ids2');
|
||||
for (const entryId of ['../x', 'a/b', '__proto__', 'x'.repeat(200), '.', '..']) {
|
||||
assert.throws(() => sessions.create({ entryId }), /会话条目 ID 无效/);
|
||||
}
|
||||
const meta = sessions.create({ entryId: 'e1' });
|
||||
for (const msgId of ['', '../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||||
assert.throws(() => sessions.finishAssistant(meta.id, msgId, { text: 'x' }), /会话消息 ID 无效/);
|
||||
}
|
||||
for (const msgId of ['../x', 'a/b', '__proto__', 'x'.repeat(200)]) {
|
||||
assert.throws(() => sessions.messages(meta.id, { before: msgId }), /会话消息 ID 无效/);
|
||||
}
|
||||
assert.doesNotThrow(() => sessions.messages(meta.id, { before: '' }), '空游标等价于不传,取最新一页');
|
||||
});
|
||||
|
||||
test('finishAssistant 不能跨会话认领占位消息', () => {
|
||||
const { sessions } = fresh('cross');
|
||||
const a = sessions.create({});
|
||||
const b = sessions.create({});
|
||||
sessions.appendUser(a.id, { text: 'q' });
|
||||
const p = sessions.appendAssistant(a.id, {});
|
||||
assert.throws(() => sessions.finishAssistant(b.id, p.id, { text: '越界' }), /会话消息不存在/,
|
||||
'占位消息必须绑定会话,否则渲染层可以把回答写进别的会话');
|
||||
});
|
||||
|
||||
// --- historyFor ---
|
||||
|
||||
function history(sessions, id, budget) {
|
||||
return sessions.historyFor(id, budget);
|
||||
}
|
||||
|
||||
test('historyFor 始终 pin 首条 user 消息', () => {
|
||||
const { sessions } = fresh('hist-pin');
|
||||
const meta = sessions.create({});
|
||||
sessions.appendUser(meta.id, { text: `正文${'甲'.repeat(300)}`, contextRef: { scope: 'document', text: 'x' } });
|
||||
const p = sessions.appendAssistant(meta.id, {});
|
||||
sessions.finishAssistant(meta.id, p.id, { text: '首答' });
|
||||
for (let i = 0; i < 20; i++) round(sessions, meta.id, `问${i}`.repeat(20), `答${i}`.repeat(20));
|
||||
const out = history(sessions, meta.id, { maxChars: 600, maxMessages: 6, maxMessageChars: 400 });
|
||||
assert.strictEqual(out.messages[0].role, 'user');
|
||||
assert.ok(out.messages[0].text.includes('正文'),
|
||||
'承载正文的首条 user 必须永远在历史里,否则后续追问会失去参照');
|
||||
assert.ok(out.messages.length < 42, '预算内应确实丢掉了中间轮次');
|
||||
assert.ok(out.dropped > 0);
|
||||
});
|
||||
|
||||
test('historyFor 的省略标记折进现有消息而不是新增消息', () => {
|
||||
const { sessions } = fresh('hist-mark');
|
||||
const meta = sessions.create({});
|
||||
for (let i = 0; i < 12; i++) round(sessions, meta.id, `问题${i}`.repeat(20), `回答${i}`.repeat(20));
|
||||
const out = history(sessions, meta.id, { maxChars: 500, maxMessages: 5, maxMessageChars: 400 });
|
||||
const marked = out.messages.filter((m) => /已省略较早的 \d+ 轮对话/.test(m.text));
|
||||
assert.strictEqual(marked.length, 1, '省略标记只应出现一次');
|
||||
assert.strictEqual(marked[0], out.messages[0], '标记必须折进最旧那条保留消息');
|
||||
assert.ok(marked[0].text.length > '[……已省略较早的 1 轮对话……]\n'.length,
|
||||
'标记是折进去的,所以这条消息里还应有原本的正文,而不是一条只有标记的空消息');
|
||||
assert.ok(out.messages.every((m) => m.role === 'user' || m.role === 'assistant'),
|
||||
'不允许为了放标记而造出 system 之类的新角色');
|
||||
});
|
||||
|
||||
test('historyFor 总字符不超过 maxChars', () => {
|
||||
const { sessions } = fresh('hist-total');
|
||||
const meta = sessions.create({});
|
||||
for (let i = 0; i < 15; i++) round(sessions, meta.id, `问${i}`.repeat(50), `答${i}`.repeat(50));
|
||||
for (const maxChars of [200, 500, 1200, 3000]) {
|
||||
const out = history(sessions, meta.id, { maxChars, maxMessages: 30, maxMessageChars: 2000 });
|
||||
const total = out.messages.reduce((n, m) => n + m.text.length, 0);
|
||||
assert.ok(total <= maxChars, `maxChars=${maxChars} 时实际 ${total} 字符,超预算会直接被接口拒绝`);
|
||||
}
|
||||
});
|
||||
|
||||
test('historyFor 单条超限中间挖空且保留尾部', () => {
|
||||
const { sessions } = fresh('hist-clip');
|
||||
const meta = sessions.create({});
|
||||
const head = '开头标记';
|
||||
const tail = '结尾标记';
|
||||
sessions.appendUser(meta.id, { text: `${head}${'填'.repeat(3000)}${tail}` });
|
||||
const p = sessions.appendAssistant(meta.id, {});
|
||||
sessions.finishAssistant(meta.id, p.id, { text: '答' });
|
||||
const out = history(sessions, meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 600 });
|
||||
const first = out.messages[0];
|
||||
assert.ok(first.text.length <= 600, '单条应被压到 maxMessageChars 以内');
|
||||
assert.ok(first.text.startsWith(head), '挖空必须保留头部');
|
||||
assert.ok(first.text.endsWith(tail), '挖空必须保留尾部,尾部往往是真正的提问');
|
||||
assert.ok(first.text.includes('中间内容已省略'), '挖空处要有可见标记');
|
||||
assert.strictEqual(first.truncated, true, 'truncated 标记供界面提示用户');
|
||||
});
|
||||
|
||||
test('historyFor 裁剪后若首条是 assistant 则丢掉它', () => {
|
||||
const { sessions, dir } = fresh('hist-lead');
|
||||
const meta = sessions.create({});
|
||||
round(sessions, meta.id, '问', '答');
|
||||
// 恶性输入:直接把会话文件改成 assistant 领头,模拟历史数据或模型侧写坏
|
||||
const file = sessionFile(dir, meta.id);
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
raw.messages = [
|
||||
{ id: 'msg_a1', role: 'assistant', text: '孤立回答', createdAt: 1 },
|
||||
{ id: 'msg_u1', role: 'user', text: '真正的提问', createdAt: 2 },
|
||||
{ id: 'msg_a2', role: 'assistant', text: '真正的回答', createdAt: 3 }
|
||||
];
|
||||
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||
const out = at(dir).sessions.historyFor(meta.id, { maxChars: 4000, maxMessages: 10, maxMessageChars: 400 });
|
||||
assert.strictEqual(out.messages[0].role, 'user',
|
||||
'Anthropic 的 /messages 要求 messages[0].role === "user",领头的 assistant 必须丢掉');
|
||||
assert.ok(!out.messages.some((m) => m.text.includes('孤立回答')));
|
||||
});
|
||||
|
||||
test('historyFor 不产生连续同角色消息', () => {
|
||||
const { sessions, dir } = fresh('hist-roles');
|
||||
const meta = sessions.create({});
|
||||
round(sessions, meta.id, '问', '答');
|
||||
const file = sessionFile(dir, meta.id);
|
||||
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
raw.messages = [
|
||||
{ id: 'msg_u1', role: 'user', text: '问一', createdAt: 1 },
|
||||
{ id: 'msg_u2', role: 'user', text: '问二', createdAt: 2 },
|
||||
{ id: 'msg_u3', role: 'user', text: '问三', createdAt: 3 },
|
||||
{ id: 'msg_a1', role: 'assistant', text: '答一', createdAt: 4 },
|
||||
{ id: 'msg_a2', role: 'assistant', text: '答二', createdAt: 5 },
|
||||
{ id: 'msg_u4', role: 'user', text: '问四', createdAt: 6 }
|
||||
];
|
||||
fs.writeFileSync(file, JSON.stringify(raw, null, 2));
|
||||
const reloaded = at(dir).sessions;
|
||||
for (const budget of [
|
||||
{ maxChars: 4000, maxMessages: 10, maxMessageChars: 400 },
|
||||
{ maxChars: 20, maxMessages: 3, maxMessageChars: 50 },
|
||||
{ maxChars: 4000, maxMessages: 2, maxMessageChars: 400 }
|
||||
]) {
|
||||
const out = reloaded.historyFor(meta.id, budget);
|
||||
const roles = out.messages.map((m) => m.role);
|
||||
for (let i = 1; i < roles.length; i++) {
|
||||
assert.notStrictEqual(roles[i], roles[i - 1],
|
||||
`预算 ${JSON.stringify(budget)} 下出现了连续同角色,Anthropic 会直接 400`);
|
||||
}
|
||||
if (roles.length) assert.strictEqual(roles[0], 'user');
|
||||
assert.ok(out.messages.find((m) => m.text.includes('问一')), '首条 user 仍应被 pin');
|
||||
}
|
||||
});
|
||||
|
||||
test('historyFor 把已被物理丢弃的轮次计入省略标记', () => {
|
||||
const { sessions } = fresh('hist-dropped');
|
||||
const max = sessions.LIMITS.messagesPerSession;
|
||||
const meta = sessions.create({});
|
||||
for (let i = 0; i < max; i++) round(sessions, meta.id, `问${i}`, `答${i}`);
|
||||
const read = sessions.messages(meta.id, { limit: max });
|
||||
assert.ok(read.meta.droppedMessages > 0);
|
||||
const out = history(sessions, meta.id, { maxChars: 100000, maxMessages: max, maxMessageChars: 2000 });
|
||||
assert.ok(out.dropped >= read.meta.droppedMessages,
|
||||
'磁盘上已丢的轮次也要计入 dropped,否则模型会以为它看到了完整对话');
|
||||
assert.match(out.messages[0].text, /已省略较早的 \d+ 轮对话/);
|
||||
});
|
||||
|
||||
test('historyFor 在空会话与单条会话上不炸', () => {
|
||||
const { sessions } = fresh('hist-edge');
|
||||
const meta = sessions.create({});
|
||||
assert.deepStrictEqual(sessions.historyFor(meta.id, {}), { messages: [], dropped: 0 });
|
||||
sessions.appendUser(meta.id, { text: '只有一条' });
|
||||
const out = sessions.historyFor(meta.id, { maxChars: 5, maxMessages: 1, maxMessageChars: 50 });
|
||||
assert.strictEqual(out.messages.length, 1, '首条 user 即使超预算也要留下,否则请求没有内容可发');
|
||||
assert.strictEqual(out.messages[0].role, 'user');
|
||||
});
|
||||
|
||||
// --- imageIds 与 GC ---
|
||||
|
||||
test('imageIds 遍历全部会话文件,索引缺行也能找到引用', () => {
|
||||
const { sessions, images, dir } = fresh('gc-scan');
|
||||
const a = sessions.create({ title: '甲' });
|
||||
const b = sessions.create({ title: '乙' });
|
||||
const imgA = images.put(jpeg(11), 'image/jpeg');
|
||||
const imgB = images.put(jpeg(22), 'image/jpeg');
|
||||
sessions.appendUser(a.id, { text: '带图甲', images: [{ imageId: imgA.imageId, bytes: imgA.bytes }] });
|
||||
sessions.appendUser(b.id, { text: '带图乙', images: [{ imageId: imgB.imageId, bytes: imgB.bytes }] });
|
||||
// 故意让索引缺掉 b:只读索引的 GC 会把 b 引用的图当垃圾删掉
|
||||
const idxFile = path.join(sessionDir(dir), 'index.json');
|
||||
const idx = JSON.parse(fs.readFileSync(idxFile, 'utf8'));
|
||||
idx.sessions = idx.sessions.filter((row) => row.id !== b.id);
|
||||
fs.writeFileSync(idxFile, JSON.stringify(idx, null, 2));
|
||||
const reloaded = at(dir);
|
||||
assert.ok(!reloaded.sessions.list().some((row) => row.id === b.id) || true);
|
||||
const ids = reloaded.sessions.imageIds();
|
||||
assert.ok(ids.includes(imgB.imageId),
|
||||
'imageIds 必须扫目录而不是读索引,索引损坏时只读索引会静默删掉仍被引用的图');
|
||||
assert.ok(ids.includes(imgA.imageId));
|
||||
const removed = reloaded.images.cleanup(ids, { graceMs: 0 });
|
||||
assert.strictEqual(removed, 0, '全部图仍被引用时不该删任何东西');
|
||||
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgB.imageId}.jpg`)));
|
||||
});
|
||||
|
||||
test('imageIds 覆盖 .bak 里的引用与内存中的占位消息', () => {
|
||||
const { sessions, images, dir } = fresh('gc-bak');
|
||||
const meta = sessions.create({});
|
||||
const img = images.put(jpeg(33), 'image/jpeg');
|
||||
sessions.appendUser(meta.id, { text: '带图', images: [{ imageId: img.imageId, bytes: img.bytes }] });
|
||||
const file = sessionFile(dir, meta.id);
|
||||
fs.copyFileSync(file, `${file}.bak`);
|
||||
fs.writeFileSync(file, 'broken');
|
||||
const reloaded = at(dir);
|
||||
assert.ok(reloaded.sessions.imageIds().includes(img.imageId),
|
||||
'主文件坏掉但 .bak 还引用着这张图,GC 不能删它');
|
||||
const pendingImg = images.put(jpeg(44), 'image/jpeg');
|
||||
const s2 = fresh('gc-pending');
|
||||
const m2 = s2.sessions.create({});
|
||||
s2.sessions.appendUser(m2.id, { text: 'q' });
|
||||
s2.sessions.appendAssistant(m2.id, { images: [{ imageId: pendingImg.imageId, bytes: pendingImg.bytes }] });
|
||||
assert.ok(s2.sessions.imageIds().includes(pendingImg.imageId),
|
||||
'未落盘的占位消息引用的图也要在白名单里,否则流式期间的 GC 会删掉它');
|
||||
});
|
||||
|
||||
test('会话删除后其图像被 cleanup 回收', () => {
|
||||
const { sessions, images, dir } = fresh('gc-remove');
|
||||
const keep = sessions.create({ title: '留' });
|
||||
const drop = sessions.create({ title: '删' });
|
||||
const imgKeep = images.put(jpeg(55), 'image/jpeg');
|
||||
const imgDrop = images.put(jpeg(66), 'image/jpeg');
|
||||
sessions.appendUser(keep.id, { text: 'k', images: [{ imageId: imgKeep.imageId, bytes: imgKeep.bytes }] });
|
||||
sessions.appendUser(drop.id, { text: 'd', images: [{ imageId: imgDrop.imageId, bytes: imgDrop.bytes }] });
|
||||
sessions.remove(drop.id);
|
||||
const removed = images.cleanup(sessions.imageIds(), { graceMs: 0 });
|
||||
assert.strictEqual(removed, 1);
|
||||
assert.ok(fs.existsSync(path.join(imageDir(dir), `${imgKeep.imageId}.jpg`)));
|
||||
assert.ok(!fs.existsSync(path.join(imageDir(dir), `${imgDrop.imageId}.jpg`)));
|
||||
});
|
||||
|
||||
// --- ai-images ---
|
||||
|
||||
test('ai-images 只收 JPEG,魔数不对直接拒', () => {
|
||||
const { images } = fresh('img-mime');
|
||||
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||
assert.throws(() => images.put(png, 'image/png'), /仅支持 JPEG/);
|
||||
assert.throws(() => images.put(png, 'image/jpeg'), /不是有效的 JPEG/,
|
||||
'声明 JPEG 但内容是 PNG 必须被拒,否则视觉接口会收到无法解码的负载');
|
||||
assert.throws(() => images.put(Buffer.alloc(0), 'image/jpeg'), /数据为空/);
|
||||
assert.throws(() => images.put(Buffer.alloc(64, 0), 'image/jpeg'), /不是有效的 JPEG/);
|
||||
assert.throws(() => images.put('not a buffer', 'image/jpeg'), /数据为空/);
|
||||
assert.throws(() => images.put(jpeg(1, 4 * 1024 * 1024), 'image/jpeg'), /超过 3 MB/);
|
||||
});
|
||||
|
||||
test('ai-images 内容寻址去重,同图两次 put 只占一份磁盘', () => {
|
||||
const { images, dir } = fresh('img-dedup');
|
||||
const bytes = jpeg(77, 1024);
|
||||
const first = images.put(bytes, 'image/jpeg');
|
||||
const second = images.put(Buffer.from(bytes), 'image/jpeg');
|
||||
assert.strictEqual(second.imageId, first.imageId, '同样的字节必须得到同样的 imageId');
|
||||
assert.strictEqual(fs.readdirSync(imageDir(dir)).length, 1, '去重后磁盘上只应有一份');
|
||||
assert.strictEqual(images.totalBytes(), 1024);
|
||||
assert.match(first.imageId, /^img_[a-f0-9]{64}$/);
|
||||
const other = images.put(jpeg(78, 1024), 'image/jpeg');
|
||||
assert.notStrictEqual(other.imageId, first.imageId, '不同字节必须得到不同 imageId');
|
||||
assert.strictEqual(images.totalBytes(), 2048);
|
||||
});
|
||||
|
||||
test('ai-images 读回与 dataUrl 一致,非法 imageId 被拒', () => {
|
||||
const { images } = fresh('img-read');
|
||||
const bytes = jpeg(88, 256);
|
||||
const { imageId } = images.put(bytes, 'image/jpeg');
|
||||
assert.ok(images.read(imageId).equals(bytes));
|
||||
assert.strictEqual(images.dataUrl(imageId), `data:image/jpeg;base64,${bytes.toString('base64')}`);
|
||||
for (const bad of ['', 'img_short', '../escape', 'img_' + 'g'.repeat(64), '__proto__',
|
||||
`img_${'a'.repeat(64)}/../x`, null, 'pdf_' + 'a'.repeat(64)]) {
|
||||
assert.throws(() => images.read(bad), /会话图像标识无效/, `imageId ${JSON.stringify(bad)} 必须被拒`);
|
||||
assert.throws(() => images.dataUrl(bad), /会话图像标识无效/);
|
||||
assert.throws(() => images.safeImageId(bad), /会话图像标识无效/);
|
||||
}
|
||||
assert.throws(() => images.read(`img_${'a'.repeat(64)}`), /会话图像不存在/);
|
||||
});
|
||||
|
||||
test('ai-images cleanup 只删未引用的,宽限期内的新文件不删', () => {
|
||||
const { images, dir } = fresh('img-cleanup');
|
||||
const a = images.put(jpeg(1, 128), 'image/jpeg');
|
||||
const b = images.put(jpeg(2, 128), 'image/jpeg');
|
||||
const c = images.put(jpeg(3, 128), 'image/jpeg');
|
||||
assert.strictEqual(images.cleanup([a.imageId]), 0,
|
||||
'刚落盘的图还没被会话引用,宽限期内删掉就是删正在提交的数据');
|
||||
assert.strictEqual(images.cleanup([a.imageId], { graceMs: 0 }), 2);
|
||||
assert.deepStrictEqual(fs.readdirSync(imageDir(dir)), [`${a.imageId}.jpg`]);
|
||||
assert.ok(images.read(a.imageId));
|
||||
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 1);
|
||||
assert.strictEqual(images.totalBytes(), 0);
|
||||
assert.strictEqual(images.cleanup([], { graceMs: 0 }), 0, '目录空了也不该报错');
|
||||
// 白名单里的垃圾值不应意外保住任何文件
|
||||
images.put(jpeg(4, 128), 'image/jpeg');
|
||||
assert.strictEqual(images.cleanup(['../escape', null, '__proto__'], { graceMs: 0 }), 1);
|
||||
});
|
||||
|
||||
test('ai-images 目录不存在时 totalBytes 与 cleanup 返回零值', () => {
|
||||
const { images } = fresh('img-empty');
|
||||
assert.strictEqual(images.totalBytes(), 0);
|
||||
assert.strictEqual(images.cleanup([]), 0);
|
||||
});
|
||||
|
||||
test('会话里的图像元数据被规范化,非法引用被拒', () => {
|
||||
const { sessions, images } = fresh('img-meta');
|
||||
const meta = sessions.create({});
|
||||
const img = images.put(jpeg(99, 512), 'image/jpeg');
|
||||
assert.throws(() => sessions.appendUser(meta.id, {
|
||||
text: 'q', images: [{ imageId: '../escape' }]
|
||||
}), /会话图像标识无效/);
|
||||
assert.throws(() => sessions.appendUser(meta.id, {
|
||||
text: 'q', images: [{ imageId: img.imageId, mimeType: 'image/png' }]
|
||||
}), /仅支持 JPEG/);
|
||||
const stored = sessions.appendUser(meta.id, {
|
||||
text: 'q',
|
||||
images: [
|
||||
{ imageId: img.imageId, width: 100, height: 200, bytes: 512, ocrIncluded: true },
|
||||
{ imageId: img.imageId, width: 100, height: 200, bytes: 512 }
|
||||
]
|
||||
});
|
||||
assert.strictEqual(stored.images.length, 1, '同一张图重复引用应折成一条');
|
||||
assert.deepStrictEqual(stored.images[0], {
|
||||
imageId: img.imageId,
|
||||
mimeType: 'image/jpeg',
|
||||
width: 100,
|
||||
height: 200,
|
||||
bytes: 512,
|
||||
ocrIncluded: true
|
||||
});
|
||||
});
|
||||
|
||||
// --- 对账 ---
|
||||
|
||||
test('orphanReport 只报书库里已不存在的条目,GLOBAL_ENTRY_ID 永不判为孤立', () => {
|
||||
const { sessions } = fresh('orphan');
|
||||
const inLib = sessions.create({ entryId: 'e1', title: '在库' });
|
||||
const gone = sessions.create({ entryId: 'e404', title: '已删' });
|
||||
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||||
round(sessions, gone.id, '问', '答');
|
||||
const report = sessions.orphanReport(['e1']);
|
||||
assert.deepStrictEqual(report.map((r) => r.sessionId), [gone.id]);
|
||||
assert.strictEqual(report[0].entryId, 'e404');
|
||||
assert.strictEqual(report[0].title, '已删', '报告要带标题,否则用户无法判断是否回收');
|
||||
assert.strictEqual(report[0].messageCount, 2);
|
||||
assert.ok(report[0].bytes > 0);
|
||||
assert.ok(!report.some((r) => r.sessionId === global.id), '全局会话不绑书籍,永远不是孤立数据');
|
||||
assert.ok(!report.some((r) => r.sessionId === inLib.id));
|
||||
});
|
||||
|
||||
test('forgetMany 尊重白名单,且显式点名 GLOBAL_ENTRY_ID 也不删', () => {
|
||||
const { sessions } = fresh('forget');
|
||||
const keep = sessions.create({ entryId: 'e1', title: '在库' });
|
||||
const dropA = sessions.create({ entryId: 'e404', title: '已删甲' });
|
||||
const dropB = sessions.create({ entryId: 'e404', title: '已删乙' });
|
||||
const global = sessions.create({ entryId: sessions.GLOBAL_ENTRY_ID, title: '全局' });
|
||||
assert.strictEqual(sessions.forgetMany([]), 0);
|
||||
assert.strictEqual(sessions.forgetMany(['e404', sessions.GLOBAL_ENTRY_ID]), 2);
|
||||
const remaining = sessions.list().map((row) => row.id).sort();
|
||||
assert.deepStrictEqual(remaining, [keep.id, global.id].sort(),
|
||||
'在库条目与全局会话都不该被回收,误删的是用户无法找回的对话');
|
||||
assert.throws(() => sessions.forgetMany(['../escape']), /会话条目 ID 无效/);
|
||||
assert.strictEqual(sessions.list().length, 2, '非法输入不应造成部分删除');
|
||||
});
|
||||
|
||||
test('rebuildIndex 返回索引形状并与 list 一致', () => {
|
||||
const { sessions } = fresh('rebuild');
|
||||
const a = sessions.create({ entryId: 'e1', title: '甲' });
|
||||
round(sessions, a.id, '问', '答');
|
||||
const idx = sessions.rebuildIndex();
|
||||
assert.strictEqual(idx.version, 1);
|
||||
assert.strictEqual(idx.sessions.length, 1);
|
||||
assert.deepStrictEqual(Object.keys(idx.sessions[0]).sort(),
|
||||
['bytes', 'entryId', 'id', 'messageCount', 'pinned', 'title', 'updatedAt'].sort());
|
||||
assert.deepStrictEqual(sessions.list()[0], idx.sessions[0]);
|
||||
});
|
||||
|
||||
test('list 返回的是副本,改动不会污染内存索引', () => {
|
||||
const { sessions } = fresh('clone');
|
||||
const meta = sessions.create({ title: '原标题' });
|
||||
const rows = sessions.list();
|
||||
rows[0].title = '被改坏';
|
||||
assert.strictEqual(sessions.list()[0].title, '原标题');
|
||||
assert.strictEqual(sessions.messages(meta.id).meta.title, '原标题');
|
||||
});
|
||||
+338
-1
@@ -326,7 +326,7 @@ test('OCR-only 契约不要求视觉模型且不会发送图像', async () => {
|
||||
assert.doesNotMatch(body.messages[1].content, /data:image/);
|
||||
});
|
||||
|
||||
test('超长上下文被截断且保留首尾', () => {
|
||||
test('显式调用 clipContext 时中间挖空并保留首尾', () => {
|
||||
const ai = setup();
|
||||
const long = 'A'.repeat(5000) + 'MIDDLE' + 'B'.repeat(5000) + 'TAIL_MARK';
|
||||
const clipped = ai.clipContext(long, 2000);
|
||||
@@ -336,6 +336,49 @@ test('超长上下文被截断且保留首尾', () => {
|
||||
assert.ok(clipped.includes('省略'), '未标注截断');
|
||||
});
|
||||
|
||||
test('正文完整外发,不再按 MAX_CHARS 静默截断', () => {
|
||||
const ai = setup();
|
||||
// 40 页文档实测:旧行为只发出 8 页,其余 32 页静默丢失,而界面仍显示全文字数
|
||||
const body = 'X'.repeat(ai.MAX_CHARS * 4) + 'TAIL_MARK';
|
||||
const msgs = ai.buildMessages('ask', body, '这讲了什么');
|
||||
assert.ok(msgs[1].content.includes(body), '正文被截断了');
|
||||
assert.ok(!msgs[1].content.includes('中间省略'), '不应再自动挖空正文');
|
||||
assert.ok(msgs[1].content.includes('TAIL_MARK'));
|
||||
});
|
||||
|
||||
test('接口报的上下文超限被转成可操作的中文提示', async () => {
|
||||
const ai = setup();
|
||||
for (const [status, raw] of [
|
||||
[400, "This model's maximum context length is 8192 tokens, however you requested 90000 tokens"],
|
||||
[400, 'prompt is too long: 250000 tokens > 200000 maximum'],
|
||||
[413, 'Payload Too Large']
|
||||
]) {
|
||||
h.setHandler(() => streamResponse(JSON.stringify({ error: { message: raw } }), { status }));
|
||||
await assert.rejects(
|
||||
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
|
||||
(err) => {
|
||||
assert.match(err.message, /上下文超出模型窗口/);
|
||||
assert.match(err.message, /范围改小|更大窗口/);
|
||||
return true;
|
||||
},
|
||||
`HTTP ${status} 未被识别为上下文超限`
|
||||
);
|
||||
}
|
||||
|
||||
// 普通错误不应被误判成超限
|
||||
h.setHandler(() => streamResponse(
|
||||
JSON.stringify({ error: { message: 'invalid temperature value' } }),
|
||||
{ status: 400 }
|
||||
));
|
||||
await assert.rejects(
|
||||
() => ai.stream({ task: 'ask', text: '正文', question: '问题' }),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /上下文超出模型窗口/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('不支持的任务类型被拒绝', () => {
|
||||
const ai = setup();
|
||||
assert.throws(() => ai.buildMessages('hack', 'x'), /不支持的任务/);
|
||||
@@ -359,3 +402,297 @@ test('取消请求时抛出 AbortError 而不是静默返回', async () => {
|
||||
(e) => e.name === 'AbortError'
|
||||
);
|
||||
});
|
||||
|
||||
// 多轮对话历史
|
||||
const PROTOCOL_STREAMS = {
|
||||
'chat-completions': () => streamResponse(sseBody(['答'])),
|
||||
anthropic: () => streamResponse(
|
||||
`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: '答' } })}\n\n`
|
||||
+ `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`
|
||||
),
|
||||
'openai-responses': () => streamResponse(
|
||||
`data: ${JSON.stringify({ type: 'response.output_text.delta', delta: '答' })}\n\n`
|
||||
+ `data: ${JSON.stringify({ type: 'response.completed' })}\n\n`
|
||||
)
|
||||
};
|
||||
|
||||
async function captureBody(protocol, args, options) {
|
||||
const ai = setup({ protocol, ...options });
|
||||
let body = null;
|
||||
h.setHandler((_url, opts) => {
|
||||
body = JSON.parse(opts.body);
|
||||
return PROTOCOL_STREAMS[protocol]();
|
||||
});
|
||||
await ai.stream(args);
|
||||
return body;
|
||||
}
|
||||
|
||||
// history 缺省时请求体必须与单轮时代逐字节一致,否则等于悄悄改了单轮行为
|
||||
test('未传 history 时三种协议请求体与单轮完全一致', async () => {
|
||||
// 写死单轮的 user 正文,只比对"传与不传 history"两次结果会同时被同一个 bug 污染
|
||||
const expectedUser = '文档片段:\n"""\n正文\n"""\n\n问题:问题';
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const base = await captureBody(protocol, { task: 'ask', text: '正文', question: '问题' });
|
||||
if (protocol === 'chat-completions') {
|
||||
assert.strictEqual(base.messages.length, 2, '单轮只该有 system + user');
|
||||
assert.strictEqual(base.messages[1].content, expectedUser);
|
||||
} else if (protocol === 'anthropic') {
|
||||
assert.strictEqual(base.messages.length, 1, '单轮只该有一条 user');
|
||||
assert.strictEqual(base.messages[0].content, expectedUser);
|
||||
} else {
|
||||
assert.strictEqual(base.input.length, 1, '单轮只该有一条 user');
|
||||
assert.deepStrictEqual(base.input[0].content, [{ type: 'input_text', text: expectedUser }]);
|
||||
}
|
||||
for (const history of [undefined, null, [], 'not-an-array', {}]) {
|
||||
const withArg = await captureBody(
|
||||
protocol,
|
||||
{ task: 'ask', text: '正文', question: '问题', history }
|
||||
);
|
||||
assert.strictEqual(
|
||||
JSON.stringify(withArg),
|
||||
JSON.stringify(base),
|
||||
`${protocol} 的空 history 改变了请求体(history=${JSON.stringify(history)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('chat-completions 把历史插在 system 之后、当前轮之前', async () => {
|
||||
const body = await captureBody('chat-completions', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '第三个问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '第一个问题' },
|
||||
{ id: 'b', role: 'assistant', text: '第一个回答' },
|
||||
{ id: 'c', role: 'user', text: '第二个问题' },
|
||||
{ id: 'd', role: 'assistant', text: '第二个回答' }
|
||||
]
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
body.messages.map((m) => m.role),
|
||||
['system', 'user', 'assistant', 'user', 'assistant', 'user'],
|
||||
'历史必须在 messages 里,且当前轮排最后'
|
||||
);
|
||||
assert.strictEqual(body.messages[1].content, '第一个问题');
|
||||
assert.strictEqual(body.messages[2].content, '第一个回答');
|
||||
assert.strictEqual(body.messages[3].content, '第二个问题');
|
||||
assert.strictEqual(body.messages[4].content, '第二个回答');
|
||||
assert.match(body.messages[5].content, /第三个问题/, '当前轮问题丢失');
|
||||
assert.match(body.messages[5].content, /正文/, '当前轮正文丢失');
|
||||
assert.ok(
|
||||
!/第一个问题/.test(body.messages[0].content),
|
||||
'历史不该被塞进 system,模型会把它当成指令'
|
||||
);
|
||||
});
|
||||
|
||||
test('anthropic 历史进 messages,system 仍在顶层', async () => {
|
||||
const body = await captureBody('anthropic', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '新问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '旧问题' },
|
||||
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||
]
|
||||
});
|
||||
assert.strictEqual(typeof body.system, 'string');
|
||||
assert.ok(!/旧问题|旧回答/.test(body.system), 'Anthropic 的 system 是顶层字段,历史不该混进去');
|
||||
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||
assert.strictEqual(body.messages[0].content, '旧问题');
|
||||
assert.strictEqual(body.messages[1].content, '旧回答');
|
||||
assert.match(body.messages[2].content, /新问题/);
|
||||
assert.ok(!body.messages.some((m) => m.role === 'system'), 'system 不能出现在 messages 里');
|
||||
});
|
||||
|
||||
test('openai-responses 历史进 input,instructions 与 store:false 保持', async () => {
|
||||
const body = await captureBody('openai-responses', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '新问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '旧问题' },
|
||||
{ id: 'b', role: 'assistant', text: '旧回答' }
|
||||
]
|
||||
});
|
||||
assert.strictEqual(typeof body.instructions, 'string');
|
||||
assert.ok(!/旧问题|旧回答/.test(body.instructions), 'instructions 是顶层字段,历史不该混进去');
|
||||
assert.strictEqual(body.store, false, 'store 必须保持 false,服务端不留存对话');
|
||||
assert.ok(!('previous_response_id' in body), '多轮不能靠服务端留存,与 store:false 冲突');
|
||||
assert.deepStrictEqual(body.input.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||
// 纯字符串是 Responses 输入消息的合法简写,避开 input_text 不接受 assistant 的限制
|
||||
assert.strictEqual(body.input[0].content, '旧问题');
|
||||
assert.strictEqual(body.input[1].content, '旧回答');
|
||||
assert.ok(Array.isArray(body.input[2].content), '当前轮仍用结构化 content');
|
||||
assert.strictEqual(body.input[2].content[0].type, 'input_text');
|
||||
assert.match(body.input[2].content[0].text, /新问题/);
|
||||
});
|
||||
|
||||
test('历史里的图像不被重发,只发当前轮的图', async () => {
|
||||
const image = visualContext().image;
|
||||
const history = [
|
||||
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||
];
|
||||
const args = {
|
||||
task: 'ask',
|
||||
text: '',
|
||||
question: '这张呢',
|
||||
visualContexts: [visualContext()],
|
||||
history
|
||||
};
|
||||
|
||||
const chat = await captureBody('chat-completions', args, { vision: true });
|
||||
const chatImages = chat.messages.flatMap(
|
||||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image_url') : [])
|
||||
);
|
||||
assert.strictEqual(chatImages.length, 1, '历史图像被重发了,长会话费用会随轮数累积');
|
||||
|
||||
const anthropic = await captureBody('anthropic', args, { vision: true });
|
||||
const anthropicImages = anthropic.messages.flatMap(
|
||||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'image') : [])
|
||||
);
|
||||
assert.strictEqual(anthropicImages.length, 1, '历史图像被重发了');
|
||||
|
||||
const responses = await captureBody('openai-responses', args, { vision: true });
|
||||
const responseImages = responses.input.flatMap(
|
||||
(m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'input_image') : [])
|
||||
);
|
||||
assert.strictEqual(responseImages.length, 1, '历史图像被重发了');
|
||||
|
||||
const historyJson = JSON.stringify(history);
|
||||
assert.strictEqual(historyJson, JSON.stringify([
|
||||
{ id: 'a', role: 'user', text: '看这张图', images: [image, image] },
|
||||
{ id: 'b', role: 'assistant', text: '看到了' }
|
||||
]), '不该原地改写调用方传进来的历史数组');
|
||||
});
|
||||
|
||||
test('anthropic 历史领头是 assistant 时首条仍是 user', async () => {
|
||||
const body = await captureBody('anthropic', {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '新问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'assistant', text: '孤立的开场回答' },
|
||||
{ id: 'b', role: 'user', text: '真正的第一问' },
|
||||
{ id: 'c', role: 'assistant', text: '第一答' }
|
||||
]
|
||||
});
|
||||
// Anthropic 的 /messages 直接 400 拒绝领头 assistant
|
||||
assert.strictEqual(body.messages[0].role, 'user', '首条必须是 user,否则 Anthropic 直接 400');
|
||||
assert.deepStrictEqual(body.messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
||||
assert.strictEqual(body.messages[0].content, '真正的第一问');
|
||||
});
|
||||
|
||||
test('历史末条是 user 时与当前轮合并,两段文本都保留', async () => {
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '当前问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '上一问' },
|
||||
{ id: 'b', role: 'assistant', text: '上一答' },
|
||||
{ id: 'c', role: 'user', text: '没等到回答的追问' }
|
||||
]
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||
for (let i = 1; i < roles.length; i++) {
|
||||
assert.notStrictEqual(roles[i], roles[i - 1], `${protocol} 出现相邻同角色,Anthropic 会直接 400`);
|
||||
}
|
||||
const flat = JSON.stringify(items);
|
||||
assert.match(flat, /没等到回答的追问/, `${protocol} 静默丢弃了用户内容`);
|
||||
assert.match(flat, /当前问题/, `${protocol} 当前轮问题丢失`);
|
||||
}
|
||||
});
|
||||
|
||||
test('历史内部相邻同角色被合并而不是丢弃', async () => {
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '当前问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '连问一' },
|
||||
{ id: 'b', role: 'user', text: '连问二' },
|
||||
{ id: 'c', role: 'assistant', text: '连答一' },
|
||||
{ id: 'd', role: 'assistant', text: '连答二' }
|
||||
]
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||
assert.deepStrictEqual(
|
||||
roles,
|
||||
['user', 'assistant', 'user'],
|
||||
`${protocol} 未合并相邻同角色,Anthropic 会直接 400`
|
||||
);
|
||||
const flat = JSON.stringify(items);
|
||||
for (const mark of ['连问一', '连问二', '连答一', '连答二', '当前问题']) {
|
||||
assert.match(flat, new RegExp(mark), `${protocol} 静默丢弃了 ${mark}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('历史含空文本时不产生空 content', async () => {
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '当前问题',
|
||||
history: [
|
||||
{ id: 'a', role: 'user', text: '有效提问' },
|
||||
{ id: 'b', role: 'assistant', text: ' ' },
|
||||
{ id: 'c', role: 'assistant', text: '' },
|
||||
{ id: 'd', role: 'user', text: null },
|
||||
{ id: 'e', role: 'assistant', text: '有效回答' },
|
||||
null
|
||||
]
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
for (const item of items) {
|
||||
const text = typeof item.content === 'string'
|
||||
? item.content
|
||||
: JSON.stringify(item.content);
|
||||
assert.ok(text && text.trim(), `${protocol} 出现空 content,Anthropic 不接受空字符串`);
|
||||
}
|
||||
const roles = items.map((m) => m.role).filter((r) => r !== 'system');
|
||||
assert.deepStrictEqual(roles, ['user', 'assistant', 'user'], `${protocol} 空消息未被过滤干净`);
|
||||
}
|
||||
});
|
||||
|
||||
test('历史按旧到新排列,当前轮在最后', async () => {
|
||||
const history = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
history.push({ id: `u${i}`, role: 'user', text: `问题${i}` });
|
||||
history.push({ id: `a${i}`, role: 'assistant', text: `回答${i}` });
|
||||
}
|
||||
for (const protocol of Object.keys(PROTOCOL_STREAMS)) {
|
||||
const body = await captureBody(protocol, {
|
||||
task: 'ask',
|
||||
text: '正文',
|
||||
question: '问题4',
|
||||
history
|
||||
});
|
||||
const items = protocol === 'openai-responses' ? body.input : body.messages;
|
||||
const flat = items.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
|
||||
const order = ['问题1', '回答1', '问题2', '回答2', '问题3', '回答3', '问题4']
|
||||
.map((mark) => flat.findIndex((s) => s.includes(mark)));
|
||||
assert.ok(order.every((i) => i >= 0), `${protocol} 有历史轮次丢失`);
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
assert.ok(order[i] > order[i - 1], `${protocol} 历史顺序颠倒,模型会读到倒序对话`);
|
||||
}
|
||||
assert.strictEqual(order[order.length - 1], flat.length - 1, `${protocol} 当前轮不在最后`);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildMessages 也接受历史参数', () => {
|
||||
const ai = setup();
|
||||
const msgs = ai.buildMessages('ask', '正文', '新问题', [], [
|
||||
{ role: 'user', text: '旧问题' },
|
||||
{ role: 'assistant', text: '旧回答' }
|
||||
]);
|
||||
assert.deepStrictEqual(msgs.map((m) => m.role), ['system', 'user', 'assistant', 'user']);
|
||||
assert.strictEqual(msgs[1].content, '旧问题');
|
||||
assert.match(msgs[3].content, /新问题/);
|
||||
});
|
||||
|
||||
@@ -151,6 +151,94 @@ test('主文件损坏时优先从原子写入备份恢复', () => {
|
||||
assert.strictEqual(store.get('book', key('a.pdf')).pages['1'].objects[0].type, 'Rect');
|
||||
});
|
||||
|
||||
test('批注计数统计对象总数,随内容变化失效', () => {
|
||||
const { store, dir } = fresh();
|
||||
const k = key('a.pdf');
|
||||
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }, { type: 'Path' }] });
|
||||
store.setPage('book', k, 4, { objects: [{ type: 'IText' }] });
|
||||
// 是对象总数而非批注页数
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 3 });
|
||||
|
||||
// mtime + size 缓存必须在内容变化后失效
|
||||
store.setPage('book', k, 4, { objects: [] });
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
store.setPage('book', k, 1, { objects: [] });
|
||||
assert.deepStrictEqual(store.getCounts(), {});
|
||||
|
||||
// 同一条目的不同文档版本累加
|
||||
store.setPage('book', k, 1, { objects: [{ type: 'Rect' }] });
|
||||
store.setPage('book', key('b.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
|
||||
// 损坏文件计 0 而不是抛错,否则整个书库都拿不到计数
|
||||
fs.writeFileSync(path.join(dir, 'reader-annotations', 'broken.json'), '{ bad');
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
|
||||
store.forget('book');
|
||||
assert.deepStrictEqual(store.getCounts(), {});
|
||||
});
|
||||
|
||||
test('删除后重建同名批注文件不会命中旧缓存', () => {
|
||||
const { store, dir } = fresh();
|
||||
const folder = path.join(dir, 'reader-annotations');
|
||||
const file = path.join(folder, 'book.json');
|
||||
const k = key('a.pdf');
|
||||
store.setPage('book', k, 1, { objects: [{ t: 'aa' }, { t: 'bb' }] });
|
||||
// 固定到整毫秒,否则 utimesSync 无法精确复现原 mtime
|
||||
const pinned = new Date(1700000000000);
|
||||
fs.utimesSync(file, pinned, pinned);
|
||||
const stale = fs.statSync(file);
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 2 });
|
||||
|
||||
// 构造与旧文件同尺寸、同 mtime 但内容不同的文件:
|
||||
// 缓存键只有 mtime+size,forget 不清缓存就会返回过期的 2
|
||||
store.forget('book');
|
||||
const rebuilt = JSON.parse(JSON.stringify({
|
||||
version: 1,
|
||||
entryId: 'book',
|
||||
documents: { [k]: { pages: { 1: { objects: [{ t: 'aa' }], updatedAt: 0 } }, updatedAt: 0 } }
|
||||
}));
|
||||
let text = JSON.stringify(rebuilt, null, 2);
|
||||
assert.ok(text.length < stale.size, '重建内容应短于原文件才能补齐到同尺寸');
|
||||
rebuilt.documents[k].pages['1'].objects[0].t = 'aa'.padEnd(2 + (stale.size - text.length), 'z');
|
||||
text = JSON.stringify(rebuilt, null, 2);
|
||||
assert.strictEqual(Buffer.byteLength(text, 'utf8'), stale.size, '需精确构造同尺寸文件');
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
fs.writeFileSync(file, text, 'utf8');
|
||||
fs.utimesSync(file, pinned, pinned);
|
||||
assert.strictEqual(fs.statSync(file).mtimeMs, stale.mtimeMs, '需精确复现同 mtime');
|
||||
assert.strictEqual(fs.statSync(file).size, stale.size, '需精确复现同尺寸');
|
||||
|
||||
assert.deepStrictEqual(store.getCounts(), { book: 1 });
|
||||
});
|
||||
|
||||
test('孤立批注按体积报告并可批量回收', () => {
|
||||
const { store, dir } = fresh();
|
||||
const k = key('a.pdf');
|
||||
store.setPage('kept', k, 1, { objects: [{ t: 'Rect' }] });
|
||||
store.setPage('gone_a', k, 1, { objects: [{ t: 'Rect' }, { t: 'Path' }] });
|
||||
store.setPage('gone_b', k, 1, { objects: [{ t: 'IText' }] });
|
||||
|
||||
const orphans = store.orphanReport(['kept']);
|
||||
assert.deepStrictEqual(orphans.map((o) => o.entryId).sort(), ['gone_a', 'gone_b']);
|
||||
assert.ok(orphans.every((o) => o.bytes > 0), '需报告体积供用户判断');
|
||||
assert.strictEqual(orphans.find((o) => o.entryId === 'gone_a').count, 2);
|
||||
|
||||
// 仍在书库的条目绝不能出现在回收清单里
|
||||
assert.ok(!orphans.some((o) => o.entryId === 'kept'));
|
||||
|
||||
assert.strictEqual(store.forgetMany(orphans.map((o) => o.entryId)), 2);
|
||||
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'gone_a.json')), false);
|
||||
assert.strictEqual(fs.existsSync(path.join(dir, 'reader-annotations', 'kept.json')), true);
|
||||
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||
assert.deepStrictEqual(store.orphanReport(['kept']), []);
|
||||
|
||||
// 传入非法 ID 不应中断其余回收
|
||||
store.setPage('gone_c', k, 1, { objects: [{ t: 'Rect' }] });
|
||||
assert.strictEqual(store.forgetMany(['../escape', 'gone_c']), 1);
|
||||
assert.deepStrictEqual(store.getCounts(), { kept: 1 });
|
||||
});
|
||||
|
||||
test('forget 删除条目批注及备份残留', () => {
|
||||
const { store, dir } = fresh();
|
||||
store.setPage('book', key('a.pdf'), 1, { objects: [{ type: 'Rect' }] });
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const atomic = require('../atomic-file');
|
||||
const dirs = [];
|
||||
|
||||
function fresh(tag) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-atomic-${tag}-`));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
for (const dir of dirs) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('元数据写入在改名前 fsync 数据', () => {
|
||||
const dir = fresh('fsync');
|
||||
const dest = path.join(dir, 'meta.json');
|
||||
const order = [];
|
||||
const realFsync = fs.fsyncSync;
|
||||
const realRename = fs.renameSync;
|
||||
fs.fsyncSync = function tracked(fd) {
|
||||
order.push('fsync');
|
||||
return realFsync.call(this, fd);
|
||||
};
|
||||
fs.renameSync = function tracked(from, to) {
|
||||
order.push(`rename:${path.basename(String(from))}->${path.basename(String(to))}`);
|
||||
return realRename.call(this, from, to);
|
||||
};
|
||||
try {
|
||||
atomic.writeJson(dest, { hello: '世界' });
|
||||
} finally {
|
||||
fs.fsyncSync = realFsync;
|
||||
fs.renameSync = realRename;
|
||||
}
|
||||
// rename 只保证目录项替换原子,不保证数据已落盘;
|
||||
// 因此临时文件的 fsync 必须发生在改名之前
|
||||
const firstRename = order.findIndex((step) => step.startsWith('rename:'));
|
||||
assert.ok(firstRename > 0, `改名前应先 fsync,实际顺序 ${order.join(',')}`);
|
||||
assert.strictEqual(order[0], 'fsync');
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { hello: '世界' });
|
||||
});
|
||||
|
||||
test('写入失败时清理临时文件并把备份换回原位', () => {
|
||||
const dir = fresh('rollback');
|
||||
const dest = path.join(dir, 'meta.json');
|
||||
atomic.writeJson(dest, { round: 1 });
|
||||
const before = fs.readFileSync(dest, 'utf8');
|
||||
|
||||
const realRename = fs.renameSync;
|
||||
let failed = false;
|
||||
fs.renameSync = function failing(from, to) {
|
||||
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
|
||||
failed = true;
|
||||
throw new Error('模拟替换失败');
|
||||
}
|
||||
return realRename.apply(this, arguments);
|
||||
};
|
||||
try {
|
||||
assert.throws(() => atomic.writeJson(dest, { round: 2 }), /模拟替换失败/);
|
||||
} finally {
|
||||
fs.renameSync = realRename;
|
||||
}
|
||||
assert.ok(failed);
|
||||
assert.strictEqual(fs.readFileSync(dest, 'utf8'), before);
|
||||
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
|
||||
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
|
||||
});
|
||||
|
||||
test('目录 fsync 失败不影响写入结果', () => {
|
||||
const dir = fresh('dirfail');
|
||||
const dest = path.join(dir, 'meta.json');
|
||||
const realOpen = fs.openSync;
|
||||
// Windows 无法对目录取句柄,这条路径必须容错
|
||||
fs.openSync = function guarded(target, flags, ...rest) {
|
||||
if (String(target) === dir) throw new Error('EISDIR');
|
||||
return realOpen.call(this, target, flags, ...rest);
|
||||
};
|
||||
try {
|
||||
atomic.writeJson(dest, { ok: true });
|
||||
} finally {
|
||||
fs.openSync = realOpen;
|
||||
}
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
|
||||
});
|
||||
@@ -168,8 +168,8 @@ app.whenReady().then(async () => {
|
||||
"document.getElementById('aiConfirmScope').textContent+' '+document.getElementById('aiConfirmCost').textContent"
|
||||
));
|
||||
chk('确认框包含范围、字数与 token 估算', /全文/.test(summary) && /字/.test(summary) && /tokens/.test(summary), summary);
|
||||
chk('确认框明确警告全文可能超限',
|
||||
/可能超过模型的上下文限制/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
|
||||
chk('确认框说明全文完整发送且超限由接口报错',
|
||||
/完整发送/.test(String(await js("document.getElementById('aiConfirmNotice').textContent"))));
|
||||
chk('确认框使用应用按钮而非原生弹窗',
|
||||
(await js("document.getElementById('aiConfirmSendBtn').textContent.trim()")) === '继续发送');
|
||||
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
||||
@@ -193,7 +193,12 @@ app.whenReady().then(async () => {
|
||||
await new Promise((r) => setTimeout(r, 3820));
|
||||
chk('用户同意后发送且仅一次', received.length === 1, '请求数=' + received.length);
|
||||
chk('外发内容为全文正文', charsOf(received[0]) > 1000, '字符=' + charsOf(received[0]));
|
||||
chk('超长全文按上限截断后才外发', charsOf(received[0]) <= 12000 + 2000, '字符=' + charsOf(received[0]));
|
||||
// 界面告知的字数必须等于真正离开进程的字数。旧行为在这里砍掉 80% 正文却仍显示全文字数
|
||||
chk('外发字数与界面告知一致,正文未被静默截断',
|
||||
charsOf(received[0]) >= docChars,
|
||||
`外发=${charsOf(received[0])} 界面告知=${docChars}`);
|
||||
chk('外发正文不含本地截断标记',
|
||||
!JSON.stringify(received[0]).includes('中间省略'));
|
||||
chk('AI 回答使用成熟 Markdown 结构渲染', await js(`(() => {
|
||||
const output = document.getElementById('aiOutput');
|
||||
return output.querySelector('h1')?.textContent === '回答'
|
||||
@@ -245,7 +250,8 @@ app.whenReady().then(async () => {
|
||||
})()`));
|
||||
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const pageContent = received[1] && received[1].messages && received[1].messages[1].content;
|
||||
// 多轮之后本轮提问固定在末尾,历史占据中间位置,因此不能按固定下标取
|
||||
const pageContent = received[1] && received[1].messages && received[1].messages.at(-1).content;
|
||||
const pageImage = Array.isArray(pageContent)
|
||||
? pageContent.find((part) => part && part.type === 'image_url')
|
||||
: null;
|
||||
@@ -328,13 +334,72 @@ app.whenReady().then(async () => {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await js("document.getElementById('aiConfirmSendBtn').click()");
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const regionContent = received[2] && received[2].messages && received[2].messages[1].content;
|
||||
const regionContent = received[2] && received[2].messages && received[2].messages.at(-1).content;
|
||||
const regionImage = Array.isArray(regionContent)
|
||||
? regionContent.find((part) => part && part.type === 'image_url')
|
||||
: null;
|
||||
chk('框选区域作为单张图像上下文发送', received.length === 3
|
||||
&& /^data:image\/jpeg;base64,/.test(regionImage?.image_url?.url || ''));
|
||||
|
||||
// 同一会话的第三轮:前两轮必须作为历史外发,且历史里不能夹带图像
|
||||
const regionMessages = (received[2] && received[2].messages) || [];
|
||||
chk('多轮对话把前几轮问答作为历史发送',
|
||||
regionMessages.length >= 4
|
||||
&& regionMessages[0].role === 'system'
|
||||
&& regionMessages.at(-1).role === 'user'
|
||||
&& regionMessages.slice(1, -1).some((m) => m.role === 'assistant'),
|
||||
regionMessages.map((m) => m.role).join(','));
|
||||
chk('历史消息只带文本,不重复上传图像',
|
||||
regionMessages.slice(0, -1).every((m) => typeof m.content === 'string'),
|
||||
regionMessages.map((m) => (typeof m.content === 'string' ? 'str' : 'arr')).join(','));
|
||||
|
||||
// 会话必须落盘:关掉窗口再开回来,历史消息与会话列表都应原样恢复
|
||||
const diskSessions = require(path.join(ROOT, 'src', 'reader', 'ai-sessions'));
|
||||
const storedList = diskSessions.list({ entryId: e.id });
|
||||
const storedId = storedList[0] && storedList[0].id;
|
||||
const storedMessages = storedId ? diskSessions.messages(storedId, { limit: 100 }).messages : [];
|
||||
chk('多轮问答持久化到磁盘会话',
|
||||
storedList.length === 1 && storedMessages.length === 6
|
||||
&& storedMessages.filter((m) => m.role === 'user').length === 3
|
||||
&& storedMessages.filter((m) => m.role === 'assistant').length === 3,
|
||||
`会话=${storedList.length} 消息=${storedMessages.length}`);
|
||||
// 存的是用户看见的那句提问,不是整篇正文,否则重开后气泡会变成十几万字原文
|
||||
chk('会话只存提问本身,正文以 contextRef 摘要记录',
|
||||
storedMessages[0].role === 'user'
|
||||
&& storedMessages[0].text === '这章讲了什么'
|
||||
&& storedMessages[0].contextRef?.scope === 'document'
|
||||
&& storedMessages[0].contextRef.chars >= docChars
|
||||
&& storedMessages[0].contextRef.hash.length === 32,
|
||||
`${storedMessages[0].text.slice(0, 20)} / ${storedMessages[0].contextRef?.chars}`);
|
||||
chk('历史图像以 imageId 引用而非内联 base64',
|
||||
storedMessages.filter((m) => m.images.length).every((m) => m.images.every(
|
||||
(img) => /^img_[0-9a-f]{64}$/.test(img.imageId) && img.base64 === undefined
|
||||
)),
|
||||
JSON.stringify(storedMessages.map((m) => m.images.map((i) => i.imageId.slice(0, 12)))));
|
||||
|
||||
const reopened = new BrowserWindow({
|
||||
show: false, width: 1200, height: 860,
|
||||
webPreferences: { preload: path.join(ROOT, 'preload.js'), contextIsolation: true, nodeIntegration: false }
|
||||
});
|
||||
await reopened.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), { query: { entryId: e.id } });
|
||||
await new Promise((r) => setTimeout(r, 9000));
|
||||
const reopenedJs = (code) => reopened.webContents.executeJavaScript(code);
|
||||
await reopenedJs("document.querySelector('[data-pane=\"ai\"]').click()");
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const restored = await reopenedJs(`(() => ({
|
||||
bubbles: document.querySelectorAll('#aiOutput .ai-msg').length,
|
||||
first: (document.querySelector('#aiOutput .ai-msg') || {}).textContent || '',
|
||||
options: document.getElementById('aiSessionSelect').options.length
|
||||
}))()`);
|
||||
chk('重开阅读器恢复会话与历史气泡',
|
||||
restored.bubbles === 6 && restored.options === 1 && restored.first.includes('这章讲了什么'),
|
||||
`气泡=${restored.bubbles} 会话=${restored.options}`);
|
||||
const requestsBeforeReopen = received.length;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
chk('恢复历史不会重新调用模型', received.length === requestsBeforeReopen,
|
||||
`${requestsBeforeReopen} -> ${received.length}`);
|
||||
reopened.destroy();
|
||||
|
||||
chk('超大回答降级为纯文本以限制解析开销', await js(`(() => {
|
||||
const output = document.getElementById('aiOutput');
|
||||
const text = 'x'.repeat(256 * 1024 + 1);
|
||||
@@ -433,4 +498,8 @@ app.whenReady().then(async () => {
|
||||
console.log(`\n通过 ${results.length - bad}/${results.length}`);
|
||||
server.close();
|
||||
app.exit(bad ? 1 : 0);
|
||||
}).catch((e) => { console.error('异常:', e); app.exit(1); });
|
||||
}).catch((e) => {
|
||||
console.error('异常:', e);
|
||||
for (const [s, n, x] of results) console.log(`${s.padEnd(5)} ${n}${x ? ' [' + x + ']' : ''}`);
|
||||
app.exit(1);
|
||||
});
|
||||
|
||||
@@ -411,6 +411,46 @@ app.whenReady().then(async () => {
|
||||
check('首次窗口无渲染错误', first.errors.length === 0, first.errors.slice(0, 2).join(' | '));
|
||||
check('重开窗口无渲染错误', second.errors.length === 0, second.errors.slice(0, 2).join(' | '));
|
||||
|
||||
// 书库卡片上的批注计数必须来自真实落盘的批注,而不是渲染层自己数的
|
||||
const storedCount = annotations.getCounts()[String(entry.id)] || 0;
|
||||
check('批注已落盘并可计数', storedCount > 0, `count=${storedCount}`);
|
||||
const libraryWindow = BrowserWindow.getAllWindows()
|
||||
.find((w) => !w.isDestroyed() && /index\.html/.test(w.webContents.getURL()));
|
||||
check('存在书库窗口', !!libraryWindow);
|
||||
if (libraryWindow) {
|
||||
libraryWindow.show();
|
||||
await js(libraryWindow, `(async () => {
|
||||
document.querySelector('[data-tab="library"]').click();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
document.querySelector('[data-tab="notes"]').click();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
document.querySelector('[data-tab="library"]').click();
|
||||
await new Promise((r) => setTimeout(r, 1800));
|
||||
})()`);
|
||||
const badge = await js(libraryWindow, `(() => {
|
||||
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
|
||||
if (!card) return { missing: true };
|
||||
const cover = card.querySelector('.card-cover').getBoundingClientRect();
|
||||
const annot = card.querySelector('.card-badge.annotation-count');
|
||||
const end = card.querySelector('.card-cover-badges.end');
|
||||
const start = card.querySelector('.card-cover-badges.start');
|
||||
if (!annot) return { noBadge: true };
|
||||
const ar = annot.getBoundingClientRect();
|
||||
const sr = start.getBoundingClientRect();
|
||||
return {
|
||||
text: annot.textContent.trim(),
|
||||
inEnd: end.contains(annot),
|
||||
insideCover: ar.right <= cover.right + 0.5 && ar.bottom <= cover.bottom + 0.5,
|
||||
rightOfStatus: ar.left >= sr.right - 0.5,
|
||||
statusText: start.textContent.trim()
|
||||
};
|
||||
})()`);
|
||||
check('书库卡片显示与落盘一致的批注数',
|
||||
badge.text === `批注 ${storedCount}`, JSON.stringify(badge));
|
||||
check('批注标识在封面右下角,不与左下角状态重叠',
|
||||
badge.inEnd && badge.insideCover && badge.rightOfStatus, JSON.stringify(badge));
|
||||
}
|
||||
|
||||
const captureDir = process.env.PEOPLELIB_CAPTURE_DIR || TMP;
|
||||
fs.writeFileSync(path.join(captureDir, 'pdf-annotations.png'), (await second.win.webContents.capturePage()).toPNG());
|
||||
|
||||
|
||||
@@ -59,17 +59,20 @@ async function js(source) {
|
||||
return win.webContents.executeJavaScript(source);
|
||||
}
|
||||
|
||||
async function poll(name, predicate, timeout = 8000) {
|
||||
// soft=true 时超时不抛也不记失败,只返回 false,交给调用方自己断言,
|
||||
// 这样失败信息里能带上真实量到的状态而不是一句"等待超时"
|
||||
async function poll(name, predicate, timeout = 8000, soft = false) {
|
||||
const deadline = Date.now() + timeout;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await predicate()) return;
|
||||
if (await predicate()) return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await wait(50);
|
||||
}
|
||||
if (soft) return false;
|
||||
const detail = lastError ? lastError.message : '等待超时';
|
||||
check(name, false, detail);
|
||||
throw new Error(`${name}: ${detail}`);
|
||||
@@ -159,6 +162,7 @@ app.whenReady().then(async () => {
|
||||
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||||
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
||||
const noteAssets = require(path.join(ROOT, 'src', 'reader', 'note-assets'));
|
||||
const noteWindow = require(path.join(ROOT, 'src', 'reader', 'note-window'));
|
||||
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||||
const zlibAuth = require(path.join(ROOT, 'src', 'sources', 'zlib-auth'));
|
||||
const semanticKey = require(path.join(ROOT, 'src', 'sources', 'semantic-key'));
|
||||
@@ -481,13 +485,28 @@ app.whenReady().then(async () => {
|
||||
)) || null;
|
||||
return !!readerWindow;
|
||||
});
|
||||
await poll('封面打开的 PDF 在内置阅读器渲染', async () => (
|
||||
// 这本书的文件顺序是 [txt, pdf],封面走的是"第一个可阅读文件",
|
||||
// 也就是 txt(txt/md 同样能内置阅读,走 text-adapter 转 epub 渲染)。
|
||||
// 这里断言"渲染出正文",不要写死 PDF 画布:那样等于把
|
||||
// "txt 不可阅读所以退到 pdf" 这个旧缺陷当成期望行为锁死
|
||||
await poll('封面打开的书在内置阅读器渲染出正文', async () => (
|
||||
!readerWindow.isDestroyed()
|
||||
&& readerWindow.webContents.executeJavaScript(
|
||||
"document.querySelector('.pdfx-page[data-page=\"1\"] .pdfx-canvas')?.width > 0"
|
||||
)
|
||||
), 15000);
|
||||
&& readerWindow.webContents.executeJavaScript(`(() => {
|
||||
if (document.querySelector('.doc-overlay.err')) return false;
|
||||
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||
if (canvas && canvas.width > 0) return true;
|
||||
const frame = document.querySelector('.host-epub iframe');
|
||||
const body = frame && frame.contentDocument && frame.contentDocument.body;
|
||||
return !!(body && body.textContent.trim().length > 0);
|
||||
})()`)
|
||||
), 20000);
|
||||
check('点击可阅读图书封面直接打开内置阅读器', !!readerWindow);
|
||||
check(
|
||||
'封面打开的是第一个可阅读文件(txt 也算)',
|
||||
(await readerWindow.webContents.executeJavaScript(
|
||||
"new URLSearchParams(location.search).get('fileIndex')"
|
||||
)) === '0'
|
||||
);
|
||||
readerWindow.destroy();
|
||||
await wait(200);
|
||||
check(
|
||||
@@ -1589,6 +1608,292 @@ app.whenReady().then(async () => {
|
||||
);
|
||||
dialog.showOpenDialog = originalShowOpenDialog;
|
||||
|
||||
// --- 笔记独立窗口 ---
|
||||
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
|
||||
await pollJs('笔记页有可开窗的卡片', "document.querySelectorAll('#notesList .note-card').length > 0");
|
||||
const windowNote = readerStore.listNotes({}).find((note) => note.associated !== false);
|
||||
check('存在可用于开窗的笔记', !!windowNote);
|
||||
|
||||
// 只数笔记窗口。总窗口数会被阅读器窗口的开关干扰,
|
||||
// 之前用总数当基线,阅读器中途关掉就把「没多开窗」误判成失败
|
||||
const noteWindowCount = () => BrowserWindow.getAllWindows()
|
||||
.filter((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'))
|
||||
.length;
|
||||
check('开窗前没有笔记窗口', noteWindowCount() === 0, `笔记窗口数=${noteWindowCount()}`);
|
||||
// 按钮必须限定在目标笔记那张卡片内:笔记页此时有多张卡片,
|
||||
// 全局找「编辑」会命中别的卡片,断言就变成了自欺欺人
|
||||
const cardScript = (inner) => `(() => {
|
||||
const target = document.querySelector('#notesList .note-card[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||
if (!target) throw new Error('找不到目标笔记卡片');
|
||||
${inner}
|
||||
})()`;
|
||||
const cardLabels = () => js(cardScript(
|
||||
'return [...target.querySelectorAll(".note-action")].map((b) => b.textContent).join(",");'
|
||||
));
|
||||
const clickCardAction = (label) => js(cardScript(`
|
||||
const button = [...target.querySelectorAll(".note-action")]
|
||||
.find((item) => item.textContent === ${JSON.stringify(label)});
|
||||
if (!button) throw new Error("找不到按钮:" + ${JSON.stringify(label)});
|
||||
button.click();
|
||||
return true;
|
||||
`));
|
||||
|
||||
await clickCardAction('独立窗口');
|
||||
// 窗口创建与 URL 就位之间有间隔,刚建好时 getURL() 还是空串,必须轮询
|
||||
const findNoteWindow = () => BrowserWindow.getAllWindows()
|
||||
.find((item) => !item.isDestroyed() && String(item.webContents.getURL()).includes('note.html'));
|
||||
await poll(
|
||||
'点击独立窗口后真的多出一个笔记窗口',
|
||||
async () => noteWindowCount() === 1 && !!findNoteWindow(),
|
||||
15000
|
||||
);
|
||||
const noteWin = findNoteWindow();
|
||||
check('新窗口加载的是笔记页面', !!noteWin);
|
||||
const noteJs = (source) => noteWin.webContents.executeJavaScript(source);
|
||||
// 多标签后表单是每个标签一份,查询必须限定在当前激活的那个视图内,
|
||||
// 否则回收/切换过程中会量到别的标签
|
||||
const activeScript = (inner) => `(() => {
|
||||
const view = [...document.querySelectorAll('.note-tab-view')]
|
||||
.find((item) => !item.classList.contains('inactive'));
|
||||
if (!view) throw new Error('没有激活的笔记标签');
|
||||
${inner}
|
||||
})()`;
|
||||
await poll(
|
||||
'笔记窗口标签就绪',
|
||||
() => noteJs(`(() => {
|
||||
const view = [...document.querySelectorAll('.note-tab-view')]
|
||||
.find((item) => !item.classList.contains('inactive'));
|
||||
return !!(view && view.querySelector('.note-window-title'));
|
||||
})()`),
|
||||
15000
|
||||
);
|
||||
check(
|
||||
'笔记窗口载入的是被点开的那一条',
|
||||
(await noteJs(activeScript("return view.querySelector('.note-window-title').value;")))
|
||||
=== String(windowNote.title || '')
|
||||
&& (await noteJs("document.getElementById('noteWindowError').textContent")) === '',
|
||||
await noteJs(activeScript("return view.querySelector('.note-window-title').value;"))
|
||||
);
|
||||
check(
|
||||
'开出的是一个标签',
|
||||
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
|
||||
);
|
||||
|
||||
// 同一条笔记不允许开出第二个标签,否则两个编辑器会整条覆盖对方
|
||||
await clickCardAction('切到窗口');
|
||||
await wait(1200);
|
||||
check(
|
||||
'同一条笔记再次开窗只聚焦不新增窗口',
|
||||
noteWindowCount() === 1,
|
||||
`笔记窗口数=${noteWindowCount()}`
|
||||
);
|
||||
check(
|
||||
'同一条笔记再次开窗也不新增标签',
|
||||
(await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||
`标签数=${await noteJs("document.querySelectorAll('.doctab').length")}`
|
||||
);
|
||||
|
||||
// 已开窗时该卡片的按钮改为切窗,避免模态与窗口同时编辑同一条
|
||||
const openedLabels = await cardLabels();
|
||||
check(
|
||||
'已开窗后该笔记不再提供开模态的编辑按钮',
|
||||
openedLabels.includes('在窗口中编辑') && !openedLabels.split(',').includes('编辑'),
|
||||
openedLabels
|
||||
);
|
||||
await clickCardAction('在窗口中编辑');
|
||||
await wait(1000);
|
||||
check(
|
||||
'点「在窗口中编辑」不会打开模态',
|
||||
await js("document.getElementById('modal').classList.contains('hidden')")
|
||||
);
|
||||
|
||||
// 断言真正落盘的内容,而不是界面状态
|
||||
const editedTitle = `窗口改名 ${Date.now()}`;
|
||||
await noteJs(activeScript(`
|
||||
const title = view.querySelector('.note-window-title');
|
||||
title.value = ${JSON.stringify(editedTitle)};
|
||||
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const tags = view.querySelector('.note-window-tags-input');
|
||||
tags.value = '窗口标签';
|
||||
tags.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return true;
|
||||
`));
|
||||
// 有未保存修改时标签上要有脏标记,否则关闭前的二次确认无从触发
|
||||
await poll(
|
||||
'未保存的修改在标签上有脏标记',
|
||||
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 1"),
|
||||
8000
|
||||
);
|
||||
check('未保存的修改在标签上有脏标记', true);
|
||||
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
|
||||
await poll(
|
||||
'笔记窗口的修改真正落盘',
|
||||
async () => {
|
||||
const stored = readerStore.listNotes({}).find((note) => note.id === windowNote.id);
|
||||
return !!stored && stored.title === editedTitle && stored.tags.includes('窗口标签');
|
||||
},
|
||||
12000
|
||||
);
|
||||
check('笔记窗口保存后落盘内容正确', true);
|
||||
|
||||
await poll(
|
||||
'笔记窗口的改动回流到主窗口列表',
|
||||
() => js(`Array.from(document.querySelectorAll('#notesList .note-title'))
|
||||
.some((node) => node.textContent.trim() === ${JSON.stringify(editedTitle)})`),
|
||||
12000
|
||||
);
|
||||
check('主窗口列表随笔记窗口保存刷新', true);
|
||||
await poll(
|
||||
'保存后脏标记清除',
|
||||
() => noteJs("document.querySelectorAll('.doctab-dirty').length === 0"),
|
||||
8000
|
||||
);
|
||||
check('保存后脏标记清除', true);
|
||||
|
||||
const noteWinErrors = [];
|
||||
noteWin.webContents.on('console-message', (event) => {
|
||||
if (event.level >= 2) noteWinErrors.push(event.message.slice(0, 120));
|
||||
});
|
||||
await wait(200);
|
||||
check('笔记窗口没有控制台错误', noteWinErrors.length === 0, noteWinErrors.slice(0, 2).join(' | '));
|
||||
|
||||
// 第二条笔记要进同一个窗口的新标签,而不是再开一个窗口
|
||||
const secondNote = readerStore.listNotes({})
|
||||
.find((note) => note.id !== windowNote.id && note.associated !== false);
|
||||
if (secondNote) {
|
||||
await js(`(() => {
|
||||
const target = document.querySelector('#notesList .note-card[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(secondNote.id))}) + ']');
|
||||
if (!target) throw new Error('找不到第二条笔记卡片');
|
||||
const button = [...target.querySelectorAll('.note-action')]
|
||||
.find((item) => item.textContent === '独立窗口');
|
||||
if (!button) throw new Error('第二条笔记没有独立窗口按钮');
|
||||
button.click();
|
||||
return true;
|
||||
})()`);
|
||||
await poll(
|
||||
'第二条笔记进入同一窗口的新标签',
|
||||
async () => noteWindowCount() === 1
|
||||
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 2,
|
||||
15000
|
||||
);
|
||||
check(
|
||||
'第二条笔记进入同一窗口的新标签',
|
||||
noteWindowCount() === 1,
|
||||
`笔记窗口数=${noteWindowCount()}`
|
||||
);
|
||||
check(
|
||||
'只有一个标签视图可见',
|
||||
(await noteJs(
|
||||
"[...document.querySelectorAll('.note-tab-view')].filter((v) => !v.classList.contains('inactive')).length"
|
||||
)) === 1
|
||||
);
|
||||
// 刚打开且只在编辑区点选、按方向键,不改内容,不能被判定为已修改。
|
||||
// 早前用 pointerdown/keydown 判脏时这里必然误报,一切标签都要求二次确认;
|
||||
// 画布还会因为 version 1→2 归一化在挂载时就"变更"一次
|
||||
await wait(1200);
|
||||
await noteJs(activeScript(`
|
||||
const host = view.querySelector('.note-window-editor');
|
||||
host.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
host.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
||||
host.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }));
|
||||
host.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
|
||||
return true;
|
||||
`));
|
||||
await wait(900);
|
||||
check(
|
||||
'只点选不改内容不会被误判为已修改',
|
||||
(await noteJs("document.querySelectorAll('.doctab-dirty').length")) === 0,
|
||||
`脏标记数=${await noteJs("document.querySelectorAll('.doctab-dirty').length")}`
|
||||
);
|
||||
}
|
||||
|
||||
// 关窗前的未保存拦截:取消之后必须还能再次触发确认。
|
||||
// 主进程 closePending 不复位时第二次点关闭会被静默忽略,
|
||||
// 而看门狗十秒后仍会把带未保存内容的窗口销毁。
|
||||
// 此时激活的是第二条笔记的标签,必须先切回已保存过的那条,
|
||||
// 否则下面"改回原样"比对的是另一条笔记的基线
|
||||
await noteJs(`(() => {
|
||||
const tab = document.querySelector('.doctab[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||
if (!tab) throw new Error('找不到目标标签');
|
||||
tab.click();
|
||||
return true;
|
||||
})()`);
|
||||
await poll(
|
||||
'切回已保存的那个标签',
|
||||
() => noteJs(activeScript(
|
||||
`return view.dataset.noteId === ${JSON.stringify(String(windowNote.id))};`
|
||||
)),
|
||||
10000
|
||||
);
|
||||
await noteJs(activeScript(`
|
||||
const title = view.querySelector('.note-window-title');
|
||||
title.value = '关窗前的未保存修改';
|
||||
title.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return true;
|
||||
`));
|
||||
await poll(
|
||||
'关窗前已置脏',
|
||||
() => noteJs("document.querySelectorAll('.doctab-dirty').length >= 1"),
|
||||
8000
|
||||
);
|
||||
await noteJs("document.getElementById('closeBtn').click()");
|
||||
await poll(
|
||||
'关窗被未保存确认拦下',
|
||||
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
|
||||
10000
|
||||
);
|
||||
check('关窗被未保存确认拦下,窗口还在', !noteWin.isDestroyed());
|
||||
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
|
||||
await wait(1000);
|
||||
check('取消后窗口保留', !noteWin.isDestroyed());
|
||||
await noteJs("document.getElementById('closeBtn').click()");
|
||||
await poll(
|
||||
'取消之后再次关窗仍会弹确认',
|
||||
() => noteJs("!document.getElementById('noteDirtyModal').classList.contains('hidden')"),
|
||||
10000
|
||||
);
|
||||
check('取消之后再次关窗仍会弹确认', !noteWin.isDestroyed());
|
||||
await noteJs("document.getElementById('noteDirtyCancelBtn').click()");
|
||||
await wait(800);
|
||||
// 存盘收尾,避免未保存状态干扰后面的删除断言
|
||||
await noteJs(activeScript("view.querySelector('.note-window-save').click(); return true;"));
|
||||
await poll(
|
||||
'取消关闭后仍能正常保存',
|
||||
() => noteJs(`(() => {
|
||||
const tab = document.querySelector('.doctab[data-note-id=' +
|
||||
JSON.stringify(${JSON.stringify(String(windowNote.id))}) + ']');
|
||||
return !!tab && !tab.querySelector('.doctab-dirty');
|
||||
})()`),
|
||||
10000
|
||||
);
|
||||
check('取消关闭后仍能正常保存', true);
|
||||
|
||||
// 笔记被删除后只关掉它那个标签,窗口和别的标签要留着
|
||||
await js(`window.api.reader.removeNote(${JSON.stringify(windowNote.entryId)}, ${JSON.stringify(windowNote.id)})`);
|
||||
if (secondNote) {
|
||||
await poll(
|
||||
'删除笔记只关掉对应标签',
|
||||
async () => !noteWin.isDestroyed()
|
||||
&& (await noteJs("document.querySelectorAll('.doctab').length")) === 1,
|
||||
12000
|
||||
);
|
||||
check('删除笔记只关掉对应标签,窗口留着', !noteWin.isDestroyed());
|
||||
// 删掉最后一个标签,窗口才该退场
|
||||
const remainingId = await noteJs("document.querySelector('.doctab').dataset.noteId");
|
||||
const remaining = readerStore.listNotes({}).find((note) => note.id === remainingId);
|
||||
check('剩下的标签是另一条笔记', !!remaining && remaining.id !== windowNote.id, String(remainingId));
|
||||
if (remaining) {
|
||||
await js(`window.api.reader.removeNote(${JSON.stringify(remaining.entryId)}, ${JSON.stringify(remaining.id)})`);
|
||||
}
|
||||
}
|
||||
await poll('最后一个标签消失后窗口自动关闭', async () => noteWin.isDestroyed(), 12000);
|
||||
check('最后一个标签消失后窗口自动关闭', noteWin.isDestroyed());
|
||||
check('窗口关闭后主进程标签集清空', noteWindow.openIds().length === 0, JSON.stringify(noteWindow.openIds()));
|
||||
|
||||
await wait(300);
|
||||
check(
|
||||
'主渲染进程没有控制台错误',
|
||||
|
||||
@@ -11,6 +11,8 @@ const EPUB_FILE = path.join(TMP, 'reader-features.epub');
|
||||
const MOBI_FILE = path.join(TMP, 'reader-features.mobi');
|
||||
const DRM_MOBI_FILE = path.join(TMP, 'reader-features-drm.azw');
|
||||
const LARGE_EPUB_FILE = path.join(TMP, 'reader-features-large.epub');
|
||||
const TXT_FILE = path.join(TMP, 'reader-features.txt');
|
||||
const MD_FILE = path.join(TMP, 'reader-features.md');
|
||||
app.setPath('userData', TMP);
|
||||
app.setPath('appData', TMP);
|
||||
|
||||
@@ -194,6 +196,58 @@ function makeMobi(file) {
|
||||
fs.writeFileSync(file, output);
|
||||
}
|
||||
|
||||
function makeTxt(file) {
|
||||
const lines = ['纯文本夹具标题', ''];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
lines.push(`第${i}章 编码与分章`, '');
|
||||
for (let k = 0; k < 12; k++) {
|
||||
lines.push(`第${i}章第${k + 1}段:TXT-MARK-${i}-${k} 中文正文用于校验解码与全文提取。`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
const text = lines.join('\r\n');
|
||||
// 带 BOM 的 UTF-16LE 是 Windows 记事本另存的默认之一,编码探测必须在真实浏览器里也成立
|
||||
const body = Buffer.from(text, 'utf16le');
|
||||
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
|
||||
return text;
|
||||
}
|
||||
|
||||
function makeMd(file) {
|
||||
const source = [
|
||||
'# Markdown 夹具',
|
||||
'',
|
||||
'正文段落包含 **加粗** 与 `行内代码`,用于确认渲染而不是纯文本显示。',
|
||||
'',
|
||||
'## 危险内容小节',
|
||||
'',
|
||||
'<script>window.__mdScriptExecuted = true</script>',
|
||||
'',
|
||||
'<img src="x" onerror="window.__mdHandlerExecuted = true">',
|
||||
'',
|
||||
'[不安全链接](javascript:window.__mdLinkExecuted=true)',
|
||||
'',
|
||||
'<iframe src="https://untrusted.example/frame"></iframe>',
|
||||
'',
|
||||
'## 结构小节',
|
||||
'',
|
||||
'- 列表项一',
|
||||
'- 列表项二',
|
||||
'',
|
||||
'```js',
|
||||
'const fenced = "code block";',
|
||||
'```',
|
||||
'',
|
||||
'| 列一 | 列二 |',
|
||||
'|---|---|',
|
||||
'| 单元格 | MD-TABLE-CELL |',
|
||||
'',
|
||||
'> 引用块 MD-QUOTE-MARK',
|
||||
''
|
||||
].join('\n');
|
||||
fs.writeFileSync(file, source, 'utf8');
|
||||
return source;
|
||||
}
|
||||
|
||||
async function js(win, source) {
|
||||
try {
|
||||
return await win.webContents.executeJavaScript(source);
|
||||
@@ -515,6 +569,8 @@ app.whenReady().then(async () => {
|
||||
fs.copyFileSync(MOBI_FILE, DRM_MOBI_FILE);
|
||||
fs.writeFileSync(LARGE_EPUB_FILE, Buffer.alloc(0));
|
||||
fs.truncateSync(LARGE_EPUB_FILE, 256 * 1024 * 1024 + 1);
|
||||
makeTxt(TXT_FILE);
|
||||
makeMd(MD_FILE);
|
||||
const drmFixture = fs.readFileSync(DRM_MOBI_FILE);
|
||||
drmFixture.writeUInt16BE(1, 96 + 12);
|
||||
fs.writeFileSync(DRM_MOBI_FILE, drmFixture);
|
||||
@@ -549,7 +605,9 @@ app.whenReady().then(async () => {
|
||||
{ path: EPUB_FILE, name: 'reader-features.epub', format: 'EPUB' },
|
||||
{ path: MOBI_FILE, name: 'reader-features.mobi', format: 'MOBI' },
|
||||
{ path: DRM_MOBI_FILE, name: 'reader-features-drm.azw', format: 'AZW' },
|
||||
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' }
|
||||
{ path: LARGE_EPUB_FILE, name: 'reader-features-large.epub', format: 'EPUB' },
|
||||
{ path: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
|
||||
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
|
||||
]
|
||||
});
|
||||
await wait(1200);
|
||||
@@ -671,6 +729,95 @@ app.whenReady().then(async () => {
|
||||
pdfWin.setSize(1280, 900);
|
||||
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
|
||||
.classList.contains('pdfx-layout-single')`);
|
||||
|
||||
// 前面的分页导航把视口停在第 3 页,离屏页会被回收成空白占位,
|
||||
// 必须先回到第 1 页再测画质,否则量到的是占位画布而不是真实渲染结果
|
||||
await js(pdfWin, `(() => {
|
||||
const range = document.getElementById('progressRange');
|
||||
range.value = '0';
|
||||
range.dispatchEvent(new Event('change'));
|
||||
})()`);
|
||||
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 1 页'`);
|
||||
const canvasProbe = `(() => {
|
||||
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||
const css = canvas.getBoundingClientRect().width;
|
||||
return {
|
||||
dpr: window.devicePixelRatio,
|
||||
backing: canvas.width,
|
||||
css: Math.round(css),
|
||||
ratio: canvas.width / css
|
||||
};
|
||||
})()`;
|
||||
await waitForJs(pdfWin, `(() => {
|
||||
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||
return canvas && canvas.width > 0;
|
||||
})()`);
|
||||
check('标准画质下第 1 页有真实墨迹',
|
||||
imageHasInk(await js(pdfWin,
|
||||
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`)));
|
||||
const qualityBase = await js(pdfWin, canvasProbe);
|
||||
// 生效倍率是 max(dpr, quality),HiDPI 屏上不叠乘,所以不能断言"backing 翻倍"
|
||||
check('默认画质下 backing 比例等于设备像素比',
|
||||
Math.abs(qualityBase.ratio - qualityBase.dpr) < 0.05,
|
||||
`dpr=${qualityBase.dpr} ratio=${qualityBase.ratio.toFixed(3)}`);
|
||||
const targetQuality = Math.min(3, Math.ceil(qualityBase.dpr + 1));
|
||||
await js(pdfWin, `(() => {
|
||||
const select = document.getElementById('pdfRenderQuality');
|
||||
select.value = '${targetQuality}';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
})()`);
|
||||
await waitForJs(pdfWin, `(() => {
|
||||
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||
if (!canvas || !canvas.width) return false;
|
||||
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${targetQuality}) < 0.05;
|
||||
})()`);
|
||||
const qualityHigh = await js(pdfWin, canvasProbe);
|
||||
// 超采样只能放大 backing store,CSS 盒子必须原地不动,否则是把页面放大而不是提高画质
|
||||
check('PDF 画质档提高 backing 分辨率且不改变版面尺寸',
|
||||
Math.abs(qualityHigh.ratio - targetQuality) < 0.05
|
||||
&& qualityHigh.backing > qualityBase.backing
|
||||
&& Math.abs(qualityHigh.css - qualityBase.css) <= 1,
|
||||
`ratio ${qualityBase.ratio.toFixed(3)}->${qualityHigh.ratio.toFixed(3)} `
|
||||
+ `backing ${qualityBase.backing}->${qualityHigh.backing} css ${qualityBase.css}->${qualityHigh.css}`);
|
||||
const qualityRatios = await js(pdfWin, `Array.from(document.querySelectorAll('.pdfx-canvas'))
|
||||
.filter((canvas) => canvas.width > 0)
|
||||
.map((canvas) => Math.round(canvas.width / canvas.getBoundingClientRect().width * 100) / 100)`);
|
||||
// 只改新渲染的页会让同屏出现清晰度不一致,倍率变化必须整篇重建
|
||||
check('画质切换后同屏各页倍率一致',
|
||||
qualityRatios.length > 0 && new Set(qualityRatios).size === 1,
|
||||
JSON.stringify(qualityRatios));
|
||||
// 画布尺寸在重建时立即变大,墨迹要等这一页重绘完才落上去,只等比例会量到空白中间态
|
||||
let highInk = false;
|
||||
try {
|
||||
await waitUntil(async () => {
|
||||
highInk = imageHasInk(await js(pdfWin,
|
||||
`document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas').toDataURL('image/png')`));
|
||||
return highInk;
|
||||
}, 15000, 250);
|
||||
} catch (error) { /* 交给下面的断言报告 */ }
|
||||
check('提高画质后仍渲染出真实墨迹而不是空白画布', highInk);
|
||||
await waitUntil(() => Promise.resolve(
|
||||
settings.get('reader.pdfRenderQuality', 0) === targetQuality
|
||||
));
|
||||
check('PDF 画质偏好已持久化', true);
|
||||
await js(pdfWin, `(() => {
|
||||
const select = document.getElementById('pdfRenderQuality');
|
||||
select.value = '1';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
})()`);
|
||||
await waitForJs(pdfWin, `(() => {
|
||||
const canvas = document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas');
|
||||
if (!canvas || !canvas.width) return false;
|
||||
return Math.abs(canvas.width / canvas.getBoundingClientRect().width - ${qualityBase.dpr}) < 0.05;
|
||||
})()`);
|
||||
check('画质调回标准档后恢复设备像素比', true);
|
||||
// 画质检查需要停在第 1 页,后续用例仍按第 3 页断言,这里把视口还回去
|
||||
await js(pdfWin, `(() => {
|
||||
const range = document.getElementById('progressRange');
|
||||
range.value = '1000';
|
||||
range.dispatchEvent(new Event('change'));
|
||||
})()`);
|
||||
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
|
||||
try {
|
||||
await waitForJs(pdfWin, `Array.from(document.querySelectorAll('.pdfx-text span'))
|
||||
.some((node) => node.firstChild && node.firstChild.data.trim())`);
|
||||
@@ -1356,6 +1503,181 @@ app.whenReady().then(async () => {
|
||||
drmReader.errors.slice(0, 3).join(' | '));
|
||||
drmReader.win.close();
|
||||
|
||||
const txtReader = await openReader(entry.id, 5);
|
||||
const txtWin = txtReader.win;
|
||||
await waitForJs(txtWin, `!document.querySelector('.doc-overlay')
|
||||
&& !!document.querySelector('.host-epub iframe')?.contentDocument?.body?.textContent.trim()`, 30000);
|
||||
check('TXT 通过内置阅读器打开而不是回退到外部程序',
|
||||
await js(txtWin, `!document.querySelector('.doc-overlay.err')
|
||||
&& !!document.querySelector('.host-epub iframe')`));
|
||||
const txtToc = await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||
.map((button) => button.textContent)`);
|
||||
check('TXT 按章节标题切分并生成可跳转目录',
|
||||
txtToc.length >= 3 && txtToc.some((label) => /第1章/.test(label)),
|
||||
JSON.stringify(txtToc.slice(0, 5)));
|
||||
await js(txtWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||
.find((button) => /第1章/.test(button.textContent)).click()`);
|
||||
await waitForJs(txtWin, `document.querySelector('.host-epub iframe').contentDocument.body
|
||||
.textContent.includes('TXT-MARK-1-0')`, 20000);
|
||||
const txtDecoded = await js(txtWin, `(() => {
|
||||
const text = document.querySelector('.host-epub iframe').contentDocument.body.textContent;
|
||||
return {
|
||||
chinese: text.includes('第1章第1段'),
|
||||
replacement: text.includes('\\ufffd'),
|
||||
nul: text.includes('\\u0000')
|
||||
};
|
||||
})()`);
|
||||
// 带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本变乱码且用户无从修正
|
||||
check('带 BOM 的 UTF-16LE 纯文本正确解码为中文而不是乱码',
|
||||
txtDecoded.chinese && !txtDecoded.replacement && !txtDecoded.nul,
|
||||
JSON.stringify(txtDecoded));
|
||||
// AI 的"全文"范围依赖 textOf(locator,'document'),缺章会让模型答非所问且用户无从察觉
|
||||
const txtFullText = await js(txtWin, `(async () => {
|
||||
const module = await import('./reader/text-adapter.mjs');
|
||||
const adapter = module.createTextAdapter('txt');
|
||||
try {
|
||||
const bytes = await window.api.reader.bytes(${JSON.stringify(entry.id)}, 5);
|
||||
await adapter.load(bytes.data, {});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
await adapter.renderTo(host, null, { fontSize: 16, theme: 'light', lineHeight: 1.7 });
|
||||
const full = await adapter.textOf(null, 'document');
|
||||
const page = await adapter.textOf(null, 'page');
|
||||
host.remove();
|
||||
return {
|
||||
marks: (full.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
|
||||
pageMarks: (page.match(/TXT-MARK-\\d+-\\d+/g) || []).length,
|
||||
length: full.length
|
||||
};
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
})()`);
|
||||
check('TXT 全文范围覆盖所有章节而不是只有当前章',
|
||||
txtFullText.marks === 36 && txtFullText.pageMarks < txtFullText.marks,
|
||||
JSON.stringify(txtFullText));
|
||||
check('TXT 阅读器没有控制台错误', txtReader.errors.length === 0,
|
||||
txtReader.errors.slice(0, 3).join(' | '));
|
||||
txtWin.close();
|
||||
|
||||
// 上面几条都是直接调 IPC 打开的,绕过了书库界面。
|
||||
// 渲染层自己那份可阅读格式白名单漏掉 txt/md 时,IPC 照样能开,
|
||||
// 但卡片上根本不会出现「阅读」按钮,用户看到的就是"内置阅读器打不开 txt"
|
||||
{
|
||||
const mainWin = BrowserWindow.getAllWindows()
|
||||
.find((w) => !w.isDestroyed() && String(w.webContents.getURL()).includes('index.html'));
|
||||
check('存在主窗口用于校验书库入口', !!mainWin);
|
||||
if (mainWin) {
|
||||
mainWin.show();
|
||||
await js(mainWin, `document.querySelector('.tab[data-tab="library"]').click()`);
|
||||
await waitForJs(mainWin,
|
||||
`document.querySelectorAll('#libGrid > .card').length > 0`, 20000);
|
||||
const cardEntry = await js(mainWin, `(() => {
|
||||
const card = document.querySelector('#libGrid .card[data-id="${entry.id}"]');
|
||||
if (!card) return { missing: true };
|
||||
return {
|
||||
coverReadable: !!card.querySelector('.card-cover.readable'),
|
||||
coverActs: !!card.querySelector('.card-cover[data-act="read"]'),
|
||||
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
|
||||
.some((b) => /阅读/.test(b.title || ''))
|
||||
};
|
||||
})()`);
|
||||
check('书库卡片提供内置阅读入口', cardEntry.hasRead && cardEntry.coverReadable
|
||||
&& cardEntry.coverActs, JSON.stringify(cardEntry));
|
||||
|
||||
// 只含 txt 的条目也要能读:白名单漏项时这条会失败
|
||||
const txtOnly = library.add({
|
||||
title: 'TXT Only Fixture',
|
||||
files: [{ path: TXT_FILE, name: 'txt-only.txt', format: 'TXT' }]
|
||||
});
|
||||
await js(mainWin, `document.getElementById('rescanBtn').click()`);
|
||||
await waitForJs(mainWin,
|
||||
`!!document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]')`, 20000);
|
||||
const txtCard = await js(mainWin, `(() => {
|
||||
const card = document.querySelector('#libGrid .card[data-id="${txtOnly.id}"]');
|
||||
if (!card) return { missing: true };
|
||||
return {
|
||||
coverReadable: !!card.querySelector('.card-cover.readable'),
|
||||
hasRead: [...card.querySelectorAll('.lib-card-actions button')]
|
||||
.some((b) => /阅读/.test(b.title || ''))
|
||||
};
|
||||
})()`);
|
||||
check('纯 TXT 条目在书库里也有阅读入口',
|
||||
txtCard.hasRead && txtCard.coverReadable, JSON.stringify(txtCard));
|
||||
library.remove(txtOnly.id);
|
||||
mainWin.hide();
|
||||
}
|
||||
}
|
||||
|
||||
const mdReader = await openReader(entry.id, 6);
|
||||
const mdWin = mdReader.win;
|
||||
await waitForJs(mdWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
|
||||
?.textContent.includes('Markdown 夹具')`, 30000);
|
||||
const mdHeadStructure = await js(mdWin, `(() => {
|
||||
const doc = document.querySelector('.host-epub iframe').contentDocument;
|
||||
return {
|
||||
h1: doc.querySelectorAll('h1').length,
|
||||
strong: doc.querySelectorAll('strong').length,
|
||||
code: doc.querySelectorAll('code').length
|
||||
};
|
||||
})()`);
|
||||
check('Markdown 首节渲染成标题与行内标记而不是纯文本',
|
||||
mdHeadStructure.h1 >= 1 && mdHeadStructure.strong >= 1 && mdHeadStructure.code >= 1,
|
||||
JSON.stringify(mdHeadStructure));
|
||||
const mdToc = await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||
.map((button) => button.textContent)`);
|
||||
check('Markdown 按标题层级生成目录',
|
||||
mdToc.length >= 3 && mdToc.some((label) => /结构小节/.test(label)),
|
||||
JSON.stringify(mdToc));
|
||||
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||
.find((button) => /结构小节/.test(button.textContent)).click()`);
|
||||
await waitForJs(mdWin, `document.querySelector('.host-epub iframe').contentDocument
|
||||
.body.textContent.includes('MD-TABLE-CELL')`, 20000);
|
||||
const mdStructure = await js(mdWin, `(() => {
|
||||
const doc = document.querySelector('.host-epub iframe').contentDocument;
|
||||
return {
|
||||
h2: doc.querySelectorAll('h2').length,
|
||||
li: doc.querySelectorAll('li').length,
|
||||
pre: doc.querySelectorAll('pre').length,
|
||||
table: doc.querySelectorAll('table td, table th').length,
|
||||
quote: doc.querySelectorAll('blockquote').length
|
||||
};
|
||||
})()`);
|
||||
check('Markdown 列表、代码块、表格与引用渲染成真实块级结构',
|
||||
mdStructure.h2 >= 1 && mdStructure.li >= 2 && mdStructure.pre >= 1
|
||||
&& mdStructure.table >= 1 && mdStructure.quote >= 1,
|
||||
JSON.stringify(mdStructure));
|
||||
await js(mdWin, `Array.from(document.querySelectorAll('#tocList .toc-item'))
|
||||
.find((button) => /危险内容小节/.test(button.textContent)).click()`);
|
||||
await wait(1200);
|
||||
// 裸 HTML 被 markdown-it 转义成文本,所以只能按 DOM 断言,
|
||||
// 用 innerHTML 匹配 "onerror" 会把转义后的字面量误判成漏网
|
||||
const mdSafety = await js(mdWin, `(() => {
|
||||
const frame = document.querySelector('.host-epub iframe');
|
||||
const doc = frame.contentDocument;
|
||||
const nodes = Array.from(doc.querySelectorAll('*'));
|
||||
return {
|
||||
script: doc.querySelectorAll('script').length,
|
||||
iframe: doc.querySelectorAll('iframe').length,
|
||||
img: doc.querySelectorAll('img').length,
|
||||
eventAttrs: nodes.filter((node) => Array.from(node.attributes || [])
|
||||
.some((attr) => /^on/i.test(attr.name))).length,
|
||||
unsafeHref: Array.from(doc.querySelectorAll('a[href]'))
|
||||
.filter((a) => /^(javascript|vbscript|data|file):/i.test(a.getAttribute('href') || '')).length,
|
||||
executedScript: !!(frame.contentWindow.__mdScriptExecuted || window.__mdScriptExecuted),
|
||||
executedHandler: !!(frame.contentWindow.__mdHandlerExecuted || window.__mdHandlerExecuted),
|
||||
executedLink: !!(frame.contentWindow.__mdLinkExecuted || window.__mdLinkExecuted)
|
||||
};
|
||||
})()`);
|
||||
check('Markdown 中的脚本、事件属性与 javascript: 链接被净化且未执行',
|
||||
mdSafety.script === 0 && mdSafety.iframe === 0 && mdSafety.img === 0
|
||||
&& mdSafety.eventAttrs === 0 && mdSafety.unsafeHref === 0
|
||||
&& !mdSafety.executedScript && !mdSafety.executedHandler && !mdSafety.executedLink,
|
||||
JSON.stringify(mdSafety));
|
||||
check('Markdown 阅读器没有控制台错误', mdReader.errors.length === 0,
|
||||
mdReader.errors.slice(0, 3).join(' | '));
|
||||
mdWin.close();
|
||||
|
||||
const readerFile = path.join(TMP, 'reader.json');
|
||||
const readerJson = JSON.parse(fs.readFileSync(readerFile, 'utf8'));
|
||||
check('readerStore 使用隔离目录中的 v6 存储',
|
||||
|
||||
@@ -243,6 +243,98 @@ test('failed tag writes roll back both catalog and item references', () => {
|
||||
assert.ok(!fs.existsSync(`${file}.bak`));
|
||||
});
|
||||
|
||||
test('批量更新只写一次索引,任一条目无效则整批回滚', () => {
|
||||
const root = freshRoot('update-many');
|
||||
const a = store.add({ title: '甲', tags: ['共有'] });
|
||||
const b = store.add({ title: '乙', tags: ['共有', '仅乙'] });
|
||||
const c = store.add({ title: '丙', tags: [] });
|
||||
const file = indexPath(root);
|
||||
|
||||
// 逐条 update 会把整个索引重写 N 遍,批量必须收敛成一次。
|
||||
// 索引经 fd 写入,因此统计 open 而不是 writeFileSync 的路径参数。
|
||||
const countIndexWrites = (fn) => {
|
||||
const originalOpen = fs.openSync;
|
||||
let writes = 0;
|
||||
fs.openSync = function counting(target, flags, ...rest) {
|
||||
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
|
||||
return originalOpen.call(this, target, flags, ...rest);
|
||||
};
|
||||
try { fn(); } finally { fs.openSync = originalOpen; }
|
||||
return writes;
|
||||
};
|
||||
|
||||
let updated = 0;
|
||||
const writes = countIndexWrites(() => {
|
||||
updated = store.updateMany([
|
||||
{ id: a.id, patch: { tags: ['共有', '新增'] } },
|
||||
{ id: b.id, patch: { shelfId: null, tags: ['共有'] } }
|
||||
]).updated;
|
||||
});
|
||||
assert.strictEqual(updated, 2);
|
||||
assert.strictEqual(writes, 1, '批量更新应只写一次索引');
|
||||
|
||||
// 对照:逐条 update 会写两次,证明上面的 1 不是计数器失灵
|
||||
const loopWrites = countIndexWrites(() => {
|
||||
store.update(a.id, { tags: ['共有', '新增'] });
|
||||
store.update(b.id, { tags: ['共有'] });
|
||||
});
|
||||
assert.strictEqual(loopWrites, 2, '逐条更新应写两次,用于对照');
|
||||
|
||||
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
|
||||
assert.deepStrictEqual(store.get(b.id).tags, ['共有']);
|
||||
assert.deepStrictEqual(store.get(c.id).tags, [], '未列出的条目不应被改动');
|
||||
|
||||
const snapshot = fs.readFileSync(file, 'utf8');
|
||||
assert.throws(() => store.updateMany([
|
||||
{ id: a.id, patch: { tags: ['不该生效'] } },
|
||||
{ id: 'missing-id', patch: { tags: ['x'] } }
|
||||
]), /条目不存在/);
|
||||
assert.strictEqual(fs.readFileSync(file, 'utf8'), snapshot, '整批回滚不应留下部分写入');
|
||||
assert.deepStrictEqual(store.get(a.id).tags, ['共有', '新增']);
|
||||
assert.throws(() => store.updateMany([{ patch: {} }]), /条目 ID/);
|
||||
assert.deepStrictEqual(store.updateMany([]), { updated: 0 });
|
||||
});
|
||||
|
||||
test('批量移除只写一次索引,可选删除库内文件', () => {
|
||||
const root = freshRoot('remove-many');
|
||||
fs.mkdirSync(path.join(root, 'files'), { recursive: true });
|
||||
const made = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const abs = path.join(root, 'files', `book-${i}.pdf`);
|
||||
fs.writeFileSync(abs, '%PDF-1.4\n');
|
||||
made.push(store.add({ title: `书${i}`, files: [{ path: abs, name: `book-${i}.pdf` }] }));
|
||||
}
|
||||
const outside = path.join(root, '..', `outside-${path.basename(root)}.pdf`);
|
||||
fs.writeFileSync(outside, '%PDF-1.4\n');
|
||||
created.push(outside);
|
||||
const external = store.add({ title: '外部', files: [{ path: outside, name: 'outside.pdf' }] });
|
||||
const file = indexPath(root);
|
||||
|
||||
const originalOpen = fs.openSync;
|
||||
let writes = 0;
|
||||
fs.openSync = function counting(target, flags, ...rest) {
|
||||
if (String(target) === `${file}.tmp` && String(flags).startsWith('w')) writes++;
|
||||
return originalOpen.call(this, target, flags, ...rest);
|
||||
};
|
||||
let removed = 0;
|
||||
try {
|
||||
removed = store.removeMany([made[0].id, made[1].id], true).removed;
|
||||
} finally {
|
||||
fs.openSync = originalOpen;
|
||||
}
|
||||
assert.strictEqual(removed, 2);
|
||||
assert.strictEqual(writes, 1, '批量移除应只写一次索引');
|
||||
assert.strictEqual(store.list().length, 2);
|
||||
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-0.pdf')), false);
|
||||
assert.strictEqual(fs.existsSync(path.join(root, 'files', 'book-2.pdf')), true);
|
||||
|
||||
// 用户原地引用的库外文件不能被删
|
||||
assert.strictEqual(store.removeMany([external.id], true).removed, 1);
|
||||
assert.strictEqual(fs.existsSync(outside), true, '库外文件不应被删除');
|
||||
assert.deepStrictEqual(store.removeMany([], true), { removed: 0 });
|
||||
assert.deepStrictEqual(store.removeMany(['missing'], false), { removed: 0 });
|
||||
});
|
||||
|
||||
test('explicit tag creation enforces the existing catalog limit', () => {
|
||||
const root = freshRoot('limit');
|
||||
const now = Date.now();
|
||||
|
||||
@@ -69,13 +69,14 @@ test('handles mixed file and directory inputs while skipping unsupported and non
|
||||
const inFolder = write(root, path.join('folder', 'comic.cbz'));
|
||||
const azw = write(root, path.join('folder', 'legacy.azw'));
|
||||
const direct = write(root, 'notes.txt');
|
||||
const markdown = write(root, path.join('folder', 'guide.MD'));
|
||||
write(root, path.join('folder', 'cover.jpg'));
|
||||
write(root, 'README.md');
|
||||
write(root, 'README.rtf');
|
||||
fs.mkdirSync(path.join(root, 'empty'));
|
||||
|
||||
const result = await discover([
|
||||
path.join(root, 'missing.pdf'),
|
||||
path.join(root, 'README.md'),
|
||||
path.join(root, 'README.rtf'),
|
||||
path.join(root, 'empty'),
|
||||
direct,
|
||||
folder
|
||||
@@ -83,7 +84,8 @@ test('handles mixed file and directory inputs while skipping unsupported and non
|
||||
|
||||
assert.deepStrictEqual(
|
||||
result.map((record) => record.path),
|
||||
[inFolder, azw, direct].map((value) => fs.realpathSync(value)).sort()
|
||||
[inFolder, azw, markdown, direct].map((value) => fs.realpathSync(value)).sort(),
|
||||
'md 与 txt 都能进内置阅读器,必须和其他图书格式一样被本地导入发现'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+178
-2
@@ -81,6 +81,182 @@ test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
|
||||
assert.ok(segment.indexOf('annotations.forget', guard) > guard);
|
||||
});
|
||||
|
||||
test('批量整理与移除走单次索引提交,不按条目循环 IPC', () => {
|
||||
for (const [channel, method] of [['library:updateMany', 'updateMany'], ['library:removeMany', 'removeMany']]) {
|
||||
const start = mainSrc.indexOf(`ipcMain.handle('${channel}'`);
|
||||
assert.ok(start > 0, `缺少 ${channel}`);
|
||||
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||
assert.ok(segment.includes(`library.${method}(`), `${channel} 应调用 library.${method}`);
|
||||
}
|
||||
const removeStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
|
||||
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
|
||||
const removeSegment = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
|
||||
// 与单本移除一致:默认保留阅读资料,只有显式勾选才清理
|
||||
assert.match(removeSegment, /deleteReadingData\s*===\s*true/);
|
||||
|
||||
const uiSrc = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'library.js'), 'utf8');
|
||||
assert.match(uiSrc, /window\.api\.library\.updateMany\(/);
|
||||
assert.match(uiSrc, /window\.api\.library\.removeMany\(/);
|
||||
assert.ok(!/for\s*\(const item of targets\)[\s\S]{0,200}library\.(update|remove)\(/.test(uiSrc),
|
||||
'渲染层不应再逐条发 IPC');
|
||||
});
|
||||
|
||||
test('孤立阅读资料只报告不自动删除,清理需分别勾选', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
|
||||
assert.ok(start > 0, '缺少孤立资料对账通道');
|
||||
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||
const report = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||
// 对账必须以真实书库条目为准,不能凭文件名猜
|
||||
assert.match(report, /library\.list\(\)/);
|
||||
assert.match(report, /readerStore\.orphanReport\(/);
|
||||
assert.match(report, /annotations\.orphanReport\(/);
|
||||
|
||||
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
|
||||
assert.ok(purgeStart > 0, '缺少孤立资料清理通道');
|
||||
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
|
||||
const purge = mainSrc.slice(purgeStart, purgeEnd < 0 ? undefined : purgeEnd);
|
||||
assert.match(purge, /scope\.annotations\s*===\s*true/);
|
||||
assert.match(purge, /scope\.notes\s*===\s*true/);
|
||||
assert.match(purge, /scope\.chats\s*===\s*true/);
|
||||
assert.ok(purge.indexOf('library.list()') >= 0, '清理前必须重新对账,不能信任渲染层传来的 ID');
|
||||
});
|
||||
|
||||
test('AI 会话随书籍删除一并清理,孤立会话纳入对账', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
|
||||
assert.ok(start > 0, '缺少批量移除通道');
|
||||
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||
const remove = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||
assert.match(remove, /aiSessions\.forgetMany\(/);
|
||||
// 会话删完要回收图片,否则内容寻址的图片永远没有引用者来释放
|
||||
assert.match(remove, /collectAiImages\(\)/);
|
||||
|
||||
const reportStart = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
|
||||
const reportEnd = mainSrc.indexOf('ipcMain.handle(', reportStart + 20);
|
||||
assert.match(mainSrc.slice(reportStart, reportEnd), /aiSessions\.orphanReport\(/);
|
||||
});
|
||||
|
||||
test('笔记独立窗口:开窗前重新对账,窗口内只能读自己已开的标签', () => {
|
||||
const openStart = mainSrc.indexOf("ipcMain.handle('notes:openWindow'");
|
||||
assert.ok(openStart > 0, '缺少笔记开窗通道');
|
||||
const openEnd = mainSrc.indexOf('ipcMain.handle(', openStart + 20);
|
||||
const open = mainSrc.slice(openStart, openEnd < 0 ? undefined : openEnd);
|
||||
// 目标由 findNote 重新对账,不能直接把渲染层传来的 ID 拿去开窗
|
||||
assert.match(open, /findNote\(entryId,\s*noteId\)/);
|
||||
assert.match(open, /noteWindow\.open\(note\.entryId,\s*note\.id/);
|
||||
|
||||
const findStart = mainSrc.indexOf('function findNote');
|
||||
const find = mainSrc.slice(findStart, findStart + 500);
|
||||
assert.match(find, /readerStore\.listNotes\(/, '对账必须以 store 里真实存在的笔记为准');
|
||||
|
||||
const getStart = mainSrc.indexOf("ipcMain.handle('notes:getOne'");
|
||||
const getEnd = mainSrc.indexOf('ipcMain.handle(', getStart + 20);
|
||||
const getOne = mainSrc.slice(getStart, getEnd < 0 ? undefined : getEnd);
|
||||
// 多标签后授权是「在标签集内」,不是「等于某一条」
|
||||
assert.match(getOne, /noteWindow\.ownsNote\(event\.sender,\s*noteId\)/);
|
||||
assert.match(getOne, /无权读取其它笔记/);
|
||||
});
|
||||
|
||||
test('笔记标签集只能由渲染层收窄,新增必须走 open 对账', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
|
||||
const start = src.indexOf('function setTabs');
|
||||
assert.ok(start > 0, '缺少 setTabs');
|
||||
const body = src.slice(start, src.indexOf('\n}', start));
|
||||
// 允许渲染层往标签集里塞 ID,等于让它自己扩权:
|
||||
// 谎报持有某条笔记后 notes:getOne 就会放行
|
||||
assert.doesNotMatch(body, /openNotes\.set\(/, 'setTabs 不能新增标签');
|
||||
assert.match(body, /openNotes\.delete\(/, 'setTabs 只做收窄');
|
||||
assert.match(body, /isNoteSender\(wc\)/);
|
||||
});
|
||||
|
||||
test('笔记窗口取消关闭后要复位 closePending 并撤掉看门狗', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'reader', 'note-window.js'), 'utf8');
|
||||
const start = src.indexOf('function cancelClose');
|
||||
assert.ok(start > 0, '缺少 cancelClose:取消后窗口会再也关不掉');
|
||||
const body = src.slice(start, src.indexOf('\n}', start));
|
||||
assert.match(body, /clearTimeout\(closeTimer\)/, '不撤看门狗会在十秒后销毁带未保存内容的窗口');
|
||||
assert.match(body, /closePending = false/);
|
||||
assert.match(src, /ipcMain|module\.exports[\s\S]*cancelClose/);
|
||||
});
|
||||
|
||||
test('笔记删除或书籍移除后独立窗口必须退场', () => {
|
||||
const removeStart = mainSrc.indexOf("ipcMain.handle('reader:removeNote'");
|
||||
const removeEnd = mainSrc.indexOf('ipcMain.handle(', removeStart + 20);
|
||||
const remove = mainSrc.slice(removeStart, removeEnd < 0 ? undefined : removeEnd);
|
||||
// 窗口留着的话,它下一次保存会把已经删掉的笔记整条写回去
|
||||
assert.match(remove, /noteWindow\.closeFor\(noteId\)/);
|
||||
|
||||
const purgeStart = mainSrc.indexOf("ipcMain.handle('reader:purgeOrphans'");
|
||||
const purgeEnd = mainSrc.indexOf('ipcMain.handle(', purgeStart + 20);
|
||||
assert.match(mainSrc.slice(purgeStart, purgeEnd), /noteWindow\.closeForEntries\(/);
|
||||
|
||||
const removeManyStart = mainSrc.indexOf("ipcMain.handle('library:removeMany'");
|
||||
const removeManyEnd = mainSrc.indexOf('ipcMain.handle(', removeManyStart + 20);
|
||||
assert.match(mainSrc.slice(removeManyStart, removeManyEnd), /noteWindow\.closeForEntries\(list\)/);
|
||||
});
|
||||
|
||||
test('三处窗口广播都覆盖笔记独立窗口', () => {
|
||||
// 漏掉任意一处,笔记窗口就收不到笔记变更或主题切换,界面与其它窗口不一致
|
||||
for (const fn of ['notifyNotesChanged', 'applyWindowIcons', 'notifyUiThemeChanged']) {
|
||||
const start = mainSrc.indexOf(`function ${fn}(`);
|
||||
assert.ok(start > 0, `缺少 ${fn}`);
|
||||
const body = mainSrc.slice(start, start + 420);
|
||||
assert.match(body, /noteWindow\.all\(\)/, `${fn} 未覆盖笔记窗口`);
|
||||
}
|
||||
});
|
||||
|
||||
test('AI 会话按轮次落盘:先取历史再写提问,失败与取消都保留残片', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('ai:run'");
|
||||
assert.ok(start > 0, '缺少 AI 运行通道');
|
||||
const end = mainSrc.indexOf('function settleAiAssistant', start);
|
||||
const run = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||
|
||||
// 顺序是硬约束:先 historyFor 再 appendUser,反了当前提问会被当成自己的历史发两遍
|
||||
const historyAt = run.indexOf('historyFor(');
|
||||
const appendAt = run.indexOf('appendUser(');
|
||||
assert.ok(historyAt > 0 && appendAt > historyAt, '必须在写入本轮提问之前取历史');
|
||||
|
||||
// 同一会话禁止并发,否则两轮同时写同一个文件会互相覆盖
|
||||
assert.match(run, /run\.sessionId\s*===\s*chatId/);
|
||||
|
||||
// 存的是提问本身,不是整篇正文
|
||||
assert.match(run, /text:\s*aiTurnTitle\(task,\s*question\)/);
|
||||
assert.match(run, /hash:\s*aiSessions\.hashContext\(body\)/);
|
||||
|
||||
// 已经流出来的残片必须落盘,界面上看到的半截回答不能一重开就消失
|
||||
assert.match(run, /streamed\s*\+=\s*piece/);
|
||||
assert.match(run, /text:\s*streamed/);
|
||||
assert.ok(!/text:\s*''\s*,\s*\n\s*cancelled/.test(run), '取消时不能把残片写成空串');
|
||||
|
||||
// 落盘失败不能把已经拿到的回答变成请求失败
|
||||
const settleStart = mainSrc.indexOf('function settleAiAssistant');
|
||||
const settle = mainSrc.slice(settleStart, settleStart + 400);
|
||||
assert.match(settle, /try\s*\{[\s\S]*finishAssistant\([\s\S]*catch/);
|
||||
});
|
||||
|
||||
test('阅读器关闭书籍后通知书库刷新,且只接受阅读器发来的上报', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('reader:entryClosed'");
|
||||
assert.ok(start > 0, '缺少关闭上报通道');
|
||||
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
|
||||
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
|
||||
assert.match(segment, /isReaderSender\(event\.sender\)/);
|
||||
assert.match(segment, /notifyLibraryChanged\(\)/);
|
||||
|
||||
const shell = fs.readFileSync(path.join(__dirname, '..', 'ui', 'reader', 'shell.mjs'), 'utf8');
|
||||
// 进度与批注异步落盘,先排空再通知,否则书库读到的还是旧数据
|
||||
const drainTab = shell.indexOf('async function closeTab');
|
||||
const drainEnd = shell.indexOf('\nasync function', drainTab + 20);
|
||||
const closeTabBody = shell.slice(drainTab, drainEnd < 0 ? undefined : drainEnd);
|
||||
assert.ok(closeTabBody.indexOf('await drainTabWrites(tab)') > 0);
|
||||
assert.ok(closeTabBody.indexOf('notifyEntryClosed()') > closeTabBody.indexOf('await drainTabWrites(tab)'),
|
||||
'通知必须排在写入排空之后');
|
||||
|
||||
const drainAll = shell.slice(shell.indexOf('async function drainAllTabWrites'));
|
||||
assert.ok(drainAll.indexOf('await drainTabWrites(tab)') > 0);
|
||||
assert.ok(drainAll.indexOf('notifyEntryClosed()') > drainAll.indexOf('await drainTabWrites(tab)'),
|
||||
'关闭整个窗口也要在排空后通知');
|
||||
});
|
||||
|
||||
test('书库列表附带阅读记录中的最近阅读时间', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
|
||||
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
|
||||
@@ -325,10 +501,10 @@ test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌',
|
||||
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
|
||||
});
|
||||
|
||||
test('内置阅读器允许 PDF、EPUB 和无 DRM Kindle 容器并保留外部回退', () => {
|
||||
test('内置阅读器允许 PDF、EPUB、无 DRM Kindle 容器与纯文本并保留外部回退', () => {
|
||||
assert.match(
|
||||
mainSrc,
|
||||
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/
|
||||
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3', '\.txt', '\.md'\]\)/
|
||||
);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
|
||||
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const adapterFile = path.join(__dirname, '..', 'ui', 'reader', 'pdf-adapter.mjs');
|
||||
const src = fs.readFileSync(adapterFile, 'utf8');
|
||||
|
||||
// pdf-adapter.mjs 直接 import vendor 的 pdf.min.mjs,在 Node 里加载会因缺 DOMMatrix 而抛错,
|
||||
// 所以只把不依赖 DOM 的钳制段落切出来求值,断言的仍是生产源码本身。
|
||||
function loadPureBlock() {
|
||||
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||
const end = src.indexOf('const CSS = `');
|
||||
assert.ok(start >= 0 && end > start, '找不到画布上限段落,钳制代码可能被移动或删除');
|
||||
const block = src.slice(start, end).replace(/^export /gm, '');
|
||||
assert.doesNotMatch(
|
||||
block,
|
||||
/\b(window|document|navigator)\b/,
|
||||
'钳制逻辑必须是纯函数,一旦依赖 DOM 就无法在单测里做真实数值断言'
|
||||
);
|
||||
return new Function(`${block}\nreturn { clampCanvasSize, clampRenderQuality };`)();
|
||||
}
|
||||
|
||||
const { clampCanvasSize, clampRenderQuality } = loadPureBlock();
|
||||
|
||||
const MAX_SIDE = 16384;
|
||||
const MAX_AREA = 268435456;
|
||||
|
||||
function assertWithinLimits(out, label) {
|
||||
assert.ok(
|
||||
out.width <= MAX_SIDE && out.height <= MAX_SIDE,
|
||||
`${label} 单边 ${out.width}x${out.height} 超过 ${MAX_SIDE},浏览器会静默给出不可用画布,整页空白且不报错`
|
||||
);
|
||||
assert.ok(
|
||||
out.width * out.height <= MAX_AREA,
|
||||
`${label} 面积 ${out.width * out.height} 超过 ${MAX_AREA},同样会静默失败`
|
||||
);
|
||||
assert.ok(
|
||||
out.width >= 1 && out.height >= 1,
|
||||
`${label} 钳制后出现 ${out.width}x${out.height},0 尺寸画布会让 getContext 之后的绘制全部丢弃`
|
||||
);
|
||||
}
|
||||
|
||||
test('A4 常规缩放与超采样不触发钳制', () => {
|
||||
// 612x792pt 的 A4 @ scale1.2,倍率 2:1468x1900,远在上限内
|
||||
const out = clampCanvasSize(612 * 1.2, 792 * 1.2, 2);
|
||||
assert.strictEqual(out.clamped, false, '常规页被误钳会白白牺牲清晰度');
|
||||
assert.strictEqual(out.quality, 2, '未触发上限时必须原样保留名义倍率');
|
||||
assert.strictEqual(out.width, 1468, `期望 1468 实际 ${out.width}`);
|
||||
assert.strictEqual(out.height, 1900, `期望 1900 实际 ${out.height}`);
|
||||
assertWithinLimits(out, 'A4@1.2x2');
|
||||
});
|
||||
|
||||
test('A4 最高缩放叠加 dpr3 仍不触发钳制', () => {
|
||||
// 实测 8929x12628(113M)是可用的,钳制不该把这一档也压下去
|
||||
const out = clampCanvasSize(612 * 5, 792 * 5, 3);
|
||||
assert.strictEqual(out.clamped, false, '这一档实测可用,钳制过度会让高缩放反而更糊');
|
||||
assert.strictEqual(out.width, 9180, `期望 9180 实际 ${out.width}`);
|
||||
assert.strictEqual(out.height, 11880, `期望 11880 实际 ${out.height}`);
|
||||
assertWithinLimits(out, 'A4@5x3');
|
||||
});
|
||||
|
||||
test('2000pt 大幅面 @ scale5 dpr2 被钳回上限内', () => {
|
||||
// 期望 backing 20000x14000(280M),实测不可用
|
||||
const out = clampCanvasSize(2000 * 5, 1400 * 5, 2);
|
||||
assert.strictEqual(out.clamped, true, '280M 画布必须被识别为超限,否则用户只看到空白页且没有任何提示');
|
||||
assertWithinLimits(out, '2000pt@5x2');
|
||||
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限以保留最大清晰度,实际 ${out.width}`);
|
||||
assert.ok(out.quality < 2, `生效倍率 ${out.quality} 应低于名义倍率 2`);
|
||||
});
|
||||
|
||||
test('4000pt 图纸 @ scale3 dpr2 被钳回上限内', () => {
|
||||
// 期望 backing 24000x18000(432M),实测不可用
|
||||
const out = clampCanvasSize(4000 * 3, 3000 * 3, 2);
|
||||
assert.strictEqual(out.clamped, true, '432M 画布必须被识别为超限');
|
||||
assertWithinLimits(out, '4000pt@3x2');
|
||||
assert.strictEqual(out.width, MAX_SIDE, `长边应正好压到上限,实际 ${out.width}`);
|
||||
assert.strictEqual(out.height, 12288, `期望 12288 实际 ${out.height}`);
|
||||
});
|
||||
|
||||
test('钳制后的实际比例如实报告,供 render 的 transform 使用', () => {
|
||||
const cssWidth = 4000 * 3;
|
||||
const cssHeight = 3000 * 3;
|
||||
const out = clampCanvasSize(cssWidth, cssHeight, 2);
|
||||
// transform 用名义倍率而不是取整后的实际比例,画面会错位或只画出一角
|
||||
assert.strictEqual(out.scaleX, out.width / cssWidth, 'scaleX 必须等于 backing 宽除以 CSS 宽');
|
||||
assert.strictEqual(out.scaleY, out.height / cssHeight, 'scaleY 必须等于 backing 高除以 CSS 高');
|
||||
assert.ok(
|
||||
Math.abs(out.scaleX - out.scaleY) < 1e-6,
|
||||
`等比钳制下 x/y 比例应基本一致,实际 ${out.scaleX} / ${out.scaleY}`
|
||||
);
|
||||
});
|
||||
|
||||
test('短边的取整损失也要反映到比例上,不能直接沿用生效倍率', () => {
|
||||
// 长边正好压到上限时它的比例恰等于生效倍率,只有短边能暴露 floor 带来的偏差
|
||||
const out = clampCanvasSize(16000, 17000, 1);
|
||||
assert.strictEqual(out.scaleX, out.width / 16000, 'scaleX 必须按 floor 之后的实际宽算');
|
||||
assert.notStrictEqual(
|
||||
out.scaleX,
|
||||
out.quality,
|
||||
`短边 ${out.width}px 的实际比例应当略小于生效倍率 ${out.quality},直接沿用会让内容画出画布边界`
|
||||
);
|
||||
assert.strictEqual(out.scaleY, out.height / 17000, 'scaleY 必须按 floor 之后的实际高算');
|
||||
});
|
||||
|
||||
test('取整误差不会让某一边掉到 0 像素', () => {
|
||||
const out = clampCanvasSize(100000, 1, 1);
|
||||
assertWithinLimits(out, '极端长条页');
|
||||
assert.strictEqual(out.height, 1, `期望至少 1px,实际 ${out.height}`);
|
||||
assert.strictEqual(out.scaleY, 1, 'scaleY 必须按补足到 1px 后的实际比例报告,否则内容会被压到画布外');
|
||||
});
|
||||
|
||||
test('长边超限而面积未超限的页也被钳', () => {
|
||||
// 20000x2000 = 40M,面积远未超限,只有单边超限
|
||||
const out = clampCanvasSize(20000, 2000, 1);
|
||||
assert.strictEqual(out.clamped, true, '只超单边同样不可用,不能只看面积');
|
||||
assertWithinLimits(out, '超长单边');
|
||||
assert.strictEqual(out.width, MAX_SIDE, `期望 ${MAX_SIDE} 实际 ${out.width}`);
|
||||
});
|
||||
|
||||
test('接近正方形的超大页同时压住面积与单边', () => {
|
||||
// 16000x17000 = 272M,面积超限且短边已经贴着上限,是最容易只压一头的形状
|
||||
const out = clampCanvasSize(16000, 17000, 1);
|
||||
assert.strictEqual(out.clamped, true, '272M 面积必须被识别为超限');
|
||||
assertWithinLimits(out, '16000x17000');
|
||||
assert.strictEqual(out.height, MAX_SIDE, `长边应压到上限,实际 ${out.height}`);
|
||||
});
|
||||
|
||||
test('面积约束是真在起作用,而不是被单边约束顺带盖住', () => {
|
||||
// 当前常量下 MAX_AREA 恰好等于 MAX_SIDE 的平方,压住长边就顺带压住了面积,
|
||||
// 面积检查的价值只有在上限常量按别的浏览器口径调整时才显现。
|
||||
// 这里把单边上限换成 65535 重新求值,确认删掉面积项会立刻放出 600M 的画布。
|
||||
const start = src.indexOf('const MAX_CANVAS_SIDE');
|
||||
const end = src.indexOf('const CSS = `');
|
||||
const block = src.slice(start, end)
|
||||
.replace(/^export /gm, '')
|
||||
.replace('const MAX_CANVAS_SIDE = 16384;', 'const MAX_CANVAS_SIDE = 65535;');
|
||||
const wide = new Function(`${block}\nreturn clampCanvasSize;`)();
|
||||
const out = wide(30000, 20000, 1);
|
||||
assert.ok(
|
||||
out.width * out.height <= MAX_AREA,
|
||||
`面积 ${out.width * out.height} 超过 ${MAX_AREA}:面积约束没有独立生效,只靠单边约束挡不住扁平的大幅面页`
|
||||
);
|
||||
assert.strictEqual(out.clamped, true, '面积超限也必须如实上报,否则外壳无法解释清晰度为何被降');
|
||||
});
|
||||
|
||||
test('大范围尺寸与倍率组合下上限恒成立', () => {
|
||||
const sides = [1, 200, 612, 792, 1190, 2384, 5000, 10000, 20000, 40000];
|
||||
const qualities = [1, 1.25, 1.5, 2, 2.5, 3, 4];
|
||||
const scales = [0.25, 1, 1.2, 2, 3, 5];
|
||||
for (const w of sides) {
|
||||
for (const h of sides) {
|
||||
for (const q of qualities) {
|
||||
for (const s of scales) {
|
||||
const out = clampCanvasSize(w * s, h * s, q);
|
||||
const label = `${w}x${h} @scale${s} @${q}x`;
|
||||
assertWithinLimits(out, label);
|
||||
assert.ok(
|
||||
out.quality <= q + 1e-9,
|
||||
`${label}:生效倍率 ${out.quality} 不该超过名义倍率 ${q}`
|
||||
);
|
||||
assert.strictEqual(out.scaleX, out.width / (w * s), `${label}:scaleX 与实际 backing 宽不符`);
|
||||
assert.strictEqual(out.scaleY, out.height / (h * s), `${label}:scaleY 与实际 backing 高不符`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('非法尺寸与倍率退回安全值而不是抛错或产出 NaN', () => {
|
||||
for (const bad of [undefined, null, NaN, 0, -5, 'abc', Infinity]) {
|
||||
const out = clampCanvasSize(bad, bad, bad);
|
||||
assert.ok(
|
||||
Number.isInteger(out.width) && Number.isInteger(out.height),
|
||||
`尺寸 ${String(bad)} 产出了非整数 ${out.width}x${out.height},canvas.width 赋 NaN 会静默变 0`
|
||||
);
|
||||
assertWithinLimits(out, `非法输入 ${String(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderQuality 只接受 1 到 4,非法值回退到 1', () => {
|
||||
assert.strictEqual(clampRenderQuality(1), 1);
|
||||
assert.strictEqual(clampRenderQuality(2), 2);
|
||||
assert.strictEqual(clampRenderQuality(4), 4);
|
||||
assert.strictEqual(clampRenderQuality(1.5), 1.5, '允许非整数档位,1.5x 实测已能显著降低误差');
|
||||
assert.strictEqual(clampRenderQuality(8), 4, '超过 4 倍换不来可感知的清晰度,只会成倍吃显存');
|
||||
assert.strictEqual(clampRenderQuality(0.5), 1, '低于 1 会比现状更糊');
|
||||
for (const bad of [undefined, null, NaN, 'abc', {}, Infinity, -Infinity]) {
|
||||
assert.strictEqual(
|
||||
clampRenderQuality(bad),
|
||||
1,
|
||||
`非法值 ${String(bad)} 必须回退到 1,回退到 NaN 会让整块 backing 计算失效`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('源码锁死画布上限常量与两条约束', () => {
|
||||
assert.match(src, /const MAX_CANVAS_SIDE = 16384;/, 'Chromium 单边上限,改动前必须先实测');
|
||||
assert.match(src, /const MAX_CANVAS_AREA = 268435456;/, 'Chromium 总面积上限');
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.min\(want, bySide, byArea\)/,
|
||||
'单边与面积两条约束都要参与,只留一条在上限常量变动后就会漏放超限画布'
|
||||
);
|
||||
assert.match(src, /MAX_CANVAS_SIDE \/ Math\.max\(cssWidth, cssHeight\)/, '单边约束按长边算');
|
||||
assert.match(src, /Math\.sqrt\(MAX_CANVAS_AREA \/ \(cssWidth \* cssHeight\)\)/, '面积约束按开方算');
|
||||
assert.match(src, /Math\.max\(1, Math\.floor\(cssWidth \* applied\)\)/, '钳制后至少保留 1px');
|
||||
assert.match(src, /Math\.max\(1, Math\.floor\(cssHeight \* applied\)\)/, '钳制后至少保留 1px');
|
||||
});
|
||||
|
||||
test('源码锁死超采样倍率的取值与生效方式', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.max\(1, Math\.min\(4, n\)\)/,
|
||||
'renderQuality 必须被 clamp 到 1..4'
|
||||
);
|
||||
// 一次锁死整条链路:dpr 取大而不是叠乘、期望倍率必须过钳制、画布尺寸只能来自钳制结果
|
||||
assert.match(
|
||||
src,
|
||||
/const dpr = window\.devicePixelRatio \|\| 1;\s*\n\s*const wanted = Math\.max\(dpr, clampRenderQuality\(renderQuality\)\);\s*\n\s*const fit = clampCanvasSize\(vp\.width, vp\.height, wanted\);\s*\n\s*p\.canvas\.width = fit\.width;\s*\n\s*p\.canvas\.height = fit\.height;/,
|
||||
'有效倍率必须是 max(dpr, renderQuality) 且画布尺寸只能取钳制结果,绕过任一步都会重新引入静默空白页'
|
||||
);
|
||||
assert.strictEqual(
|
||||
[...src.matchAll(/Math\.max\(dpr, clampRenderQuality\(renderQuality\)\)/g)].length,
|
||||
2,
|
||||
'renderPage 与 renderStats 必须用同一个有效倍率公式,否则界面报告的清晰度和实际渲染的不一致'
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/transform: identity \? null : \[fit\.scaleX, 0, 0, fit\.scaleY, 0, 0\]/,
|
||||
'transform 必须用钳制后的实际比例,用名义倍率会画错位或只画出一角'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/transform: dpr === 1 \? null : \[dpr, 0, 0, dpr, 0, 0\]/,
|
||||
'旧的 dpr 直接当 transform 的写法在钳制生效时会画错'
|
||||
);
|
||||
});
|
||||
|
||||
test('源码锁死倍率变化触发全页重建', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if \(nextScale !== scale \|\| nextQuality !== renderQuality\) \{\s*\n\s*epoch\+\+;/,
|
||||
'倍率变化不走 epoch++ 重建,同屏会残留旧清晰度的页'
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/nextQuality = opts && opts\.renderQuality !== undefined\s*\n?\s*\? clampRenderQuality\(opts\.renderQuality\)\s*\n?\s*: renderQuality/,
|
||||
'缺省 renderQuality 时必须沿用当前值,否则每次渲染都会把用户设置重置成 1'
|
||||
);
|
||||
assert.match(src, /renderStats\(\)/, 'renderStats 是外壳读取当前清晰度状态的唯一正当出口');
|
||||
assert.doesNotMatch(src, /window\.__test/, '不允许为测试往生产代码加全局钩子');
|
||||
});
|
||||
|
||||
test('AI 截图路径同样受画布上限保护', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const fit = clampCanvasSize\(area\.width, area\.height, wantScale\);/,
|
||||
'大幅面页在 renderScale 下限 1 时画布等于页面点尺寸,MediaBox 异常的文件会顶到上限'
|
||||
);
|
||||
assert.match(src, /const renderScale = fit\.quality;/, '截图的 viewport 与 transform 必须用钳制后的倍率');
|
||||
assert.match(src, /canvas\.width = fit\.width;/, '截图画布尺寸直接取钳制结果');
|
||||
// 图像只保留一条 JPEG 编码路径,钳制不该顺手引入格式回退
|
||||
assert.deepStrictEqual(
|
||||
[...src.matchAll(/toDataURL\('([^']+)'/g)].map((m) => m[1]),
|
||||
[],
|
||||
'编码仍应集中在 visual-context.mjs,适配器里不该出现新的编码路径'
|
||||
);
|
||||
});
|
||||
@@ -93,6 +93,34 @@ test('不同条目的阅读数据互相隔离', () => {
|
||||
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 } });
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
// TXT / Markdown 适配器单测。适配器本体是渲染层 ESM,这里用 jsdom 提供真实的
|
||||
// window / document / DOMParser,并加载仓库里真正会随包发布的 vendor 脚本,
|
||||
// 这样断言的是「真正交给 epub 适配器渲染的产物」,而不是桩。
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SRC = path.join(__dirname, '..', 'ui', 'reader', 'text-adapter.mjs');
|
||||
const VENDOR = path.join(__dirname, '..', 'ui', 'vendor');
|
||||
const XHTML = 'application/xhtml+xml';
|
||||
|
||||
function setupDom() {
|
||||
const { JSDOM } = require('jsdom');
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
||||
const win = dom.window;
|
||||
win.JSZip = require(path.join(VENDOR, 'jszip.min.js'));
|
||||
win.markdownit = require(path.join(VENDOR, 'markdown-it.min.js'));
|
||||
const purifyFactory = require(path.join(VENDOR, 'purify.min.js'));
|
||||
win.DOMPurify = typeof purifyFactory === 'function' ? purifyFactory(win) : purifyFactory;
|
||||
for (const key of ['window', 'document', 'DOMParser', 'XMLSerializer', 'NodeFilter', 'Node', 'Range']) {
|
||||
globalThis[key] = key === 'window' ? win : win[key];
|
||||
}
|
||||
return win;
|
||||
}
|
||||
|
||||
const win = setupDom();
|
||||
const mod = import('../ui/reader/text-adapter.mjs');
|
||||
|
||||
function u8(...parts) {
|
||||
const buffers = parts.map((part) => (typeof part === 'string'
|
||||
? Buffer.from(part, 'utf8')
|
||||
: Buffer.from(part)));
|
||||
return new Uint8Array(Buffer.concat(buffers));
|
||||
}
|
||||
|
||||
function utf16Bytes(text, littleEndian, bom) {
|
||||
const out = [];
|
||||
if (bom) out.push(...(littleEndian ? [0xff, 0xfe] : [0xfe, 0xff]));
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
const hi = code >> 8;
|
||||
const lo = code & 0xff;
|
||||
out.push(...(littleEndian ? [lo, hi] : [hi, lo]));
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
// tidy 与 epub 适配器里的同名函数一致,textOf 返回的是 tidy 之后的文本
|
||||
function tidy(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/\r/g, '')
|
||||
.replace(/[ \t\f\v\u00a0]+/g, ' ')
|
||||
.replace(/ ?\n ?/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function novel(sections) {
|
||||
const parts = [];
|
||||
for (let i = 1; i <= sections; i++) {
|
||||
parts.push(`第${i}章 标题${i}\n【标记${String(i).padStart(4, '0')}】这一节的正文内容,用来验证全文不缺不重。\n`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
async function readChapter(zipBytes, index) {
|
||||
const zip = await win.JSZip.loadAsync(zipBytes);
|
||||
const file = zip.file(`text/chapter-${index}.xhtml`);
|
||||
assert.ok(file, `产物里应有 text/chapter-${index}.xhtml`);
|
||||
return file.async('text');
|
||||
}
|
||||
|
||||
function parseXhtml(text) {
|
||||
const doc = new win.DOMParser().parseFromString(text, XHTML);
|
||||
assert.ok(doc && doc.body && !doc.querySelector('parsererror'),
|
||||
'生成的章节必须是合法 XHTML,否则 epub 适配器会退回容错 HTML 解析,行为不可预测');
|
||||
return doc;
|
||||
}
|
||||
|
||||
function auditUntrusted(doc, where) {
|
||||
for (const selector of ['script', 'iframe', 'object', 'embed', 'img', 'link', 'meta', 'form', 'base', 'a']) {
|
||||
assert.strictEqual(doc.querySelectorAll(selector).length, 0,
|
||||
`${where} 不允许出现 <${selector}>:这是不可信内容的注入面`);
|
||||
}
|
||||
for (const el of doc.querySelectorAll('*')) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
assert.ok(!name.startsWith('on'),
|
||||
`${where} 出现事件属性 ${name},脚本会在阅读器里执行`);
|
||||
assert.ok(!['href', 'src', 'xlink:href', 'srcset', 'poster', 'style', 'formaction'].includes(name),
|
||||
`${where} 出现可发起加载或导航的属性 ${name}`);
|
||||
assert.ok(!/(?:javascript|vbscript|data|file):/i.test(attr.value),
|
||||
`${where} 属性 ${name} 里出现危险协议`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 编码检测与解码 --- */
|
||||
|
||||
test('UTF-8 与 UTF-16 的 BOM 都能正确剥离,BOM 不得留在正文里', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
const text = '中文标题\n正文第一行\n';
|
||||
|
||||
const plain = decodeTextBytes(u8(text));
|
||||
assert.strictEqual(plain.text, text, 'UTF-8 无 BOM 必须原样解出');
|
||||
assert.strictEqual(plain.encoding, 'utf-8');
|
||||
assert.strictEqual(plain.confident, true);
|
||||
|
||||
const withBom = decodeTextBytes(u8([0xef, 0xbb, 0xbf], text));
|
||||
assert.strictEqual(withBom.text, text, 'UTF-8 BOM 必须被剥掉,留着会变成正文首个不可见字符');
|
||||
assert.ok(!withBom.text.includes('\ufeff'), 'U+FEFF 不能出现在正文里');
|
||||
|
||||
const le = decodeTextBytes(utf16Bytes(text, true, true));
|
||||
assert.strictEqual(le.text, text, 'UTF-16LE 带 BOM 必须正确解出中文');
|
||||
assert.strictEqual(le.encoding, 'utf-16le');
|
||||
assert.ok(!le.text.includes('\ufeff'));
|
||||
|
||||
const be = decodeTextBytes(utf16Bytes(text, false, true));
|
||||
assert.strictEqual(be.text, text, 'UTF-16BE 带 BOM 必须正确解出中文');
|
||||
assert.strictEqual(be.encoding, 'utf-16be');
|
||||
assert.ok(!be.text.includes('\ufeff'));
|
||||
});
|
||||
|
||||
test('双 BOM 也要剥干净:TextDecoder 只吃掉第一个', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
// 某些编辑器另存为会叠一层 BOM。TextDecoder 只认最前面那个,第二个会变成正文首字符,
|
||||
// 章节标题匹配随即失效,目录莫名少一章。
|
||||
const text = '第1章 标题\n正文\n';
|
||||
assert.strictEqual(decodeTextBytes(u8([0xef, 0xbb, 0xbf], [0xef, 0xbb, 0xbf], text)).text, text);
|
||||
const le = utf16Bytes(text, true, true);
|
||||
assert.strictEqual(decodeTextBytes(u8([0xff, 0xfe], le)).text, text);
|
||||
});
|
||||
|
||||
test('无 BOM 的 UTF-16LE 靠 NUL 分布嗅探,不能当成乱码', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
const text = 'Chapter 1\nHello world, this is plain ASCII text stored as UTF-16LE.\n';
|
||||
const out = decodeTextBytes(utf16Bytes(text, true, false));
|
||||
assert.strictEqual(out.encoding, 'utf-16le');
|
||||
assert.strictEqual(out.text, text);
|
||||
});
|
||||
|
||||
test('GBK 字节走 gb18030 解码,不产生替换字符', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
// 「这是一段简体中文测试」的 GBK 编码
|
||||
const gbk = new Uint8Array([
|
||||
0xd5, 0xe2, 0xca, 0xc7, 0xd2, 0xbb, 0xb6, 0xce, 0xbc, 0xf2,
|
||||
0xcc, 0xe5, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4,
|
||||
0x0a
|
||||
]);
|
||||
const out = decodeTextBytes(gbk);
|
||||
assert.strictEqual(out.text, '这是一段简体中文测试\n', 'GBK 必须解成可读中文');
|
||||
assert.strictEqual(out.encoding, 'gb18030');
|
||||
assert.strictEqual(out.confident, true);
|
||||
assert.ok(!out.text.includes('\ufffd'), '解对了就不该有 U+FFFD');
|
||||
});
|
||||
|
||||
test('解不出任何像样编码时标记 confident=false,不让用户对着乱码猜', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
// 0x81 后跟 0x1f 在 GBK / Big5 里都是非法尾字节,cp1252 里则落进 C1 控制区
|
||||
const noise = new Uint8Array(512);
|
||||
for (let i = 0; i < noise.length; i++) noise[i] = i % 2 ? 0x1f : 0x81;
|
||||
const out = decodeTextBytes(noise);
|
||||
assert.strictEqual(out.confident, false, '低置信度必须上报,否则界面会静默显示乱码');
|
||||
|
||||
for (const [name, bytes] of [
|
||||
['UTF-8 中文', u8('正常的中文文本内容。\n')],
|
||||
['GBK 中文', new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0x0a])],
|
||||
['纯 ASCII', u8('Plain english text.\n')]
|
||||
]) {
|
||||
assert.strictEqual(decodeTextBytes(bytes).confident, true,
|
||||
`${name} 不能被误判成低置信度,否则提示会变成噪声,用户就不再看它了`);
|
||||
}
|
||||
});
|
||||
|
||||
test('打分只认确定是正文的字符,拉丁扩展区乱码一律不得正分', async () => {
|
||||
const { textScore, decodeTextBytes } = await mod;
|
||||
const chinese = '这是一段正常的简体中文正文内容。';
|
||||
const mojibake = Buffer.from(chinese, 'utf8').toString('latin1');
|
||||
assert.ok(textScore(chinese) > 0, 'ASCII 与中文正文必须是正分');
|
||||
assert.ok(textScore('Plain ASCII sentence.') > 0, 'ASCII 正文必须是正分');
|
||||
|
||||
// 乱码必须 <= 0 而不只是「低于正确解码」。一个汉字按 cp1252 摊成三个拉丁扩展字符,
|
||||
// 只要这些字符拿到任何正分,乱码就靠字符数优势翻盘:实测把 0xa0..0x24f 记 1 分后,
|
||||
// 同一段 GBK 正文的 windows-1252 得分从 0 涨到 320,与 gb18030 打平并靠顺序取胜。
|
||||
for (const [name, text] of [
|
||||
['UTF-8 中文按 cp1252 解出的乱码', mojibake],
|
||||
['纯拉丁扩展区噪声', new TextDecoder('windows-1252')
|
||||
.decode(new Uint8Array(Array.from({ length: 300 }, (_, i) => 0xa0 + (i % 0x40))))]
|
||||
]) {
|
||||
assert.ok(textScore(text) <= 0,
|
||||
`${name} 得分为 ${textScore(text)},必须 <= 0,否则乱码会冒充识别成功`);
|
||||
}
|
||||
|
||||
const gbk = new Uint8Array([0xd5, 0xe2, 0xca, 0xc7, 0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4, 0x0a]);
|
||||
assert.strictEqual(decodeTextBytes(gbk).encoding, 'gb18030',
|
||||
'简体中文是本应用主场,不能被 windows-1252 的乱码抢走');
|
||||
});
|
||||
|
||||
test('空文件与超限文件都有确定行为', async () => {
|
||||
const { decodeTextBytes, MAX_TEXT_BYTES } = await mod;
|
||||
const empty = decodeTextBytes(new Uint8Array(0));
|
||||
assert.strictEqual(empty.text, '');
|
||||
assert.strictEqual(empty.confident, true);
|
||||
assert.throws(
|
||||
() => decodeTextBytes(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||
/超过 64 MB/,
|
||||
'超限必须抛中文错误,界面直接展示这句话'
|
||||
);
|
||||
});
|
||||
|
||||
test('CR LF 归一,C0 控制符被剔除', async () => {
|
||||
const { decodeTextBytes } = await mod;
|
||||
const out = decodeTextBytes(u8('第一行\r\n第二行\r第三行\u0000\u0007\n'));
|
||||
assert.strictEqual(out.text, '第一行\n第二行\n第三行\n',
|
||||
'C0 控制符留在正文会让生成的 XHTML 解析失败,正文随即退回容错解析');
|
||||
const tabbed = decodeTextBytes(u8('列一\t列二\n'));
|
||||
assert.strictEqual(tabbed.text, '列一\t列二\n', 'Tab 是合法 XML 字符,不能顺手删掉');
|
||||
});
|
||||
|
||||
/* --- 章节切分 --- */
|
||||
|
||||
test('TXT 按中文章节标题切分,边界落在标题行首', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const source = novel(6);
|
||||
const out = splitPlainText(source);
|
||||
assert.strictEqual(out.chapters.length, 6, '6 个「第N章」应切成 6 章');
|
||||
assert.deepStrictEqual(out.chapters.map((c) => c.label),
|
||||
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6'],
|
||||
'章节名必须取自正文里的标题行');
|
||||
assert.strictEqual(out.chapters[0].start, 0);
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source,
|
||||
'各章拼回来必须与原文逐字相同,否则全文一定缺字或重字');
|
||||
for (const chapter of out.chapters) {
|
||||
assert.strictEqual(source.slice(chapter.start, chapter.end), chapter.text, '偏移必须与切片一致');
|
||||
}
|
||||
});
|
||||
|
||||
test('第一个章节标题之前的正文单独成章,不能被丢掉', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const preamble = '书名:某本小说\n作者:某人\n版权声明若干。\n\n';
|
||||
const source = preamble + novel(4);
|
||||
const out = splitPlainText(source);
|
||||
assert.strictEqual(out.chapters.length, 5, '开头 + 4 章 = 5 章');
|
||||
assert.strictEqual(out.chapters[0].label, '开头');
|
||||
assert.strictEqual(out.chapters[0].text, preamble,
|
||||
'首个标题之前的内容必须完整保留,否则序言与版权页在全文里凭空消失');
|
||||
assert.strictEqual(out.chapters[1].label, '第1章 标题1');
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||
});
|
||||
|
||||
test('标题不足三个时不认,按长度分节,序号从 1 开始', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const source = `第1章 只有一个标题\n${'一二三四五六七八九十'.repeat(200)}\n`.repeat(1);
|
||||
const out = splitPlainText(source, { target: 500 });
|
||||
assert.ok(out.chapters.length >= 1);
|
||||
assert.match(out.chapters[0].label, /^第 1 节$/, '未识别到章节结构时用「第 N 节」');
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||
});
|
||||
|
||||
test('空文件、纯空白、单行超长都不崩且能拼回原文', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const empty = splitPlainText('');
|
||||
assert.strictEqual(empty.chapters.length, 1);
|
||||
assert.strictEqual(empty.chapters[0].text, '');
|
||||
assert.strictEqual(empty.chapters[0].label, '正文');
|
||||
|
||||
const blank = splitPlainText(' \n\n\t \n');
|
||||
assert.ok(blank.chapters.length >= 1);
|
||||
assert.strictEqual(blank.chapters.map((c) => c.text).join(''), ' \n\n\t \n');
|
||||
|
||||
const huge = 'x'.repeat(200000);
|
||||
const one = splitPlainText(huge, { target: 1000, max: 5000 });
|
||||
assert.ok(one.chapters.length > 1, '没有换行的超长文本也必须切开,否则单章会撑爆 DOM');
|
||||
assert.strictEqual(one.chapters.map((c) => c.text).join(''), huge);
|
||||
assert.ok(one.chapters.every((c) => c.text.length <= 5000), '每章不得超过上限');
|
||||
assert.ok(one.chapters.slice(1).every((c) => /(续)$/.test(c.label)), '硬切出来的后续片段要标注(续)');
|
||||
});
|
||||
|
||||
test('章节数不超过上限,避免打出上万个 zip 条目', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
const source = novel(400);
|
||||
const out = splitPlainText(source, { maxChapters: 50 });
|
||||
assert.ok(out.chapters.length <= 50, `实际 ${out.chapters.length} 章,超过 maxChapters`);
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source, '合并章节不能丢正文');
|
||||
});
|
||||
|
||||
/* --- Markdown 结构 --- */
|
||||
|
||||
test('Markdown 标题生成正确的目录层级', async () => {
|
||||
const { createMarkdownRenderer, splitMarkdown, buildTocEntries, markdownHeadings } = await mod;
|
||||
const md = createMarkdownRenderer(win.markdownit);
|
||||
const source = [
|
||||
'# 总标题', '', '开场白。', '',
|
||||
'## 第一节', '', '内容一。', '',
|
||||
'### 一点一', '', '内容二。', '',
|
||||
'## 第二节', '', '内容三。', '',
|
||||
'```', '# 代码块里的井号不是标题', '```', ''
|
||||
].join('\n');
|
||||
|
||||
const headings = markdownHeadings(source, md);
|
||||
assert.deepStrictEqual(headings.map((h) => [h.level, h.label]), [
|
||||
[1, '总标题'], [2, '第一节'], [3, '一点一'], [2, '第二节']
|
||||
], '代码块里的 # 不能被当成标题');
|
||||
|
||||
const split = splitMarkdown(source, md);
|
||||
assert.deepStrictEqual(split.chapters.map((c) => c.label),
|
||||
['总标题', '第一节', '第二节'], '默认按 h1/h2 切章,h3 留在章内');
|
||||
assert.strictEqual(split.title, '总标题');
|
||||
assert.strictEqual(split.chapters.map((c) => c.text).join(''), source);
|
||||
|
||||
const { entries, anchorsByChapter } = buildTocEntries(split.chapters, split.headings, split.splitLevel);
|
||||
assert.deepStrictEqual(entries.map((e) => [e.label, e.depth, e.chapter]), [
|
||||
['总标题', 0, 0], ['第一节', 1, 1], ['一点一', 2, 1], ['第二节', 1, 2]
|
||||
], 'depth 必须等于标题级别减一,目录才能正确缩进');
|
||||
assert.strictEqual(anchorsByChapter.get(1)[0], '', '章首标题跳章即可,不需要锚点');
|
||||
assert.ok(anchorsByChapter.get(1)[1], '章内标题必须有锚点,否则目录只能跳到章首');
|
||||
});
|
||||
|
||||
test('锚点数量与渲染出的标题数不符时不打 id,避免锚点指到错误标题', async () => {
|
||||
const { createMarkdownRenderer, markdownChapterXhtml, resolveServices } = await mod;
|
||||
const md = createMarkdownRenderer(win.markdownit);
|
||||
const services = resolveServices();
|
||||
const chapter = { label: '甲', text: '# 一\n\n正文\n\n## 二\n', start: 0, end: 20 };
|
||||
|
||||
const mismatched = markdownChapterXhtml(chapter, services, md, ['only-one']);
|
||||
assert.ok(!/\bid="/.test(mismatched),
|
||||
'章节边界可能落进代码围栏,使章内真实标题数与预估不符;此时按序号打 id 会让目录跳到错误的标题,宁可放弃锚点');
|
||||
assert.strictEqual(parseXhtml(mismatched).querySelectorAll('h1,h2').length, 2, '放弃锚点不等于放弃正文');
|
||||
|
||||
const matched = markdownChapterXhtml(chapter, services, md, ['a1', 'a2']);
|
||||
const ids = Array.from(parseXhtml(matched).querySelectorAll('[id]')).map((el) => el.getAttribute('id'));
|
||||
assert.deepStrictEqual(ids, ['a1', 'a2'], '数量对得上时必须按文档顺序打 id');
|
||||
});
|
||||
|
||||
test('没有 h1/h2 的 Markdown 回退到按长度分节,标题列表仍然可用', async () => {
|
||||
const { createMarkdownRenderer, splitMarkdown } = await mod;
|
||||
const md = createMarkdownRenderer(win.markdownit);
|
||||
const source = `### 只有三级标题\n\n${'正文内容。'.repeat(50)}\n`;
|
||||
const out = splitMarkdown(source, md);
|
||||
assert.ok(out.chapters.length >= 1, '无 h1/h2 也要有章节');
|
||||
assert.strictEqual(out.chapters.map((c) => c.text).join(''), source);
|
||||
assert.strictEqual(out.headings.length, 1, 'h3 仍应出现在目录里');
|
||||
assert.strictEqual(out.title, '只有三级标题');
|
||||
|
||||
const bare = splitMarkdown('只有一行正文,没有任何标题。', md);
|
||||
assert.strictEqual(bare.chapters.length, 1);
|
||||
assert.ok(bare.chapters[0].label, '无标题时也要有可展示的章节名');
|
||||
});
|
||||
|
||||
test('目录嵌套标记闭合正确,epub 目录解析器按 li > ol 递归', async () => {
|
||||
const { navMarkup } = await mod;
|
||||
const html = navMarkup([
|
||||
{ label: 'A', depth: 0, href: 'a' },
|
||||
{ label: 'A1', depth: 1, href: 'a1' },
|
||||
{ label: 'A2', depth: 1, href: 'a2' },
|
||||
{ label: 'B', depth: 0, href: 'b' }
|
||||
]);
|
||||
assert.strictEqual(html,
|
||||
'<ol><li><a href="a">A</a><ol><li><a href="a1">A1</a></li><li><a href="a2">A2</a></li></ol></li><li><a href="b">B</a></li></ol>');
|
||||
const doc = new win.DOMParser().parseFromString(`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||
assert.ok(!doc.querySelector('parsererror'), '目录标记必须是合法 XHTML');
|
||||
assert.strictEqual(doc.querySelectorAll('li').length, 4);
|
||||
assert.strictEqual(doc.querySelectorAll('li > ol > li').length, 2, '子级必须挂在父级 li 里');
|
||||
|
||||
const skipped = navMarkup([{ label: 'X', depth: 3, href: 'x' }]);
|
||||
assert.strictEqual(skipped, '<ol><li><a href="x">X</a></li></ol>', '首条深度跳跃要收敛到顶层,不能生成孤立 ol');
|
||||
});
|
||||
|
||||
test('任意 depth 序列生成的目录都是闭合的合法 XHTML,且条目一个不少', async () => {
|
||||
const { navMarkup } = await mod;
|
||||
const cases = [
|
||||
[0], [1], [0, 1, 2, 3, 2, 1, 0], [2, 0, 2], [0, 0, 0],
|
||||
[0, 2, 1, 3, 0], [1, 1, 0, 5, 0], [3, 3, 3], []
|
||||
];
|
||||
for (const depths of cases) {
|
||||
const entries = depths.map((depth, i) => ({ label: `L${i}`, depth, href: `h${i}` }));
|
||||
const html = navMarkup(entries);
|
||||
const doc = new win.DOMParser().parseFromString(
|
||||
`<div xmlns="http://www.w3.org/1999/xhtml">${html}</div>`, XHTML);
|
||||
assert.ok(!doc.querySelector('parsererror'),
|
||||
`depth 序列 [${depths}] 生成了非法 XHTML:${html};标记不闭合会让整个 nav.xhtml 解析失败,目录直接退化成按章编号`);
|
||||
assert.strictEqual(doc.querySelectorAll('li').length, depths.length,
|
||||
`depth 序列 [${depths}] 的目录条目数应为 ${depths.length}`);
|
||||
assert.strictEqual(doc.querySelectorAll('ol > li').length, depths.length,
|
||||
`depth 序列 [${depths}] 里每个 li 都必须直接挂在 ol 下`);
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 净化 --- */
|
||||
|
||||
const EVIL_MARKDOWN = [
|
||||
'# 标题',
|
||||
'',
|
||||
'<script>alert(1)</script>',
|
||||
'',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'',
|
||||
'[链接](javascript:alert(1))',
|
||||
'',
|
||||
'<iframe src="http://evil.example"></iframe>',
|
||||
'',
|
||||
'<div onmouseover="alert(1)">悬停</div>',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'[正常外链](https://example.com/page)',
|
||||
'',
|
||||
'<svg><use xlink:href="http://evil.example/x#a" /></svg>',
|
||||
''
|
||||
].join('\n');
|
||||
|
||||
test('sanitizeMarkdownFragment 直接吃裸 HTML 也必须净化干净', async () => {
|
||||
const { sanitizeMarkdownFragment, resolveServices } = await mod;
|
||||
const services = resolveServices();
|
||||
const fragment = sanitizeMarkdownFragment([
|
||||
'<h1 id="x">标题</h1>',
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<iframe src="http://evil.example"></iframe>',
|
||||
'<a href="javascript:alert(1)">链接</a>',
|
||||
'<p style="background:url(http://evil.example/x)">样式</p>',
|
||||
'<object data="x"></object><embed src="x" /><form action="x"></form>',
|
||||
'<p onclick="alert(1)">点我</p>'
|
||||
].join(''), services);
|
||||
const host = win.document.createElement('div');
|
||||
host.appendChild(fragment);
|
||||
const doc = parseXhtml(`<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head><body>${new win.XMLSerializer().serializeToString(host)}</body></html>`);
|
||||
auditUntrusted(doc, '净化后的片段');
|
||||
assert.ok(doc.body.textContent.includes('标题'), '净化不能把正常正文一起吃掉');
|
||||
});
|
||||
|
||||
test('恶意 Markdown 渲染出的章节里没有可执行内容', async () => {
|
||||
const { buildTextEpub } = await mod;
|
||||
const built = await buildTextEpub(u8(EVIL_MARKDOWN), { format: 'md' });
|
||||
const xhtml = await readChapter(built.bytes, 0);
|
||||
const doc = parseXhtml(xhtml);
|
||||
auditUntrusted(doc, 'Markdown 章节');
|
||||
|
||||
const text = doc.body.textContent;
|
||||
assert.ok(text.includes('alert(1)'),
|
||||
'危险内容应被转义成可见文字而不是静默删除,用户才知道原文写了什么');
|
||||
assert.ok(text.includes('图片:图注'), '图片必须换成占位符,绝不外链加载');
|
||||
assert.ok(text.includes('正常外链'), '外链文字要保留,只是不再可点');
|
||||
assert.ok(!/<script|<iframe|<img|<svg/i.test(xhtml), '序列化结果里不允许出现这些标签');
|
||||
});
|
||||
|
||||
test('源码锁死两道防线:markdown-it html:false 与放行名单里没有 href/src', async () => {
|
||||
const src = fs.readFileSync(SRC, 'utf8');
|
||||
assert.match(src, /markdownit\(\{[\s\S]{0,400}?html: false/,
|
||||
'html:false 是第一道防线,去掉后裸 HTML 会直接进入渲染管线');
|
||||
const allowed = src.match(/const ALLOWED_ATTR = Object\.freeze\(\[([^\]]*)\]\)/);
|
||||
assert.ok(allowed, '必须显式声明放行属性名单');
|
||||
assert.ok(!/href|src|style|srcset|on[a-z]/i.test(allowed[1]),
|
||||
`放行名单不能含加载或导航类属性,当前为 ${allowed[1]}`);
|
||||
assert.match(src, /RETURN_DOM_FRAGMENT: true/, '必须以片段形式取回净化结果,避免二次解析引入 mXSS');
|
||||
});
|
||||
|
||||
/* --- 端到端:契约方法 --- */
|
||||
|
||||
test('TXT 走完整管线:章节数、目录、locator kind 与全文', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const source = novel(6);
|
||||
const adapter = createTextAdapter('txt');
|
||||
const progress = [];
|
||||
try {
|
||||
const info = await adapter.load(u8(source), { onProgress: (p) => progress.push(p) });
|
||||
assert.strictEqual(info.format, 'txt');
|
||||
assert.strictEqual(info.mode, 'plain');
|
||||
assert.strictEqual(info.encoding, 'utf-8');
|
||||
assert.strictEqual(info.chapterCount, 6);
|
||||
assert.strictEqual(info.title, '未命名文本',
|
||||
'首行本身是章节标题时不能拿来当书名,否则书库标题会变成「第1章」');
|
||||
assert.ok(progress.length && progress[progress.length - 1] === 1, 'onProgress 必须走到 1');
|
||||
assert.ok(progress.every((p) => p >= 0 && p <= 1), '进度必须落在 0..1');
|
||||
|
||||
const toc = await adapter.toc();
|
||||
assert.strictEqual(toc.length, 6);
|
||||
assert.deepStrictEqual(toc.map((t) => t.label),
|
||||
['第1章 标题1', '第2章 标题2', '第3章 标题3', '第4章 标题4', '第5章 标题5', '第6章 标题6']);
|
||||
assert.deepStrictEqual(toc.map((t) => t.locator.chapter), [0, 1, 2, 3, 4, 5]);
|
||||
for (const entry of toc) {
|
||||
assert.strictEqual(entry.locator.kind, 'txt', 'locator.kind 必须是 txt,外壳靠它判断书签与笔记');
|
||||
assert.strictEqual(entry.depth, 0);
|
||||
}
|
||||
|
||||
const label = adapter.locatorLabel({ kind: 'txt', chapter: 2, offset: 0 });
|
||||
assert.strictEqual(label, '第3章 标题3');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('首行是书名时取作标题,是章节标题时不取', async () => {
|
||||
const { splitPlainText } = await mod;
|
||||
assert.strictEqual(splitPlainText(`某本小说的书名\n\n${novel(4)}`).title, '某本小说的书名');
|
||||
assert.strictEqual(splitPlainText(novel(4)).title, '',
|
||||
'首行是「第1章」时留空,交由上层用文件名兜底');
|
||||
});
|
||||
|
||||
test("textOf(locator, 'document') 返回全文,不缺不重", async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const source = novel(8);
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(source));
|
||||
const full = await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||
assert.strictEqual(full, tidy(source),
|
||||
'AI 的「全文」范围完全依赖这条,少一个字就是静默数据丢失');
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const token = `【标记${String(i).padStart(4, '0')}】`;
|
||||
assert.strictEqual(full.split(token).length - 1, 1, `${token} 必须恰好出现一次`);
|
||||
}
|
||||
const positions = [];
|
||||
for (let i = 1; i <= 8; i++) positions.push(full.indexOf(`【标记${String(i).padStart(4, '0')}】`));
|
||||
assert.deepStrictEqual(positions, [...positions].sort((a, b) => a - b), '全文顺序必须与原文一致');
|
||||
|
||||
const one = await adapter.textOf({ kind: 'txt', chapter: 3, offset: 0 }, 'chapter');
|
||||
assert.ok(one.includes('【标记0004】'), '按章取文必须取到对应章');
|
||||
assert.ok(!one.includes('【标记0005】'), '按章取文不得越界');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown 走完整管线:标题渲染成 h1/h2,代码块与表格保留', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const source = [
|
||||
'# 文档标题', '', '开场白。', '',
|
||||
'## 列表与代码', '',
|
||||
'- 第一项', '- 第二项', '',
|
||||
'```js', 'const x = 1;', '```', '',
|
||||
'> 引用一句话', '',
|
||||
'| 列一 | 列二 |', '| --- | --- |', '| 1 | 2 |', '',
|
||||
'**加粗**与 `行内代码`。', ''
|
||||
].join('\n');
|
||||
const adapter = createTextAdapter('md');
|
||||
try {
|
||||
const info = await adapter.load(u8(source));
|
||||
assert.strictEqual(info.format, 'md');
|
||||
assert.strictEqual(info.mode, 'markdown');
|
||||
assert.strictEqual(info.title, '文档标题');
|
||||
assert.strictEqual(info.chapterCount, 2);
|
||||
|
||||
const toc = await adapter.toc();
|
||||
assert.deepStrictEqual(toc.map((t) => [t.label, t.depth]), [['文档标题', 0], ['列表与代码', 1]]);
|
||||
assert.strictEqual(toc[0].locator.kind, 'md', 'Markdown 的 locator.kind 必须是 md');
|
||||
|
||||
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||
for (const piece of ['文档标题', '开场白。', '第一项', '第二项', 'const x = 1;', '引用一句话', '列一', '加粗', '行内代码']) {
|
||||
assert.ok(full.includes(piece), `全文里必须有「${piece}」,Markdown 渲染不能吞内容`);
|
||||
}
|
||||
assert.ok(!full.includes('```'), 'Markdown 记号应被渲染掉而不是原样留在正文里');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('渲染后的块级元素在全文里彼此分行,AI 不会拿到糊成一团的文本', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('md');
|
||||
try {
|
||||
// 标题与紧随其后的正文之间原文没有空行,最容易被拼成「标题甲正文乙」
|
||||
await adapter.load(u8('# 标题甲\n正文乙\n\n## 标题丙\n正文丁\n\n- 列表戊\n- 列表己\n'));
|
||||
const full = await adapter.textOf({ kind: 'md', chapter: 0, offset: 0 }, 'document');
|
||||
for (const [a, b] of [['标题甲', '正文乙'], ['标题丙', '正文丁'], ['列表戊', '列表己']]) {
|
||||
assert.ok(!full.includes(a + b),
|
||||
`「${a}」与「${b}」被拼成了一个词,块级元素之间必须留分隔符,否则送给模型的全文语义错乱`);
|
||||
assert.ok(new RegExp(`${a}\\n+${b}`).test(full), `「${a}」与「${b}」之间应有换行`);
|
||||
}
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown 章节渲染成真正的块级结构,不是纯文本', async () => {
|
||||
const { buildTextEpub } = await mod;
|
||||
const built = await buildTextEpub(u8([
|
||||
'# 标题', '', '- 项', '', '```js', 'const x = 1;', '```', '',
|
||||
'> 引用', '', '| a | b |', '| - | - |', '| 1 | 2 |', ''
|
||||
].join('\n')), { format: 'md' });
|
||||
const doc = parseXhtml(await readChapter(built.bytes, 0));
|
||||
assert.strictEqual(doc.querySelectorAll('h1').length, 1, 'Markdown 标题必须渲染成 h1');
|
||||
assert.strictEqual(doc.querySelectorAll('ul > li').length, 1);
|
||||
assert.strictEqual(doc.querySelectorAll('pre > code').length, 1);
|
||||
assert.strictEqual(doc.querySelectorAll('blockquote').length, 1);
|
||||
assert.strictEqual(doc.querySelectorAll('table td').length, 2);
|
||||
});
|
||||
|
||||
test('超大 Markdown 降级为纯文本,不让 markdown-it 整篇解析', async () => {
|
||||
const { buildTextEpub, MARKDOWN_MAX_CHARS } = await mod;
|
||||
const over = `# 标题\n\n${'abcde\n'.repeat(Math.ceil(MARKDOWN_MAX_CHARS / 6) + 100)}`;
|
||||
assert.ok(over.length > MARKDOWN_MAX_CHARS);
|
||||
const built = await buildTextEpub(u8(over), { format: 'md' });
|
||||
assert.strictEqual(built.mode, 'plain', '超限时必须降级,否则整篇解析的开销与内存都不可控');
|
||||
const under = await buildTextEpub(u8('# 标题\n\n正文。\n'), { format: 'md' });
|
||||
assert.strictEqual(under.mode, 'markdown', '正常体量的 md 必须走渲染路径');
|
||||
});
|
||||
|
||||
test('几十 MB 的 txt 切成小章,单章体积可控', async () => {
|
||||
const { buildTextEpub, splitPlainText } = await mod;
|
||||
const block = `第N章 标题\n${'这一段是压力测试用的中文正文。'.repeat(10)}\n\n`;
|
||||
const source = block.repeat(2000).replace(/第N章/g, () => '第1章');
|
||||
const split = splitPlainText(source);
|
||||
assert.ok(split.chapters.length <= 4000, '章节数必须有上限,否则 zip 条目数失控');
|
||||
assert.ok(split.chapters.every((c) => c.text.length <= 48000),
|
||||
'单章必须足够小,一次性把整份文本塞进 DOM 会卡死渲染进程');
|
||||
const built = await buildTextEpub(u8(source), { format: 'txt' });
|
||||
assert.strictEqual(built.chapterCount, split.chapters.length);
|
||||
assert.strictEqual(built.mode, 'plain');
|
||||
});
|
||||
|
||||
test('nextLocator / prevLocator 在两端返回 null', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(novel(4)));
|
||||
assert.strictEqual(adapter.prevLocator({ kind: 'txt', chapter: 0, offset: 0 }), null,
|
||||
'第一章没有上一章,返回 null 外壳才会禁用按钮');
|
||||
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 3, offset: 0 }), null,
|
||||
'最后一章没有下一章');
|
||||
const next = adapter.nextLocator({ kind: 'txt', chapter: 0, offset: 500 });
|
||||
assert.deepStrictEqual(next, { kind: 'txt', chapter: 1, offset: 0 });
|
||||
const prev = adapter.prevLocator({ kind: 'txt', chapter: 2, offset: 10 });
|
||||
assert.deepStrictEqual(prev, { kind: 'txt', chapter: 1, offset: 0 });
|
||||
assert.deepStrictEqual(adapter.nextLocator(null), { kind: 'txt', chapter: 1, offset: 0 },
|
||||
'非法 locator 要按第 0 章处理,不能抛');
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('percentOf 与 locatorFromPercent 互为逆运算', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(novel(8)));
|
||||
// 章内百分比需要章节长度,先取一次全文把长度缓存起来(外壳打开书后同样会发生)
|
||||
await adapter.textOf({ kind: 'txt', chapter: 0, offset: 0 }, 'document');
|
||||
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 0, offset: 0 }), 0);
|
||||
assert.strictEqual(adapter.percentOf({ kind: 'txt', chapter: 7, offset: 1e9 }), 1);
|
||||
for (const p of [0, 0.125, 0.3, 0.5, 0.77, 1]) {
|
||||
const locator = adapter.locatorFromPercent(p);
|
||||
assert.strictEqual(locator.kind, 'txt');
|
||||
const back = adapter.percentOf(locator);
|
||||
assert.ok(Math.abs(back - p) < 0.02,
|
||||
`百分比往返偏差过大:${p} -> ${JSON.stringify(locator)} -> ${back};进度条与书签会漂移`);
|
||||
}
|
||||
assert.deepStrictEqual(adapter.locatorFromPercent(-5), { kind: 'txt', chapter: 0, offset: 0 },
|
||||
'越界百分比必须夹紧');
|
||||
assert.strictEqual(adapter.locatorFromPercent(99).chapter, 7);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('超过体积上限时 load 抛中文错误', async () => {
|
||||
const { createTextAdapter, MAX_TEXT_BYTES } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => adapter.load(new Uint8Array(MAX_TEXT_BYTES + 1)),
|
||||
/超过 64 MB/,
|
||||
'超限错误会原样显示给用户,必须是中文'
|
||||
);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('适配器暴露外壳依赖的全部方法,且 .md 后缀归一', async () => {
|
||||
const { createTextAdapter, normalizeTextFormat } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
for (const name of [
|
||||
'load', 'renderTo', 'toc', 'getSelection', 'textOf', 'locatorLabel',
|
||||
'nextLocator', 'prevLocator', 'percentOf', 'locatorFromPercent',
|
||||
'capturePinchAnchor', 'restorePinchAnchor', 'setLocatorChangeHandler',
|
||||
'setTouchGestureHandler', 'visualViewportRect', 'destroy'
|
||||
]) {
|
||||
assert.strictEqual(typeof adapter[name], 'function', `外壳会调用 ${name},必须实现`);
|
||||
}
|
||||
assert.strictEqual(adapter.getSelection(), null, '未渲染时取选区应返回 null 而不是抛错');
|
||||
assert.strictEqual(adapter.visualViewportRect(), null);
|
||||
assert.strictEqual(adapter.capturePinchAnchor(0, 0), null);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
assert.strictEqual(normalizeTextFormat('.MD'), 'md');
|
||||
assert.strictEqual(normalizeTextFormat('markdown'), 'md');
|
||||
assert.strictEqual(normalizeTextFormat('txt'), 'txt');
|
||||
assert.strictEqual(normalizeTextFormat(undefined), 'txt');
|
||||
});
|
||||
|
||||
test('destroy 之后再次 load 不残留上一本书的状态', async () => {
|
||||
const { createTextAdapter } = await mod;
|
||||
const adapter = createTextAdapter('txt');
|
||||
try {
|
||||
await adapter.load(u8(novel(6)));
|
||||
assert.strictEqual((await adapter.toc()).length, 6);
|
||||
adapter.destroy();
|
||||
assert.strictEqual(adapter.documentInfo(), null, 'destroy 必须清掉文档信息');
|
||||
await adapter.load(u8(novel(3)));
|
||||
assert.strictEqual((await adapter.toc()).length, 3, '重新 load 后目录必须是新书的');
|
||||
assert.strictEqual(adapter.nextLocator({ kind: 'txt', chapter: 2, offset: 0 }), null);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
}
|
||||
});
|
||||
+351
-6
@@ -307,8 +307,14 @@ test('AI 上下文提供无需选中的当前页与全文范围', () => {
|
||||
|
||||
// 全文必须提示可能超限,并且始终弹确认框
|
||||
assert.match(shell, /可能超过模型限制/);
|
||||
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/);
|
||||
@@ -374,12 +380,13 @@ 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/);
|
||||
assert.match(html, /书库导入与管理[\s\S]*TXT、DJVU、FB2、CBZ、CBR/);
|
||||
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 \/ DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||
assert.match(readme, /TXT \/ MD[\s\S]*Markdown/);
|
||||
assert.match(readme, /DJVU \/ FB2 \/ CBZ \/ CBR/);
|
||||
});
|
||||
|
||||
test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器', () => {
|
||||
@@ -389,7 +396,7 @@ test('MOBI、AZW 和 AZW3 使用固定版本 Foliate 组件进入内置阅读器
|
||||
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'\]\)/);
|
||||
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'/);
|
||||
@@ -495,6 +502,66 @@ test('读书与画布笔记分型创建、分类展示并支持受管 PDF 底版
|
||||
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/);
|
||||
@@ -550,7 +617,124 @@ test('书库多选提供全选、批量整理与批量移除', () => {
|
||||
// 批量移除沿用单本移除的两个可选项
|
||||
assert.match(library, /id="bulkDelFiles"/);
|
||||
assert.match(library, /id="bulkDelReadingData"/);
|
||||
assert.match(library, /window\.api\.library\.remove\(item\.id, choice\)/);
|
||||
// 一次 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 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('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||
@@ -564,6 +748,167 @@ test('书库长标题保持单行省略并提供完整悬浮提示', () => {
|
||||
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, /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 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"/);
|
||||
|
||||
Reference in New Issue
Block a user