构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
1747 lines
82 KiB
JavaScript
1747 lines
82 KiB
JavaScript
const { app, BrowserWindow, dialog, safeStorage, nativeImage } = require('electron');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const JSZip = require('jszip');
|
||
|
||
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-reader-features-'));
|
||
const PDF_FILE = path.join(TMP, 'reader-features.pdf');
|
||
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);
|
||
|
||
const results = [];
|
||
function check(name, condition, detail = '') {
|
||
results.push([condition ? 'OK' : 'FAIL', name, detail]);
|
||
}
|
||
|
||
function imageHasInk(dataUrl) {
|
||
const image = nativeImage.createFromDataURL(String(dataUrl || ''));
|
||
if (image.isEmpty()) return false;
|
||
const bitmap = image.toBitmap();
|
||
let dark = 0;
|
||
for (let offset = 0; offset + 3 < bitmap.length; offset += 200) {
|
||
if (bitmap[offset] < 238 || bitmap[offset + 1] < 238 || bitmap[offset + 2] < 238) dark++;
|
||
}
|
||
return dark >= 8;
|
||
}
|
||
|
||
function wait(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function waitUntil(predicate, timeout = 10000, interval = 100) {
|
||
const deadline = Date.now() + timeout;
|
||
let lastError = null;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const value = await predicate();
|
||
if (value) return value;
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
await wait(interval);
|
||
}
|
||
if (lastError) throw lastError;
|
||
throw new Error(`等待条件超时(${timeout}ms)`);
|
||
}
|
||
|
||
function makePdf(file) {
|
||
const pageText = [
|
||
'Page One reader integration fixture.',
|
||
'Page Two selectable text validates excerpt and note storage.',
|
||
'Page Three reader integration fixture.'
|
||
];
|
||
const objects = new Array(10);
|
||
objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||
objects[2] = '<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>';
|
||
for (let i = 0; i < 3; i++) {
|
||
objects[3 + i] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents ${7 + i} 0 R >>`;
|
||
}
|
||
objects[6] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||
for (let i = 0; i < 3; i++) {
|
||
const stream = [
|
||
'BT',
|
||
'/F1 18 Tf',
|
||
'72 720 Td',
|
||
`(${pageText[i]}) Tj`,
|
||
'0 -34 Td',
|
||
'(Touch gestures must preserve the focal document location.) Tj',
|
||
'ET',
|
||
''
|
||
].join('\n');
|
||
objects[7 + i] = `<< /Length ${Buffer.byteLength(stream, 'ascii')} >>\nstream\n${stream}endstream`;
|
||
}
|
||
|
||
let pdf = '%PDF-1.4\n';
|
||
const offsets = new Array(objects.length).fill(0);
|
||
for (let i = 1; i < objects.length; i++) {
|
||
offsets[i] = Buffer.byteLength(pdf, 'ascii');
|
||
pdf += `${i} 0 obj\n${objects[i]}\nendobj\n`;
|
||
}
|
||
const xref = Buffer.byteLength(pdf, 'ascii');
|
||
pdf += `xref\n0 ${objects.length}\n`;
|
||
pdf += '0000000000 65535 f \n';
|
||
for (let i = 1; i < objects.length; i++) {
|
||
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||
}
|
||
pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||
fs.writeFileSync(file, pdf, 'ascii');
|
||
}
|
||
|
||
async function makeEpub(file) {
|
||
const zip = new JSZip();
|
||
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
||
zip.file('META-INF/container.xml', `<?xml version="1.0" encoding="UTF-8"?>
|
||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||
<rootfiles>
|
||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||
</rootfiles>
|
||
</container>`);
|
||
zip.file('OEBPS/content.opf', `<?xml version="1.0" encoding="UTF-8"?>
|
||
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id">
|
||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||
<dc:identifier id="book-id">reader-features-fixture</dc:identifier>
|
||
<dc:title>Reader Features EPUB Fixture</dc:title>
|
||
<dc:language>en</dc:language>
|
||
</metadata>
|
||
<manifest>
|
||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
|
||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||
</manifest>
|
||
<spine><itemref idref="chapter"/></spine>
|
||
</package>`);
|
||
const paragraphs = Array.from({ length: 90 }, (_, index) => (
|
||
`<p${index === 44 ? ' id="section-mid"' : ''}>Paragraph ${index + 1}. EPUB focal anchor sentence ${index + 1} keeps a nearby character offset after a synthetic pinch gesture. Selection excerpt text remains available to reader notes.${index === 0 ? ' <a href="#section-mid">Jump to middle section</a>' : ''}</p>`
|
||
)).join('\n');
|
||
zip.file('OEBPS/chapter.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE html>
|
||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||
<head><title>Touch Chapter</title></head>
|
||
<body><h1>Touch Chapter</h1>${paragraphs}</body>
|
||
</html>`);
|
||
zip.file('OEBPS/nav.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
|
||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||
<head><title>Contents</title></head>
|
||
<body><nav epub:type="toc"><ol>
|
||
<li><a href="chapter.xhtml">Start</a></li>
|
||
<li><a href="chapter.xhtml#section-mid">Middle Section</a></li>
|
||
</ol></nav></body>
|
||
</html>`);
|
||
fs.writeFileSync(file, await zip.generateAsync({
|
||
type: 'nodebuffer',
|
||
mimeType: 'application/epub+zip',
|
||
compression: 'DEFLATE',
|
||
compressionOptions: { level: 6 }
|
||
}));
|
||
}
|
||
|
||
function makeMobi(file) {
|
||
const text = Buffer.from(
|
||
'<html><head><title>MOBI Chapter</title></head><body>'
|
||
+ '<h1>MOBI Chapter</h1><p>MOBI selectable text validates the Foliate parser, '
|
||
+ 'built-in rendering, bookmarks, excerpts, notes, and stable reading progress.</p>'
|
||
+ '<script>window.__mobiScriptExecuted = true</script>'
|
||
+ '<img src="https://untrusted.example/tracker.png" onerror="window.__mobiHandlerExecuted=true">'
|
||
+ '<a href="javascript:window.__mobiLinkExecuted=true">unsafe link</a>'
|
||
+ '</body></html>',
|
||
'utf8'
|
||
);
|
||
const compressed = Buffer.concat(Array.from(
|
||
{ length: Math.ceil(text.length / 8) },
|
||
(_, index) => {
|
||
const chunk = text.subarray(index * 8, index * 8 + 8);
|
||
return Buffer.concat([Buffer.from([chunk.length]), chunk]);
|
||
}
|
||
));
|
||
const record0Length = 320;
|
||
const record0Offset = 96;
|
||
const record1Offset = record0Offset + record0Length;
|
||
const output = Buffer.alloc(record1Offset + compressed.length);
|
||
output.write('Reader Features MOBI', 0, 'ascii');
|
||
output.write('BOOK', 60, 'ascii');
|
||
output.write('MOBI', 64, 'ascii');
|
||
output.writeUInt16BE(2, 76);
|
||
output.writeUInt32BE(record0Offset, 78);
|
||
output.writeUInt32BE(record1Offset, 86);
|
||
output.writeUInt16BE(2, record0Offset);
|
||
output.writeUInt16BE(1, record0Offset + 8);
|
||
output.writeUInt16BE(4096, record0Offset + 10);
|
||
output.writeUInt16BE(0, record0Offset + 12);
|
||
output.write('MOBI', record0Offset + 16, 'ascii');
|
||
output.writeUInt32BE(248, record0Offset + 20);
|
||
output.writeUInt32BE(2, record0Offset + 24);
|
||
output.writeUInt32BE(65001, record0Offset + 28);
|
||
output.writeUInt32BE(12345, record0Offset + 32);
|
||
output.writeUInt32BE(6, record0Offset + 36);
|
||
const title = Buffer.from('Reader Features MOBI Fixture', 'utf8');
|
||
output.writeUInt32BE(270, record0Offset + 84);
|
||
output.writeUInt32BE(title.length, record0Offset + 88);
|
||
output[record0Offset + 94] = 0;
|
||
output[record0Offset + 95] = 9;
|
||
output.writeUInt32BE(2, record0Offset + 108);
|
||
output.writeUInt32BE(0xffffffff, record0Offset + 112);
|
||
output.writeUInt32BE(0, record0Offset + 116);
|
||
output.writeUInt32BE(0, record0Offset + 128);
|
||
output.writeUInt32BE(0, record0Offset + 240);
|
||
output.writeUInt32BE(0xffffffff, record0Offset + 244);
|
||
title.copy(output, record0Offset + 270);
|
||
compressed.copy(output, record1Offset);
|
||
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);
|
||
} catch (error) {
|
||
console.error('脚本失败:', source.slice(0, 180), error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function waitForJs(win, source, timeout = 15000) {
|
||
return waitUntil(async () => {
|
||
if (win.isDestroyed()) throw new Error('阅读器窗口已关闭');
|
||
return js(win, source);
|
||
}, timeout, 120);
|
||
}
|
||
|
||
async function openReader(entryId, fileIndex) {
|
||
const win = new BrowserWindow({
|
||
show: false,
|
||
width: 1280,
|
||
height: 900,
|
||
webPreferences: {
|
||
preload: path.join(ROOT, 'preload.js'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false
|
||
}
|
||
});
|
||
const errors = [];
|
||
win.webContents.on('console-message', (event) => {
|
||
const { level, message } = event;
|
||
if (level >= 2 && !/Autofill|Indexing all PDF objects/.test(message)) {
|
||
errors.push(message);
|
||
console.error('RENDERER:', message);
|
||
}
|
||
});
|
||
await win.loadFile(path.join(ROOT, 'src', 'ui', 'reader.html'), {
|
||
query: { entryId, fileIndex: String(fileIndex) }
|
||
});
|
||
return { win, errors };
|
||
}
|
||
|
||
async function selectPdfText(win, page) {
|
||
return js(win, `(() => {
|
||
const root = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-text');
|
||
const span = root && Array.from(root.querySelectorAll('span')).find((item) => {
|
||
return item.firstChild && item.firstChild.nodeType === Node.TEXT_NODE
|
||
&& item.firstChild.data.trim().length >= 12;
|
||
});
|
||
if (!span) return null;
|
||
const node = span.firstChild;
|
||
const start = Math.min(5, node.data.length - 2);
|
||
const end = Math.min(node.data.length, start + 24);
|
||
const range = document.createRange();
|
||
range.setStart(node, start);
|
||
range.setEnd(node, end);
|
||
const selection = window.getSelection();
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
const rect = range.getBoundingClientRect();
|
||
document.dispatchEvent(new MouseEvent('mouseup', {
|
||
bubbles: true,
|
||
clientX: rect.left + Math.max(1, rect.width / 2),
|
||
clientY: rect.top + Math.max(1, rect.height / 2)
|
||
}));
|
||
return { text: selection.toString(), offset: start };
|
||
})()`);
|
||
}
|
||
|
||
async function drawPdfTouchStroke(win, page) {
|
||
return js(win, `(async () => {
|
||
const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
|
||
const view = canvas.ownerDocument.defaultView;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const point = (id, x, y) => {
|
||
const init = {
|
||
identifier: id, target: canvas,
|
||
clientX: rect.left + x, clientY: rect.top + y,
|
||
pageX: rect.left + x + view.scrollX, pageY: rect.top + y + view.scrollY,
|
||
screenX: rect.left + x, screenY: rect.top + y,
|
||
radiusX: 2, radiusY: 2, rotationAngle: 0, force: 0.5
|
||
};
|
||
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
|
||
};
|
||
const fire = (type, touches, changed) => {
|
||
let event;
|
||
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
|
||
event = new view.TouchEvent(type, {
|
||
bubbles: true, cancelable: true, composed: true,
|
||
touches, targetTouches: touches, changedTouches: changed, view
|
||
});
|
||
} else {
|
||
// Some headless Chromium builds omit Touch; preserve the real touch event path with explicit lists.
|
||
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
|
||
Object.defineProperties(event, {
|
||
touches: { value: touches },
|
||
targetTouches: { value: touches },
|
||
changedTouches: { value: changed }
|
||
});
|
||
}
|
||
canvas.dispatchEvent(event);
|
||
};
|
||
let touch = point(31, 90, 150);
|
||
fire('touchstart', [touch], [touch]);
|
||
for (const [x, y] of [[125, 165], [170, 180], [220, 205]]) {
|
||
touch = point(31, x, y);
|
||
fire('touchmove', [touch], [touch]);
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
}
|
||
fire('touchend', [], [touch]);
|
||
return typeof view.Touch === 'function' ? 'native' : 'fallback';
|
||
})()`);
|
||
}
|
||
|
||
async function pinchPdf(win, page) {
|
||
return js(win, `(async () => {
|
||
const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
|
||
const view = canvas.ownerDocument.defaultView;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const cx = rect.left + rect.width * 0.5;
|
||
const cy = rect.top + rect.height * 0.35;
|
||
const point = (id, x, y) => {
|
||
const init = {
|
||
identifier: id, target: canvas,
|
||
clientX: x, clientY: y, pageX: x + view.scrollX, pageY: y + view.scrollY,
|
||
screenX: x, screenY: y, radiusX: 3, radiusY: 3, rotationAngle: 0, force: 0.5
|
||
};
|
||
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
|
||
};
|
||
const fire = (type, touches, changed) => {
|
||
let event;
|
||
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
|
||
event = new view.TouchEvent(type, {
|
||
bubbles: true, cancelable: true, composed: true,
|
||
touches, targetTouches: touches, changedTouches: changed, view
|
||
});
|
||
} else {
|
||
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
|
||
Object.defineProperties(event, {
|
||
touches: { value: touches },
|
||
targetTouches: { value: touches },
|
||
changedTouches: { value: changed }
|
||
});
|
||
}
|
||
canvas.dispatchEvent(event);
|
||
};
|
||
let a = point(41, cx - 60, cy);
|
||
let b = point(42, cx + 60, cy);
|
||
fire('touchstart', [a, b], [a, b]);
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
a = point(41, cx - 105, cy);
|
||
b = point(42, cx + 105, cy);
|
||
fire('touchmove', [a, b], [a, b]);
|
||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||
fire('touchend', [], [a, b]);
|
||
return typeof view.Touch === 'function' ? 'native' : 'fallback';
|
||
})()`);
|
||
}
|
||
|
||
async function rollbackPdfStroke(win, page) {
|
||
return js(win, `(async () => {
|
||
const canvas = document.querySelector('.pdfx-page[data-page="${page}"] .pdfx-annotation .upper-canvas');
|
||
const view = canvas.ownerDocument.defaultView;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const point = (id, x, y) => {
|
||
const init = {
|
||
identifier: id, target: canvas,
|
||
clientX: rect.left + x, clientY: rect.top + y,
|
||
pageX: rect.left + x + view.scrollX, pageY: rect.top + y + view.scrollY,
|
||
screenX: rect.left + x, screenY: rect.top + y,
|
||
radiusX: 2, radiusY: 2, rotationAngle: 0, force: 0.5
|
||
};
|
||
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
|
||
};
|
||
const fire = (type, touches, changed) => {
|
||
let event;
|
||
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
|
||
event = new view.TouchEvent(type, {
|
||
bubbles: true, cancelable: true, composed: true,
|
||
touches, targetTouches: touches, changedTouches: changed, view
|
||
});
|
||
} else {
|
||
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
|
||
Object.defineProperties(event, {
|
||
touches: { value: touches },
|
||
targetTouches: { value: touches },
|
||
changedTouches: { value: changed }
|
||
});
|
||
}
|
||
canvas.dispatchEvent(event);
|
||
};
|
||
let first = point(51, 120, 260);
|
||
fire('touchstart', [first], [first]);
|
||
first = point(51, 180, 285);
|
||
fire('touchmove', [first], [first]);
|
||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||
const second = point(52, 300, 285);
|
||
fire('touchstart', [first, second], [second]);
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
fire('touchend', [], [first, second]);
|
||
})()`);
|
||
}
|
||
|
||
async function pinchEpub(win) {
|
||
return js(win, `(async () => {
|
||
const frame = document.querySelector('.host-epub iframe');
|
||
const doc = frame.contentDocument;
|
||
const view = frame.contentWindow;
|
||
const outer = document.querySelector('.epub-scroll');
|
||
const outerRect = outer.getBoundingClientRect();
|
||
const frameRect = frame.getBoundingClientRect();
|
||
const focalX = 260 + outerRect.left - frameRect.left;
|
||
const focalY = 190 + outerRect.top - frameRect.top;
|
||
const target = doc.elementFromPoint(focalX, focalY) || doc.body;
|
||
const textOffsetAtPoint = () => {
|
||
let point = null;
|
||
if (typeof doc.caretPositionFromPoint === 'function') {
|
||
const caret = doc.caretPositionFromPoint(focalX, focalY);
|
||
if (caret) point = { node: caret.offsetNode, index: caret.offset };
|
||
} else if (typeof doc.caretRangeFromPoint === 'function') {
|
||
const range = doc.caretRangeFromPoint(focalX, focalY);
|
||
if (range) point = { node: range.startContainer, index: range.startOffset };
|
||
}
|
||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||
let offset = 0;
|
||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||
if (point && node === point.node) return offset + Math.max(0, point.index || 0);
|
||
if (point && point.node && point.node.nodeType === Node.ELEMENT_NODE && point.node.contains(node)) {
|
||
return offset;
|
||
}
|
||
offset += node.data.length;
|
||
}
|
||
return 0;
|
||
};
|
||
const point = (id, x, y) => {
|
||
const init = {
|
||
identifier: id, target,
|
||
clientX: x, clientY: y, pageX: x + view.scrollX, pageY: y + view.scrollY,
|
||
screenX: x, screenY: y, radiusX: 3, radiusY: 3, rotationAngle: 0, force: 0.5
|
||
};
|
||
return typeof view.Touch === 'function' ? new view.Touch(init) : init;
|
||
};
|
||
const fire = (type, touches, changed) => {
|
||
let event;
|
||
if (typeof view.TouchEvent === 'function' && typeof view.Touch === 'function') {
|
||
event = new view.TouchEvent(type, {
|
||
bubbles: true, cancelable: true, composed: true,
|
||
touches, targetTouches: touches, changedTouches: changed, view
|
||
});
|
||
} else {
|
||
event = new view.Event(type, { bubbles: true, cancelable: true, composed: true });
|
||
Object.defineProperties(event, {
|
||
touches: { value: touches },
|
||
targetTouches: { value: touches },
|
||
changedTouches: { value: changed }
|
||
});
|
||
}
|
||
target.dispatchEvent(event);
|
||
};
|
||
const anchorOffset = textOffsetAtPoint();
|
||
const before = parseFloat(view.getComputedStyle(doc.body).fontSize);
|
||
let a = point(61, focalX - 60, focalY);
|
||
let b = point(62, focalX + 60, focalY);
|
||
fire('touchstart', [a, b], [a, b]);
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
a = point(61, focalX - 90, focalY);
|
||
b = point(62, focalX + 90, focalY);
|
||
fire('touchmove', [a, b], [a, b]);
|
||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||
fire('touchend', [], [a, b]);
|
||
return {
|
||
anchorOffset,
|
||
before,
|
||
mode: typeof view.Touch === 'function' ? 'native' : 'fallback'
|
||
};
|
||
})()`);
|
||
}
|
||
|
||
async function selectEpubText(win, value = 'EPUB focal anchor sentence') {
|
||
const needle = JSON.stringify(value);
|
||
return js(win, `(() => {
|
||
const frame = document.querySelector('.host-epub iframe');
|
||
const doc = frame.contentDocument;
|
||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||
let node = walker.nextNode();
|
||
while (node && !node.data.includes(${needle})) node = walker.nextNode();
|
||
if (!node) return null;
|
||
const start = node.data.indexOf(${needle});
|
||
const end = start + ${needle}.length;
|
||
const range = doc.createRange();
|
||
range.setStart(node, start);
|
||
range.setEnd(node, end);
|
||
const selection = doc.getSelection();
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
const rect = range.getBoundingClientRect();
|
||
doc.dispatchEvent(new frame.contentWindow.MouseEvent('mouseup', {
|
||
bubbles: true,
|
||
clientX: rect.left + Math.max(1, rect.width / 2),
|
||
clientY: rect.top + Math.max(1, rect.height / 2)
|
||
}));
|
||
return { text: selection.toString(), offset: start };
|
||
})()`);
|
||
}
|
||
|
||
function printSummary() {
|
||
console.log('\n========== 阅读功能 Electron 集成验证 ==========');
|
||
for (const [status, name, detail] of results) {
|
||
console.log(`${status.padEnd(5)} ${name}${detail ? ` [${detail}]` : ''}`);
|
||
}
|
||
const failed = results.filter((result) => result[0] === 'FAIL').length;
|
||
console.log(`\n通过 ${results.length - failed}/${results.length}`);
|
||
return failed;
|
||
}
|
||
|
||
app.whenReady().then(async () => {
|
||
makePdf(PDF_FILE);
|
||
await makeEpub(EPUB_FILE);
|
||
makeMobi(MOBI_FILE);
|
||
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);
|
||
require(path.join(ROOT, 'main.js'));
|
||
|
||
const settings = require(path.join(ROOT, 'src', 'settings'));
|
||
const readerStore = require(path.join(ROOT, 'src', 'reader', 'store'));
|
||
const annotations = require(path.join(ROOT, 'src', 'reader', 'annotations'));
|
||
const rangeSessions = require(path.join(ROOT, 'src', 'reader', 'range-sessions'));
|
||
const aiConfig = require(path.join(ROOT, 'src', 'reader', 'ai-config'));
|
||
const managedReader = require(path.join(ROOT, 'src', 'reader', 'window'));
|
||
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||
settings.init(TMP);
|
||
readerStore.init(TMP);
|
||
annotations.init(TMP);
|
||
aiConfig.init(TMP, safeStorage);
|
||
aiConfig.save({
|
||
protocol: 'chat-completions',
|
||
baseUrl: 'http://127.0.0.1:65534/v1',
|
||
model: 'vision-fixture',
|
||
apiKey: '',
|
||
vision: true
|
||
});
|
||
library.init(path.join(TMP, 'library'));
|
||
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
|
||
|
||
const entry = library.add({
|
||
title: 'Reader Feature Fixtures',
|
||
authors: ['Integration Test'],
|
||
files: [
|
||
{ path: PDF_FILE, name: 'reader-features.pdf', format: 'PDF' },
|
||
{ 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: TXT_FILE, name: 'reader-features.txt', format: 'TXT' },
|
||
{ path: MD_FILE, name: 'reader-features.md', format: 'MD' }
|
||
]
|
||
});
|
||
await wait(1200);
|
||
for (const window of BrowserWindow.getAllWindows()) window.hide();
|
||
|
||
check('本地 PDF、EPUB 和 MOBI 固定夹具已创建',
|
||
fs.existsSync(PDF_FILE)
|
||
&& fs.existsSync(EPUB_FILE)
|
||
&& fs.existsSync(MOBI_FILE)
|
||
&& fs.existsSync(DRM_MOBI_FILE)
|
||
&& fs.statSync(LARGE_EPUB_FILE).size === 256 * 1024 * 1024 + 1);
|
||
|
||
const pdfReader = await openReader(entry.id, 0);
|
||
const pdfWin = pdfReader.win;
|
||
await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="1"] .pdfx-canvas')?.width > 0`);
|
||
check('PDF 通过真实主进程 IPC 渲染', await js(pdfWin,
|
||
`document.querySelectorAll('.pdfx-page').length === 3`));
|
||
const rangeStatus = rangeSessions.status();
|
||
check('PDF 通过发送者隔离的分段会话读取',
|
||
rangeStatus.sessions === 1 && rangeStatus.bytesRead > 0,
|
||
`${rangeStatus.bytesRead} bytes`);
|
||
const failedRange = await js(pdfWin, `(async () => {
|
||
const module = await import('./reader/pdf-adapter.mjs');
|
||
const adapter = module.createPdfAdapter();
|
||
let closed = false;
|
||
try {
|
||
await Promise.race([
|
||
adapter.load({
|
||
kind: 'range',
|
||
size: 4096,
|
||
chunkSize: 1024,
|
||
read: async () => { throw new Error('range fixture failure'); },
|
||
close: async () => { closed = true; }
|
||
}),
|
||
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
|
||
]);
|
||
return { rejected: false, closed };
|
||
} catch (error) {
|
||
return { rejected: true, message: error.message, closed };
|
||
} finally {
|
||
adapter.destroy();
|
||
}
|
||
})()`);
|
||
check('PDF 分段读取失败会立即拒绝加载而不是永久等待',
|
||
failedRange.rejected && failedRange.closed
|
||
&& failedRange.message.includes('range fixture failure'),
|
||
JSON.stringify(failedRange));
|
||
check('右侧面板包含书签、标注、笔记和 AI 标签', await js(pdfWin, `(
|
||
Array.from(document.querySelectorAll('.pane-tab')).map((button) => button.dataset.pane).join(',')
|
||
=== 'bookmarks,annotations,notes,ai'
|
||
)`));
|
||
await waitForJs(pdfWin, `(() => {
|
||
const controls = document.getElementById('pdfViewControls');
|
||
return !controls.classList.contains('hidden')
|
||
&& document.getElementById('posLabel').textContent === '第 1 页'
|
||
&& document.getElementById('pdfViewMode').value === 'continuous'
|
||
&& document.getElementById('pdfPageLayout').value === 'single'
|
||
&& document.querySelector('.pdfx-scroller').classList.contains('pdfx-view-continuous')
|
||
&& document.querySelector('.pdfx-pages').classList.contains('pdfx-layout-single');
|
||
})()`);
|
||
check('PDF 底栏默认显示连续和单页两个独立选项', true);
|
||
|
||
pdfWin.setSize(760, 700);
|
||
await wait(150);
|
||
check('窄窗口仍保留 PDF 阅读和版式控件', await js(pdfWin, `(() => {
|
||
const bar = document.querySelector('.statusbar');
|
||
const controls = document.getElementById('pdfViewControls');
|
||
return !controls.classList.contains('hidden') && bar.scrollWidth <= bar.clientWidth;
|
||
})()`));
|
||
|
||
pdfWin.setSize(2200, 1000);
|
||
await js(pdfWin, `(() => {
|
||
const select = document.getElementById('pdfPageLayout');
|
||
select.value = 'auto';
|
||
select.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
await waitForJs(pdfWin, `(() => {
|
||
const pages = document.querySelectorAll('.pdfx-page');
|
||
return document.querySelector('.pdfx-pages').classList.contains('pdfx-layout-auto')
|
||
&& pages[0].offsetTop === pages[1].offsetTop;
|
||
})()`);
|
||
check('自动版式在宽窗口并排显示多页', await js(pdfWin, `(() => {
|
||
const pages = document.querySelectorAll('.pdfx-page');
|
||
return pages[0].offsetTop === pages[1].offsetTop
|
||
&& pages[0].offsetLeft !== pages[1].offsetLeft;
|
||
})()`));
|
||
|
||
await js(pdfWin, `(() => {
|
||
const select = document.getElementById('pdfViewMode');
|
||
select.value = 'paged';
|
||
select.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
await waitForJs(pdfWin, `getComputedStyle(document.querySelector('.pdfx-scroller'))
|
||
.scrollSnapType.includes('mandatory')`);
|
||
check('分页模式启用按行滚动吸附', await js(pdfWin, `(() => {
|
||
const scroller = document.querySelector('.pdfx-scroller');
|
||
const pages = document.querySelectorAll('.pdfx-page');
|
||
return scroller.classList.contains('pdfx-view-paged')
|
||
&& pages[0].offsetTop === pages[1].offsetTop
|
||
&& pages[2].offsetTop > pages[0].offsetTop;
|
||
})()`));
|
||
await waitUntil(() => Promise.resolve(
|
||
settings.get('reader.pdfViewMode', '') === 'paged'
|
||
&& settings.get('reader.pdfPageLayout', '') === 'auto'
|
||
));
|
||
check('PDF 阅读和版式偏好已持久化', true);
|
||
await js(pdfWin, `document.getElementById('nextBtn').click()`);
|
||
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
|
||
check('自动多页的分页导航按整行前进', true);
|
||
|
||
await js(pdfWin, `(() => {
|
||
const mode = document.getElementById('pdfViewMode');
|
||
mode.value = 'continuous';
|
||
mode.dispatchEvent(new Event('change'));
|
||
const layout = document.getElementById('pdfPageLayout');
|
||
layout.value = 'single';
|
||
layout.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
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())`);
|
||
} catch (error) {
|
||
const state = await js(pdfWin, `(() => ({
|
||
position: document.getElementById('posLabel').textContent,
|
||
scrollTop: document.querySelector('.pdfx-scroller').scrollTop,
|
||
pages: Array.from(document.querySelectorAll('.pdfx-page')).map((page) => ({
|
||
page: page.dataset.page,
|
||
top: page.offsetTop,
|
||
canvas: page.querySelector('.pdfx-canvas')?.width || 0,
|
||
spans: page.querySelectorAll('.pdfx-text span').length
|
||
}))
|
||
}))()`);
|
||
throw new Error(`${error.message}; ${JSON.stringify(state)}`);
|
||
}
|
||
|
||
const selectionClearedByZoom = await js(pdfWin, `(() => {
|
||
const span = Array.from(document.querySelectorAll('.pdfx-text span'))
|
||
.find((node) => node.firstChild && node.firstChild.data.trim());
|
||
const selection = window.getSelection();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(span);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||
const button = document.getElementById('zoomOutBtn');
|
||
button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
|
||
button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
|
||
button.click();
|
||
return !selection.toString()
|
||
&& document.getElementById('selBar').classList.contains('hidden');
|
||
})()`);
|
||
check('点击缩放等阅读器控件会清除正文选区且不重新触发划选工具条',
|
||
selectionClearedByZoom);
|
||
await waitForJs(pdfWin,
|
||
`document.querySelector('.pdfx-page[data-page="3"] .pdfx-canvas')?.width > 0`);
|
||
|
||
const fitPage = await js(pdfWin, `document.getElementById('posLabel').textContent`);
|
||
pdfWin.setSize(900, 700);
|
||
await wait(180);
|
||
await js(pdfWin, `document.getElementById('fitWidthBtn').click()`);
|
||
await waitForJs(pdfWin, `(() => {
|
||
const page = document.querySelector('.pdfx-page[data-page="3"]');
|
||
const scroller = document.querySelector('.pdfx-scroller');
|
||
return page && page.querySelector('.pdfx-canvas').width > 0
|
||
&& Math.abs(page.getBoundingClientRect().width - (scroller.clientWidth - 32)) <= 2;
|
||
})()`, 20000);
|
||
const narrowFit = await js(pdfWin, `(() => {
|
||
const page = document.querySelector('.pdfx-page[data-page="3"]');
|
||
const scroller = document.querySelector('.pdfx-scroller');
|
||
return {
|
||
zoom: Number.parseFloat(document.getElementById('zoomLabel').textContent),
|
||
pageWidth: page.getBoundingClientRect().width,
|
||
available: scroller.clientWidth - 32,
|
||
position: document.getElementById('posLabel').textContent
|
||
};
|
||
})()`);
|
||
check('PDF 适应内容宽度在窄窗口贴合可用宽度并保持当前页',
|
||
Math.abs(narrowFit.pageWidth - narrowFit.available) <= 2
|
||
&& narrowFit.position === fitPage,
|
||
JSON.stringify(narrowFit));
|
||
await js(pdfWin, `document.getElementById('zoomInBtn').click()`);
|
||
await waitForJs(pdfWin,
|
||
`Number.parseFloat(document.getElementById('zoomLabel').textContent) > ${narrowFit.zoom}`);
|
||
const manualZoom = await js(pdfWin,
|
||
`Number.parseFloat(document.getElementById('zoomLabel').textContent)`);
|
||
await js(pdfWin, `document.getElementById('zoomOutBtn').click()`);
|
||
await waitForJs(pdfWin,
|
||
`Number.parseFloat(document.getElementById('zoomLabel').textContent) < ${manualZoom}`);
|
||
check('适宽产生的非预设比例之后仍可正常手动放大和缩小', true);
|
||
|
||
await js(pdfWin, `(() => {
|
||
const layout = document.getElementById('pdfPageLayout');
|
||
layout.value = 'auto';
|
||
layout.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
pdfWin.setSize(1900, 900);
|
||
await wait(220);
|
||
await js(pdfWin, `document.getElementById('fitWidthBtn').click()`);
|
||
await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="3"] .pdfx-canvas')?.width > 0`,
|
||
20000);
|
||
check('自动多页版式适宽时整行内容保持在可用宽度内', await js(pdfWin, `(() => {
|
||
const scroller = document.querySelector('.pdfx-scroller');
|
||
const rows = new Map();
|
||
for (const page of document.querySelectorAll('.pdfx-page')) {
|
||
const top = page.offsetTop;
|
||
const row = rows.get(top) || [];
|
||
row.push(page);
|
||
rows.set(top, row);
|
||
}
|
||
return [...rows.values()].every((row) => {
|
||
const first = row[0].getBoundingClientRect();
|
||
const last = row[row.length - 1].getBoundingClientRect();
|
||
return last.right - first.left <= scroller.clientWidth - 30;
|
||
}) && document.getElementById('posLabel').textContent === ${JSON.stringify(fitPage)};
|
||
})()`));
|
||
await js(pdfWin, `(() => {
|
||
const layout = document.getElementById('pdfPageLayout');
|
||
layout.value = 'single';
|
||
layout.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
pdfWin.setSize(1280, 900);
|
||
await waitForJs(pdfWin, `document.querySelector('.pdfx-pages')
|
||
.classList.contains('pdfx-layout-single')`);
|
||
|
||
await js(pdfWin, `(() => {
|
||
const range = document.getElementById('progressRange');
|
||
range.value = '500';
|
||
range.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 2 页'`);
|
||
await waitForJs(pdfWin, `document.querySelector('.pdfx-page[data-page="2"] .pdfx-text span')?.textContent.length > 0`);
|
||
|
||
await js(pdfWin, `document.querySelector('[data-pane="ai"]').click();
|
||
var s=document.getElementById('aiScope');
|
||
s.value='page-image';
|
||
s.dispatchEvent(new Event('change'));`);
|
||
await waitForJs(pdfWin, `(() => {
|
||
const card = document.getElementById('aiVisualCard');
|
||
const image = document.getElementById('aiVisualPreview');
|
||
return !card.classList.contains('hidden') && image.complete && image.naturalWidth > 0;
|
||
})()`, 20000);
|
||
const fullPageVisual = await js(pdfWin, `(() => {
|
||
const image = document.getElementById('aiVisualPreview');
|
||
return {
|
||
width: image.naturalWidth,
|
||
height: image.naturalHeight,
|
||
label: document.getElementById('aiVisualMeta').textContent
|
||
};
|
||
})()`);
|
||
const fullPageDataUrl = await js(pdfWin, `document.getElementById('aiVisualPreview').src`);
|
||
check('PDF 当前页按独立高分辨率生成图像上下文',
|
||
fullPageVisual.width > 1000
|
||
&& Math.max(fullPageVisual.width, fullPageVisual.height) <= 1600
|
||
&& fullPageVisual.label.includes('第 2 页')
|
||
&& imageHasInk(fullPageDataUrl),
|
||
JSON.stringify(fullPageVisual));
|
||
|
||
await js(pdfWin, `var s=document.getElementById('aiScope');
|
||
s.value='region-image';
|
||
s.dispatchEvent(new Event('change'));`);
|
||
await waitForJs(pdfWin, `!!document.querySelector('.visual-select-overlay')`);
|
||
const pdfRegionSelected = await js(pdfWin, `(() => {
|
||
const overlay = document.querySelector('.visual-select-overlay');
|
||
const page = document.querySelector('.pdfx-page[data-page="2"]').getBoundingClientRect();
|
||
const x1 = page.left + 60;
|
||
const y1 = page.top + 70;
|
||
const x2 = Math.min(page.right - 40, x1 + 260);
|
||
const y2 = Math.min(page.bottom - 40, y1 + 190);
|
||
overlay.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 52, button: 0, buttons: 1, clientX: x1, clientY: y1 }));
|
||
overlay.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerId: 52, buttons: 1, clientX: x2, clientY: y2 }));
|
||
overlay.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 52, button: 0, clientX: x2, clientY: y2 }));
|
||
return !overlay.querySelector('.visual-select-actions').classList.contains('hidden');
|
||
})()`);
|
||
check('PDF 框选被限制在单个页面并进入确认状态', pdfRegionSelected);
|
||
await js(pdfWin, `document.querySelector('.visual-select-actions .tb-btn').click()`);
|
||
await waitForJs(pdfWin, `(() => {
|
||
const image = document.getElementById('aiVisualPreview');
|
||
return !document.querySelector('.visual-select-overlay')
|
||
&& document.getElementById('aiVisualLabel').textContent === '框选区域'
|
||
&& image.complete && image.naturalWidth > 0;
|
||
})()`, 20000);
|
||
const regionSize = await js(pdfWin, `(() => {
|
||
const image = document.getElementById('aiVisualPreview');
|
||
return { width: image.naturalWidth, height: image.naturalHeight };
|
||
})()`);
|
||
const regionDataUrl = await js(pdfWin, `document.getElementById('aiVisualPreview').src`);
|
||
// 局部裁剪会放大到发送上限,所以比的是长宽比而不是绝对像素:
|
||
// 区域比整页更“扁”,说明裁的确实是页面的一小块
|
||
check('PDF 框选区域按页面坐标高质量裁剪',
|
||
regionSize.width > 100
|
||
&& regionSize.height > 100
|
||
&& Math.max(regionSize.width, regionSize.height) <= 1600
|
||
&& regionSize.width / regionSize.height > fullPageVisual.width / fullPageVisual.height
|
||
&& imageHasInk(regionDataUrl),
|
||
JSON.stringify({ region: regionSize, page: fullPageVisual }));
|
||
await js(pdfWin, `document.getElementById('aiVisualRemoveBtn').click()`);
|
||
|
||
await js(pdfWin, `document.querySelector('[data-pane="notes"]').click();
|
||
document.getElementById('addNoteBtn').click()`);
|
||
check('阅读器新建笔记先选择读书笔记或画布笔记', await js(pdfWin,
|
||
`!document.getElementById('noteTypeChooser').classList.contains('hidden')
|
||
&& Array.from(document.querySelectorAll('#noteTypeChooser [data-note-type]'))
|
||
.map((button) => button.textContent.trim()).join('|').includes('读书笔记')
|
||
&& Array.from(document.querySelectorAll('#noteTypeChooser [data-note-type]'))
|
||
.map((button) => button.textContent.trim()).join('|').includes('画布笔记')`));
|
||
await js(pdfWin, `document.querySelector('#noteTypeChooser [data-note-type="reading"]').click()`);
|
||
check('人工笔记编辑框可由笔记面板打开', await js(pdfWin,
|
||
`!document.getElementById('noteEditorModal').classList.contains('hidden')
|
||
&& document.getElementById('noteAssociation').textContent
|
||
=== '关联当前书籍:Reader Feature Fixtures'`));
|
||
await js(pdfWin, `(() => {
|
||
document.getElementById('noteTitleInput').value = 'Manual reader note';
|
||
Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
|
||
.setText('Manual note body saved through IPC.');
|
||
document.getElementById('noteTagsInput').value = 'touch, integration,touch';
|
||
document.getElementById('noteEditorSaveBtn').click();
|
||
})()`);
|
||
await waitUntil(() => readerStore.getState(entry.id).notes.length === 1);
|
||
const manual = readerStore.getState(entry.id).notes[0];
|
||
check('人工笔记标题、正文、标签和来源写入 readerStore',
|
||
manual.title === 'Manual reader note'
|
||
&& manual.noteType === 'reading'
|
||
&& manual.text === 'Manual note body saved through IPC.'
|
||
&& manual.source === 'manual'
|
||
&& JSON.stringify(manual.tags) === JSON.stringify(['touch', 'integration']));
|
||
check('人工笔记保存 PDF 文件来源和第 2 页位置',
|
||
manual.fileIndex === 0
|
||
&& manual.documentKey === annotations.documentKey(PDF_FILE)
|
||
&& manual.locator && manual.locator.kind === 'pdf' && manual.locator.page === 2);
|
||
await waitForJs(pdfWin,
|
||
`document.getElementById('noteList').textContent.includes('Manual reader note')
|
||
&& document.getElementById('noteList').textContent.includes('#touch')`);
|
||
check('人工笔记立即显示在笔记面板', await js(pdfWin,
|
||
`document.getElementById('noteList').textContent.includes('Manual reader note')
|
||
&& document.getElementById('noteList').textContent.includes('#touch')`));
|
||
|
||
await js(pdfWin, `document.getElementById('addNoteBtn').click();
|
||
document.querySelector('#noteTypeChooser [data-note-type="canvas"]').click()`);
|
||
await waitForJs(pdfWin, `!!document.querySelector('#noteRichEditor .canvas-note-root')`, 15000);
|
||
check('阅读器画布仅工作区滚动且工具栏使用一致的图标按钮', await js(pdfWin, `(() => {
|
||
const viewport = document.querySelector('#noteRichEditor .canvas-note-viewport');
|
||
const toolbar = document.querySelector('#noteRichEditor .canvas-note-toolbar');
|
||
const buttons = [...toolbar.querySelectorAll('.canvas-note-button')];
|
||
const scrollables = [];
|
||
for (let node = viewport; node && node.id !== 'noteEditorModal'; node = node.parentElement) {
|
||
const style = getComputedStyle(node);
|
||
if (/auto|scroll/.test(style.overflowX) || /auto|scroll/.test(style.overflowY)) {
|
||
scrollables.push(node);
|
||
}
|
||
}
|
||
return scrollables.length === 1
|
||
&& scrollables[0] === viewport
|
||
&& getComputedStyle(document.getElementById('noteEditorFields')).overflowY === 'hidden'
|
||
&& getComputedStyle(toolbar).flexWrap === 'wrap'
|
||
&& getComputedStyle(toolbar).overflowX === 'visible'
|
||
&& buttons.length >= 10
|
||
&& buttons.every((button) => !!button.querySelector('.canvas-note-icon'))
|
||
&& viewport.clientHeight > 0;
|
||
})()`));
|
||
await js(pdfWin, `(() => {
|
||
document.querySelector('#noteRichEditor [data-tool="flow-text"]').click();
|
||
const quill = Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill'));
|
||
quill.setText('阅读器全局正文\\n第二段会和手写批注一起保存。\\n', 'user');
|
||
})()`);
|
||
check('阅读器全局文本工具显示富文本格式栏', await js(pdfWin,
|
||
`!document.querySelector('#noteRichEditor .canvas-flow-toolbar-host')
|
||
.classList.contains('hidden')
|
||
&& document.querySelector('#noteRichEditor .canvas-flow-layer')
|
||
.classList.contains('canvas-flow-active')`));
|
||
const originalPdfPicker = dialog.showOpenDialog;
|
||
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [PDF_FILE] });
|
||
await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-import-pdf').click()`);
|
||
await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-template').value === '__pdf'
|
||
&& document.querySelector('#noteRichEditor .canvas-note-background').width > 0`, 20000);
|
||
dialog.showOpenDialog = originalPdfPicker;
|
||
const readerFlowTextBeforeDelete = await js(pdfWin,
|
||
`Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill')).getText()`);
|
||
await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-delete-page').click()`);
|
||
check('含 PDF 底版的页面需要二次确认才删除', await js(pdfWin,
|
||
`document.querySelector('#noteRichEditor .canvas-note-delete-page')
|
||
.classList.contains('canvas-note-delete-confirm')
|
||
&& document.querySelector('#noteRichEditor .canvas-note-page-counter')
|
||
.textContent.endsWith('/ 3')`));
|
||
await js(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-delete-page').click()`);
|
||
await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-page-counter')
|
||
.textContent.endsWith('/ 2')`);
|
||
check('确认删除 PDF 页面后全局正文保持不变', await js(pdfWin,
|
||
`Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill')).getText()
|
||
=== ${JSON.stringify(readerFlowTextBeforeDelete)}`));
|
||
await js(pdfWin, `(() => {
|
||
document.getElementById('noteTitleInput').value = 'Reader canvas note';
|
||
document.querySelector('#noteRichEditor [data-tool="pen"]').click();
|
||
const canvas = document.querySelector('#noteRichEditor .upper-canvas');
|
||
const rect = canvas.getBoundingClientRect();
|
||
const event = (type, x, y, buttons) => new MouseEvent(type, {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
clientX: rect.left + x,
|
||
clientY: rect.top + y,
|
||
button: 0,
|
||
buttons
|
||
});
|
||
canvas.dispatchEvent(event('mousedown', 80, 100, 1));
|
||
document.dispatchEvent(event('mousemove', 140, 130, 1));
|
||
document.dispatchEvent(event('mouseup', 190, 150, 0));
|
||
document.getElementById('noteEditorSaveBtn').click();
|
||
})()`);
|
||
await waitUntil(() => readerStore.getState(entry.id).notes.length === 2);
|
||
const canvasNote = readerStore.getState(entry.id).notes.find((note) => (
|
||
note.title === 'Reader canvas note'
|
||
));
|
||
check('阅读器画布笔记保存 PDF 底版和自由画笔并提交受管资源',
|
||
canvasNote?.noteType === 'canvas'
|
||
&& canvasNote.canvasContent?.pages.length === 2
|
||
&& canvasNote.canvasContent.flow?.ops.some((op) => (
|
||
typeof op.insert === 'string' && op.insert.includes('阅读器全局正文')
|
||
))
|
||
&& canvasNote.canvasContent.pages.every((page) => /^pdf_[a-f0-9]{64}$/.test(
|
||
page.background.assetId || ''
|
||
))
|
||
&& canvasNote.canvasContent.pages[0].objects.some((object) => (
|
||
object.type === 'Path' && object.canvasKind === 'pen'
|
||
)));
|
||
check('阅读器笔记列表区分读书笔记和画布笔记', await js(pdfWin,
|
||
`document.getElementById('noteList').textContent.includes('读书笔记')
|
||
&& document.getElementById('noteList').textContent.includes('画布笔记')`));
|
||
await js(pdfWin, `(() => {
|
||
const item = Array.from(document.querySelectorAll('#noteList .list-item'))
|
||
.find((entry) => entry.textContent.includes('Reader canvas note'));
|
||
item.querySelector('.list-item-edit').click();
|
||
})()`);
|
||
await waitForJs(pdfWin, `document.querySelector('#noteRichEditor .canvas-note-template').value === '__pdf'
|
||
&& document.querySelector('#noteRichEditor .canvas-note-background').width > 0`, 20000);
|
||
check('重开画布笔记恢复全局正文', await js(pdfWin,
|
||
`Quill.find(document.querySelector('#noteRichEditor .canvas-flow-quill'))
|
||
.getText().includes('阅读器全局正文')`));
|
||
await js(pdfWin, `document.getElementById('noteEditorSaveBtn').click()`);
|
||
await waitForJs(pdfWin, `document.getElementById('noteEditorModal').classList.contains('hidden')`);
|
||
const reopenedCanvas = readerStore.getState(entry.id).notes.find((note) => (
|
||
note.title === 'Reader canvas note'
|
||
));
|
||
check('重开并保存画布笔记后保留 PDF 底版和 Fabric 画笔',
|
||
reopenedCanvas?.canvasContent?.pages.length === 2
|
||
&& reopenedCanvas.canvasContent.flow?.ops.some((op) => (
|
||
typeof op.insert === 'string' && op.insert.includes('阅读器全局正文')
|
||
))
|
||
&& reopenedCanvas.canvasContent.pages[0].objects.some((object) => (
|
||
object.type === 'Path' && object.canvasKind === 'pen'
|
||
)));
|
||
|
||
const crossLineSelection = await js(pdfWin, `new Promise((resolve) => {
|
||
const layer = document.querySelector('.pdfx-page[data-page="2"] .pdfx-text');
|
||
const spans = Array.from(layer.querySelectorAll('span')).filter((span) => (
|
||
span.firstChild && span.firstChild.nodeType === Node.TEXT_NODE
|
||
&& span.firstChild.data.trim().length >= 12
|
||
));
|
||
const range = document.createRange();
|
||
range.setStart(spans[0].firstChild, 5);
|
||
range.setEnd(spans[1].firstChild, Math.min(24, spans[1].firstChild.data.length));
|
||
const selection = window.getSelection();
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
requestAnimationFrame(() => {
|
||
const pageRect = layer.getBoundingClientRect();
|
||
const rects = Array.from(range.getClientRects()).filter((rect) => rect.width && rect.height);
|
||
const result = {
|
||
sentinelCount: layer.querySelectorAll('.endOfContent').length,
|
||
selecting: layer.classList.contains('selecting'),
|
||
text: selection.toString(),
|
||
bounded: rects.length >= 2 && rects.every((rect) => (
|
||
rect.left >= pageRect.left - 1
|
||
&& rect.right <= pageRect.right + 1
|
||
&& rect.top >= pageRect.top - 1
|
||
&& rect.bottom <= pageRect.bottom + 1
|
||
&& rect.height < pageRect.height / 4
|
||
))
|
||
};
|
||
selection.removeAllRanges();
|
||
resolve(result);
|
||
});
|
||
})`);
|
||
check('PDF 跨行选择只覆盖实际文字区域',
|
||
crossLineSelection.sentinelCount === 1
|
||
&& crossLineSelection.selecting
|
||
&& crossLineSelection.text.includes('selectable text validates')
|
||
&& crossLineSelection.text.includes('Touch gestures')
|
||
&& crossLineSelection.bounded,
|
||
JSON.stringify(crossLineSelection));
|
||
|
||
let excerptSelection = null;
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
excerptSelection = await selectPdfText(pdfWin, 2);
|
||
await wait(150);
|
||
if (await js(pdfWin, `!document.getElementById('selBar').classList.contains('hidden')`)) break;
|
||
}
|
||
await waitForJs(pdfWin, `!document.getElementById('selBar').classList.contains('hidden')`);
|
||
check('PDF 文本选择显示摘录和记笔记操作',
|
||
!!excerptSelection && await js(pdfWin, `(
|
||
!!document.querySelector('[data-sel="excerpt"]')
|
||
&& !!document.querySelector('[data-sel="note"]')
|
||
)`), excerptSelection && excerptSelection.text);
|
||
await js(pdfWin, `document.querySelector('[data-sel="excerpt"]').click()`);
|
||
await waitUntil(() => readerStore.getState(entry.id).notes.length === 3);
|
||
const excerpt = readerStore.getState(entry.id).notes.find((note) => (
|
||
note.source === 'selection' && !note.text
|
||
));
|
||
check('摘录操作保存引文、上下文和 PDF 位置',
|
||
!!excerpt
|
||
&& excerpt.quote === excerptSelection.text
|
||
&& excerpt.context.includes(excerptSelection.text.trim())
|
||
&& excerpt.locator.kind === 'pdf'
|
||
&& excerpt.locator.page === 2
|
||
&& Number.isInteger(excerpt.locator.offset));
|
||
check('摘录通过真实 readerStore 保存文件来源',
|
||
!!excerpt
|
||
&& excerpt.fileIndex === 0
|
||
&& excerpt.documentKey === annotations.documentKey(PDF_FILE));
|
||
check('摘录立即显示在笔记面板', await js(pdfWin,
|
||
`document.getElementById('noteList').textContent.includes(${JSON.stringify(excerptSelection.text)})`));
|
||
|
||
const noteSelection = await selectPdfText(pdfWin, 2);
|
||
await js(pdfWin, `document.querySelector('[data-sel="note"]').click()`);
|
||
await waitForJs(pdfWin, `!document.getElementById('noteEditorModal').classList.contains('hidden')`);
|
||
check('记笔记操作保留选择引文预览', await js(pdfWin,
|
||
`document.getElementById('noteQuotePreview').textContent === ${JSON.stringify(noteSelection.text)}
|
||
&& !document.getElementById('noteQuotePreview').classList.contains('hidden')`));
|
||
await js(pdfWin, `(() => {
|
||
document.getElementById('noteTitleInput').value = 'Selection reader note';
|
||
Quill.find(document.querySelector('#noteRichEditor .rich-note-quill'))
|
||
.setText('Comment attached to the selected PDF quote.');
|
||
document.getElementById('noteTagsInput').value = 'selection, pdf';
|
||
document.getElementById('noteEditorSaveBtn').click();
|
||
})()`);
|
||
await waitUntil(() => readerStore.getState(entry.id).notes.length === 4);
|
||
const selectionNote = readerStore.getState(entry.id).notes.find((note) => (
|
||
note.title === 'Selection reader note'
|
||
));
|
||
check('记笔记操作保存正文、引文、标签和选择来源',
|
||
!!selectionNote
|
||
&& selectionNote.text === 'Comment attached to the selected PDF quote.'
|
||
&& selectionNote.quote === noteSelection.text
|
||
&& selectionNote.source === 'selection'
|
||
&& JSON.stringify(selectionNote.tags) === JSON.stringify(['selection', 'pdf']));
|
||
check('选择笔记保存第 2 页定位', !!selectionNote
|
||
&& selectionNote.locator.kind === 'pdf' && selectionNote.locator.page === 2);
|
||
|
||
await js(pdfWin, `document.getElementById('annotationToggleBtn').click();
|
||
document.querySelector('[data-annotation-tool="pen"]').click()`);
|
||
await waitForJs(pdfWin, `getComputedStyle(
|
||
document.querySelector('.pdfx-page[data-page="2"] .pdfx-annotation')
|
||
).pointerEvents === 'auto'`);
|
||
const pdfTouchMode = await drawPdfTouchStroke(pdfWin, 2);
|
||
await waitForJs(pdfWin, `document.getElementById('annotationStatus').textContent.includes('1 项')`);
|
||
check('单指真实触摸事件可用画笔创建笔划', true, pdfTouchMode);
|
||
await waitUntil(() => {
|
||
const stored = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
|
||
return stored.pages['2'] && stored.pages['2'].objects.length === 1;
|
||
});
|
||
let storedAnnotations = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
|
||
check('触摸画笔通过真实批注 IPC 持久化',
|
||
storedAnnotations.pages['2'].objects[0].annotationKind === 'pen');
|
||
|
||
await js(pdfWin, `document.querySelector('[data-pane="annotations"]').click()`);
|
||
await waitForJs(pdfWin, `document.getElementById('annotationList').textContent.includes('第 2 页')`);
|
||
check('标注页列表显示页码、数量和画笔类型', await js(pdfWin,
|
||
`document.getElementById('annotationList').textContent.includes('第 2 页')
|
||
&& document.getElementById('annotationList').textContent.includes('1 项标注')
|
||
&& document.getElementById('annotationList').textContent.includes('画笔')`));
|
||
await js(pdfWin, `document.getElementById('nextBtn').click()`);
|
||
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 3 页'`);
|
||
await js(pdfWin, `document.querySelector('#annotationList .list-item-label').click()`);
|
||
await waitForJs(pdfWin, `document.getElementById('posLabel').textContent === '第 2 页'`);
|
||
check('标注页列表可跳回被标注页', true);
|
||
|
||
const zoomBefore = await js(pdfWin, `document.getElementById('zoomLabel').textContent`);
|
||
const pinchMode = await pinchPdf(pdfWin, 2);
|
||
await waitForJs(pdfWin, `!document.querySelector('.host-pdf').classList.contains('pinch-preview')
|
||
&& document.getElementById('zoomLabel').textContent !== ${JSON.stringify(zoomBefore)}`, 20000);
|
||
const zoomAfter = await js(pdfWin, `document.getElementById('zoomLabel').textContent`);
|
||
check('PDF 双指触摸缩放改变比例', zoomAfter !== zoomBefore,
|
||
`${zoomBefore} -> ${zoomAfter}; ${pinchMode}`);
|
||
check('PDF 双指缩放保持焦点页', await js(pdfWin,
|
||
`document.getElementById('posLabel').textContent === '第 2 页'`));
|
||
|
||
await waitForJs(pdfWin, `!!document.querySelector(
|
||
'.pdfx-page[data-page="2"] .pdfx-annotation .upper-canvas'
|
||
)`, 20000);
|
||
await rollbackPdfStroke(pdfWin, 2);
|
||
await wait(1800);
|
||
storedAnnotations = annotations.get(entry.id, annotations.documentKey(PDF_FILE));
|
||
check('画笔中途加入第二指会回滚未完成笔划',
|
||
storedAnnotations.pages['2'].objects.length === 1,
|
||
`对象=${storedAnnotations.pages['2'].objects.length}`);
|
||
check('回滚后界面对象计数没有增加', await js(pdfWin,
|
||
`document.getElementById('annotationStatus').textContent.includes('1 项')`));
|
||
check('PDF 阅读器没有控制台错误', pdfReader.errors.length === 0,
|
||
pdfReader.errors.slice(0, 3).join(' | '));
|
||
|
||
pdfWin.close();
|
||
await waitUntil(() => rangeSessions.status().sessions === 0, 5000);
|
||
check('关闭 PDF 阅读器会释放分段文件句柄', rangeSessions.status().sessions === 0);
|
||
|
||
const largeEpubReader = await openReader(entry.id, 4);
|
||
const largeEpubWin = largeEpubReader.win;
|
||
await waitForJs(largeEpubWin, `document.querySelector('.doc-overlay.err .doc-overlay-msg')
|
||
?.textContent.includes('超过 256 MB')`);
|
||
check('超大 EPUB 在分配整文件内存前停止并提供外部应用后备', await js(largeEpubWin, `(
|
||
document.querySelector('.doc-overlay.err .doc-overlay-msg').textContent.includes('请使用外部应用')
|
||
&& Array.from(document.querySelectorAll('.doc-overlay.err button'))
|
||
.some((button) => button.textContent === '使用系统应用打开')
|
||
)`));
|
||
largeEpubWin.close();
|
||
await wait(200);
|
||
|
||
const epubReader = await openReader(entry.id, 1);
|
||
const epubWin = epubReader.win;
|
||
await waitForJs(epubWin, `document.querySelector('.host-epub iframe')?.contentDocument?.body
|
||
?.textContent.includes('EPUB focal anchor sentence')`, 20000);
|
||
check('EPUB 本地夹具通过真实主进程 IPC 渲染', await js(epubWin,
|
||
`document.getElementById('zoomLabel').textContent === '18px'
|
||
&& document.getElementById('posLabel').textContent === 'Start'`));
|
||
check('非 PDF 阅读时隐藏 PDF 阅读和版式选项', await js(epubWin,
|
||
`document.getElementById('pdfViewControls').classList.contains('hidden')`));
|
||
const epubKey = annotations.documentKey(EPUB_FILE);
|
||
await js(epubWin, `Array.from(document.querySelectorAll('.toc-item'))
|
||
.find((item) => item.textContent.includes('Middle Section')).click()`);
|
||
await waitUntil(() => {
|
||
const progress = readerStore.getState(entry.id, epubKey).progress;
|
||
return progress && progress.locator && progress.locator.offset > 100;
|
||
}, 5000);
|
||
const tocOffset = readerStore.getState(entry.id, epubKey).progress.locator.offset;
|
||
check('EPUB 目录片段跳到章节内字符位置', tocOffset > 100, String(tocOffset));
|
||
|
||
await js(epubWin, `(() => {
|
||
const range = document.getElementById('progressRange');
|
||
range.value = '0';
|
||
range.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
await wait(700);
|
||
await js(epubWin, `document.querySelector('.host-epub iframe').contentDocument
|
||
.querySelector('a[data-epub-href="#section-mid"]').click()`);
|
||
await waitUntil(() => {
|
||
const progress = readerStore.getState(entry.id, epubKey).progress;
|
||
return progress && progress.locator && Math.abs(progress.locator.offset - tocOffset) <= 4;
|
||
}, 5000);
|
||
check('EPUB 正文保留的内部链接可导航',
|
||
Math.abs(readerStore.getState(entry.id, epubKey).progress.locator.offset - tocOffset) <= 4);
|
||
|
||
await js(epubWin, `(() => {
|
||
const range = document.getElementById('progressRange');
|
||
range.value = '420';
|
||
range.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
await wait(1400);
|
||
const pinchResult = await pinchEpub(epubWin);
|
||
await waitForJs(epubWin, `!document.querySelector('.host-epub').classList.contains('pinch-preview')
|
||
&& document.getElementById('zoomLabel').textContent !== '18px'`, 20000);
|
||
const epubFont = await js(epubWin, `parseFloat(document.querySelector(
|
||
'.host-epub iframe'
|
||
).contentWindow.getComputedStyle(document.querySelector(
|
||
'.host-epub iframe'
|
||
).contentDocument.body).fontSize)`);
|
||
check('EPUB iframe 内双指触摸改变字号',
|
||
epubFont > pinchResult.before,
|
||
`${pinchResult.before}px -> ${epubFont}px; ${pinchResult.mode}`);
|
||
check('EPUB 双指缩放保持当前章节', await js(epubWin,
|
||
`document.getElementById('posLabel').textContent === 'Start'`));
|
||
await wait(1200);
|
||
await waitUntil(() => {
|
||
const progress = readerStore.getState(entry.id).progress;
|
||
return progress && progress.locator && progress.locator.kind === 'epub';
|
||
}, 5000);
|
||
const epubProgress = readerStore.getState(entry.id).progress;
|
||
const focalAfter = await js(epubWin, `(() => {
|
||
const frame = document.querySelector('.host-epub iframe');
|
||
const doc = frame.contentDocument;
|
||
const outerRect = document.querySelector('.epub-scroll').getBoundingClientRect();
|
||
const frameRect = frame.getBoundingClientRect();
|
||
const x = 260 + outerRect.left - frameRect.left;
|
||
const y = 190 + outerRect.top - frameRect.top;
|
||
let caret = null;
|
||
if (typeof doc.caretPositionFromPoint === 'function') {
|
||
caret = doc.caretPositionFromPoint(x, y);
|
||
} else if (typeof doc.caretRangeFromPoint === 'function') {
|
||
const range = doc.caretRangeFromPoint(x, y);
|
||
if (range) caret = { offsetNode: range.startContainer, offset: range.startOffset };
|
||
}
|
||
if (!caret) return { offset: -1, charsPerLine: 0 };
|
||
// 容差要按"一行有多少字"算:字体不同,同样的一行在不同平台字数不同,
|
||
// 写死字符数的话换个字体集就会假失败
|
||
let charsPerLine = 0;
|
||
const host = caret.offsetNode.parentElement;
|
||
if (host && host.textContent) {
|
||
const range = doc.createRange();
|
||
range.selectNodeContents(host);
|
||
const lines = range.getClientRects().length;
|
||
if (lines > 0) charsPerLine = host.textContent.length / lines;
|
||
}
|
||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||
let offset = 0;
|
||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||
if (node === caret.offsetNode) return { offset: offset + caret.offset, charsPerLine };
|
||
offset += node.data.length;
|
||
}
|
||
return { offset: -1, charsPerLine };
|
||
})()`);
|
||
const focalOffsetAfter = focalAfter.offset;
|
||
// 焦点锚定的语义是"停在原来那行附近",超过一行就说明锚点真的漂了
|
||
const focalTolerance = Math.max(12, Math.ceil(focalAfter.charsPerLine));
|
||
check('EPUB 双指缩放保持焦点附近字符偏移',
|
||
Math.abs(focalOffsetAfter - pinchResult.anchorOffset) <= focalTolerance,
|
||
`${pinchResult.anchorOffset} -> ${focalOffsetAfter}; 容差=${focalTolerance}; 顶部=${epubProgress.locator.offset}`);
|
||
|
||
const epubSelection = await selectEpubText(epubWin);
|
||
await waitForJs(epubWin, `!document.getElementById('selBar').classList.contains('hidden')`);
|
||
check('EPUB iframe 单指兼容选择流程显示选择工具条',
|
||
!!epubSelection && epubSelection.text === 'EPUB focal anchor sentence');
|
||
await js(epubWin, `document.querySelector('[data-sel="excerpt"]').click()`);
|
||
await waitUntil(() => readerStore.getState(entry.id).notes.length === 5);
|
||
const epubExcerpt = readerStore.getState(entry.id).notes.find((note) => (
|
||
note.fileIndex === 1 && note.quote === 'EPUB focal anchor sentence'
|
||
));
|
||
check('EPUB 选择摘录通过真实 readerStore 保存',
|
||
!!epubExcerpt
|
||
&& epubExcerpt.source === 'selection'
|
||
&& epubExcerpt.documentKey === annotations.documentKey(EPUB_FILE)
|
||
&& epubExcerpt.locator.kind === 'epub'
|
||
&& epubExcerpt.locator.chapter === 0
|
||
&& Number.isInteger(epubExcerpt.locator.offset));
|
||
check('EPUB 摘录立即显示在笔记面板', await js(epubWin,
|
||
`document.getElementById('noteList').textContent.includes('EPUB focal anchor sentence')`));
|
||
check('EPUB 阅读器没有控制台错误', epubReader.errors.length === 0,
|
||
epubReader.errors.slice(0, 3).join(' | '));
|
||
|
||
const mobiReader = await openReader(entry.id, 2);
|
||
const mobiWin = mobiReader.win;
|
||
await waitForJs(mobiWin, `(() => {
|
||
const error = document.querySelector('.doc-overlay.err .doc-overlay-msg')?.textContent;
|
||
if (error) throw new Error(error);
|
||
return document.querySelector('.host-epub iframe')?.contentDocument?.body
|
||
?.textContent.includes('MOBI selectable text');
|
||
})()`, 30000);
|
||
check('MOBI 通过 Foliate 解析器在内置阅读器渲染', await js(mobiWin,
|
||
`document.querySelector('.doctab-fmt')?.textContent === 'mobi'
|
||
&& document.getElementById('zoomLabel').textContent === '18px'
|
||
&& document.getElementById('posLabel').textContent.includes('第 1 章')`));
|
||
check('MOBI 脚本、事件属性和外部资源在渲染前被移除', await js(mobiWin, `(() => {
|
||
const frame = document.querySelector('.host-epub iframe');
|
||
const doc = frame.contentDocument;
|
||
return !doc.querySelector('script, iframe, object, embed, [onerror], img[src^="http"]')
|
||
&& !frame.contentWindow.__mobiScriptExecuted
|
||
&& !frame.contentWindow.__mobiHandlerExecuted
|
||
&& !frame.contentWindow.__mobiLinkExecuted;
|
||
})()`));
|
||
const mobiKey = annotations.documentKey(MOBI_FILE);
|
||
await js(mobiWin, `document.getElementById('addBookmarkBtn').click()`);
|
||
await waitUntil(() => readerStore.getState(entry.id, mobiKey).bookmarks.length === 1);
|
||
check('MOBI 书签保存稳定章节字符位置', (() => {
|
||
const bookmark = readerStore.getState(entry.id, mobiKey).bookmarks[0];
|
||
return bookmark
|
||
&& bookmark.documentKey === mobiKey
|
||
&& bookmark.locator.kind === 'mobi'
|
||
&& bookmark.locator.chapter === 0
|
||
&& Number.isInteger(bookmark.locator.offset);
|
||
})());
|
||
const mobiSelection = await selectEpubText(mobiWin, 'MOBI selectable text');
|
||
await waitForJs(mobiWin, `!document.getElementById('selBar').classList.contains('hidden')`);
|
||
check('MOBI 正文支持选择和摘录操作',
|
||
!!mobiSelection && mobiSelection.text === 'MOBI selectable text');
|
||
await js(mobiWin, `document.querySelector('[data-sel="excerpt"]').click()`);
|
||
await waitUntil(() => readerStore.getState(entry.id).notes.length === 6);
|
||
const mobiExcerpt = readerStore.getState(entry.id).notes.find((note) => (
|
||
note.fileIndex === 2 && note.quote === 'MOBI selectable text'
|
||
));
|
||
check('MOBI 摘录关联原文件、文档指纹和定位',
|
||
!!mobiExcerpt
|
||
&& mobiExcerpt.documentKey === mobiKey
|
||
&& mobiExcerpt.locator.kind === 'mobi'
|
||
&& mobiExcerpt.locator.chapter === 0
|
||
&& Number.isInteger(mobiExcerpt.locator.offset));
|
||
check('MOBI 阅读器没有控制台错误', mobiReader.errors.length === 0,
|
||
mobiReader.errors.slice(0, 3).join(' | '));
|
||
const encryptedMobi = fs.readFileSync(MOBI_FILE);
|
||
encryptedMobi.writeUInt16BE(1, 96 + 12);
|
||
const drmError = await js(mobiWin, `import('./reader/mobi-adapter.mjs').then(async (module) => {
|
||
const adapter = module.createMobiAdapter('azw');
|
||
try {
|
||
await adapter.load(Uint8Array.from(${JSON.stringify([...encryptedMobi])}));
|
||
return '';
|
||
} catch (error) {
|
||
return error && error.message;
|
||
} finally {
|
||
adapter.destroy();
|
||
}
|
||
})`);
|
||
check('MOBI 适配器明确拒绝 DRM 文件且不尝试绕过', drmError.includes('DRM 保护'));
|
||
mobiWin.close();
|
||
const reopenedMobi = await openReader(entry.id, 2);
|
||
await waitForJs(reopenedMobi.win, `document.querySelector('.host-epub iframe')?.contentDocument?.body
|
||
?.textContent.includes('MOBI selectable text')`, 30000);
|
||
check('重开 MOBI 后恢复对应文档的书签和阅读状态',
|
||
readerStore.getState(entry.id, mobiKey).bookmarks.length === 1
|
||
&& await js(reopenedMobi.win, `document.getElementById('bookmarkList').textContent.includes('第 1 章')`));
|
||
check('重开的 MOBI 阅读器没有控制台错误', reopenedMobi.errors.length === 0,
|
||
reopenedMobi.errors.slice(0, 3).join(' | '));
|
||
reopenedMobi.win.close();
|
||
const drmReader = await openReader(entry.id, 3);
|
||
await waitForJs(drmReader.win, `document.querySelector('.doc-overlay.err .doc-overlay-msg')
|
||
?.textContent.includes('DRM 保护')`, 15000);
|
||
check('DRM 或不兼容 AZW 打开失败时提供系统应用后备入口',
|
||
await js(drmReader.win, `Array.from(document.querySelectorAll('.doc-overlay.err button'))
|
||
.some((button) => button.textContent === '使用系统应用打开')`));
|
||
check('DRM 后备界面没有控制台错误', drmReader.errors.length === 0,
|
||
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 存储',
|
||
readerJson.version === 6
|
||
&& fs.existsSync(readerFile)
|
||
&& readerStore.getState(entry.id).notes.length === 6,
|
||
readerFile);
|
||
check('PDF 批注文件写入隔离 reader-annotations 目录',
|
||
fs.existsSync(path.join(TMP, 'reader-annotations', `${entry.id}.json`)));
|
||
|
||
epubWin.close();
|
||
await wait(300);
|
||
const managedWin = managedReader.open(entry.id, ROOT, 0, null, 'dark');
|
||
managedWin.hide();
|
||
await waitForJs(managedWin, `document.readyState === 'complete'`, 10000);
|
||
await wait(500);
|
||
check('受管阅读器就绪握手可排空队列', managedReader.markReady(managedWin.webContents));
|
||
await js(managedWin, `(() => {
|
||
const range = document.getElementById('progressRange');
|
||
range.value = '700';
|
||
range.dispatchEvent(new Event('change'));
|
||
})()`);
|
||
const removed = await js(managedWin, `window.api.library.remove(
|
||
${JSON.stringify(entry.id)},
|
||
{ deleteFiles: false, deleteReadingData: true }
|
||
)`);
|
||
const lateWrite = await js(managedWin, `window.api.reader.setProgress(
|
||
${JSON.stringify(entry.id)},
|
||
${JSON.stringify(annotations.documentKey(PDF_FILE))},
|
||
{ kind: 'pdf', page: 1 },
|
||
0.1
|
||
)`);
|
||
await wait(1200);
|
||
check('显式删除阅读资料会等待阅读器排空并移除条目', removed && removed.ok && !library.get(entry.id));
|
||
check('显式删除后迟到的进度和批注写入不会重建资料',
|
||
lateWrite && lateWrite.ok === false
|
||
&& readerStore.getState(entry.id).notes.length === 0
|
||
&& !fs.existsSync(path.join(TMP, 'reader-annotations', `${entry.id}.json`)));
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
if (!window.isDestroyed()) window.close();
|
||
}
|
||
const failed = printSummary();
|
||
app.exit(failed ? 1 : 0);
|
||
}).catch(async (error) => {
|
||
console.error('异常:', error);
|
||
check('集成测试未发生异常', false, error && error.stack ? error.stack.split('\n')[0] : String(error));
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
if (!window.isDestroyed()) window.close();
|
||
}
|
||
printSummary();
|
||
await wait(100);
|
||
app.exit(1);
|
||
});
|