新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件, 批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、 图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文, 页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。 便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化, 避免升级后 data/ 被遗留在旧目录。 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
178 lines
6.3 KiB
JavaScript
178 lines
6.3 KiB
JavaScript
const assert = require('node:assert');
|
|
const test = require('node:test');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const modulePath = require.resolve('../reader/range-sessions');
|
|
const dirs = [];
|
|
|
|
function fixture() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-range-sessions-'));
|
|
dirs.push(dir);
|
|
const file = path.join(dir, 'fixture.pdf');
|
|
const bytes = Buffer.alloc(6 * 1024 * 1024);
|
|
for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251;
|
|
fs.writeFileSync(file, bytes);
|
|
delete require.cache[modulePath];
|
|
const sessions = require(modulePath);
|
|
sessions.init((_entryId, fileIndex) => ({
|
|
abs: file,
|
|
format: 'pdf',
|
|
fileIndex: Number.isInteger(fileIndex) ? fileIndex : 0
|
|
}));
|
|
return { sessions, file, bytes };
|
|
}
|
|
|
|
test.after(async () => {
|
|
try {
|
|
const sessions = require(modulePath);
|
|
await sessions.closeAll();
|
|
} catch (error) { /* ignore */ }
|
|
for (const dir of dirs) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (error) { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
test('PDF 分段会话只返回请求范围并可显式关闭', async () => {
|
|
const { sessions, bytes } = fixture();
|
|
const opened = await sessions.open(10, 'entry', 0);
|
|
assert.match(opened.sessionId, /^[a-f0-9-]{36}$/);
|
|
assert.strictEqual(opened.size, bytes.length);
|
|
assert.strictEqual(opened.chunkSize, sessions.RANGE_CHUNK_BYTES);
|
|
const begin = 1024 * 1024 + 137;
|
|
const end = begin + 256 * 1024;
|
|
const result = await sessions.read(10, opened.sessionId, begin, end);
|
|
assert.deepStrictEqual(result, bytes.subarray(begin, end));
|
|
assert.strictEqual(await sessions.close(10, opened.sessionId), true);
|
|
assert.strictEqual(await sessions.close(10, opened.sessionId), false);
|
|
assert.strictEqual(sessions.status().sessions, 0);
|
|
});
|
|
|
|
test('PDF 分段会话绑定发送者并限制范围与并发会话数量', async () => {
|
|
const { sessions, bytes } = fixture();
|
|
const first = await sessions.open(20, 'entry', 0);
|
|
await assert.rejects(() => sessions.read(21, first.sessionId, 0, 1024), /无效或已关闭/);
|
|
await assert.rejects(() => sessions.read(20, first.sessionId, -1, 1024), /范围无效/);
|
|
await assert.rejects(
|
|
() => sessions.read(20, first.sessionId, 0, sessions.MAX_RANGE_BYTES + 1),
|
|
/不能超过 4 MB/
|
|
);
|
|
await assert.rejects(
|
|
() => sessions.read(20, first.sessionId, bytes.length - 10, bytes.length + 1),
|
|
/范围无效/
|
|
);
|
|
|
|
const ids = [first.sessionId];
|
|
for (let index = 0; index < sessions.MAX_SESSIONS_PER_SENDER; index++) {
|
|
ids.push((await sessions.open(20, 'entry', 0)).sessionId);
|
|
}
|
|
assert.strictEqual(sessions.status().sessions, sessions.MAX_SESSIONS_PER_SENDER);
|
|
await assert.rejects(() => sessions.read(20, ids[0], 0, 1024), /无效或已关闭/);
|
|
assert.strictEqual(await sessions.closeSender(20), sessions.MAX_SESSIONS_PER_SENDER);
|
|
assert.strictEqual(sessions.status().sessions, 0);
|
|
});
|
|
|
|
test('PDF 在阅读期间发生变化时拒绝继续提供旧会话数据', async () => {
|
|
const { sessions, file } = fixture();
|
|
const opened = await sessions.open(30, 'entry', 0);
|
|
fs.appendFileSync(file, Buffer.from([1]));
|
|
await assert.rejects(
|
|
() => sessions.read(30, opened.sessionId, 0, 1024),
|
|
/发生变化/
|
|
);
|
|
await sessions.closeSender(30);
|
|
});
|
|
|
|
test('40 GB 文件使用安全整数偏移按需读取而不分配整文件缓冲区', async () => {
|
|
delete require.cache[modulePath];
|
|
const sessions = require(modulePath);
|
|
const size = 40 * 1024 * 1024 * 1024;
|
|
let closed = false;
|
|
const stat = { size, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
|
|
const handle = {
|
|
stat: async () => stat,
|
|
read: async (buffer, offset, length, position) => {
|
|
for (let index = 0; index < length; index++) {
|
|
buffer[offset + index] = (position + index) % 251;
|
|
}
|
|
return { bytesRead: length, buffer };
|
|
},
|
|
close: async () => { closed = true; }
|
|
};
|
|
sessions.init(
|
|
() => ({ abs: 'virtual-40gb.pdf', format: 'pdf', fileIndex: 0 }),
|
|
{ promises: { open: async () => handle } }
|
|
);
|
|
const opened = await sessions.open(40, 'huge', 0);
|
|
assert.strictEqual(opened.size, size);
|
|
const begin = size - 8192;
|
|
const result = await sessions.read(40, opened.sessionId, begin, begin + 4096);
|
|
assert.strictEqual(result.length, 4096);
|
|
assert.strictEqual(result[0], begin % 251);
|
|
assert.strictEqual(result[4095], (begin + 4095) % 251);
|
|
await sessions.closeSender(40);
|
|
assert.strictEqual(closed, true);
|
|
});
|
|
|
|
test('发送者销毁与会话创建竞态不会遗留文件句柄', async () => {
|
|
delete require.cache[modulePath];
|
|
const sessions = require(modulePath);
|
|
const stat = { size: 4096, mtimeMs: 1, ctimeMs: 1, isFile: () => true };
|
|
let releaseOpen;
|
|
let closed = false;
|
|
sessions.init(
|
|
() => ({ abs: 'delayed.pdf', format: 'pdf', fileIndex: 0 }),
|
|
{
|
|
promises: {
|
|
open: () => new Promise((resolve) => {
|
|
releaseOpen = () => resolve({
|
|
stat: async () => stat,
|
|
read: async () => ({ bytesRead: 0 }),
|
|
close: async () => { closed = true; }
|
|
});
|
|
})
|
|
}
|
|
}
|
|
);
|
|
const opening = sessions.open(50, 'entry', 0);
|
|
while (!releaseOpen) await new Promise((resolve) => setImmediate(resolve));
|
|
await sessions.closeSender(50);
|
|
releaseOpen();
|
|
await assert.rejects(opening, /窗口已关闭/);
|
|
assert.strictEqual(closed, true);
|
|
assert.strictEqual(sessions.status().sessions, 0);
|
|
});
|
|
|
|
test('范围读取完成后再次校验文件签名', async () => {
|
|
delete require.cache[modulePath];
|
|
const sessions = require(modulePath);
|
|
let changed = false;
|
|
let closed = false;
|
|
const handle = {
|
|
stat: async () => ({
|
|
size: 4096,
|
|
mtimeMs: changed ? 2 : 1,
|
|
ctimeMs: 1,
|
|
isFile: () => true
|
|
}),
|
|
read: async (buffer, offset, length) => {
|
|
buffer.fill(1, offset, offset + length);
|
|
changed = true;
|
|
return { bytesRead: length, buffer };
|
|
},
|
|
close: async () => { closed = true; }
|
|
};
|
|
sessions.init(
|
|
() => ({ abs: 'changing.pdf', format: 'pdf', fileIndex: 0 }),
|
|
{ promises: { open: async () => handle } }
|
|
);
|
|
const opened = await sessions.open(60, 'entry', 0);
|
|
await assert.rejects(
|
|
() => sessions.read(60, opened.sessionId, 0, 1024),
|
|
/发生变化/
|
|
);
|
|
while (!closed) await new Promise((resolve) => setImmediate(resolve));
|
|
assert.strictEqual(sessions.status().sessions, 0);
|
|
});
|