Files
peoplelib/src/_test/electron/library-notes.integration.js
T

2031 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, safeStorage, dialog } = require('electron');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-library-notes-ui-'));
const LIBRARY_DIR = path.join(TMP, 'library');
const FIXTURE_FILE = path.join(TMP, 'retained-book.txt');
const FIXTURE_PDF = path.join(TMP, 'retained-book.pdf');
function makePdf(file) {
const objects = [
'',
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
];
const stream = 'BT /F1 16 Tf 40 320 Td (Retained Research Book) Tj ET';
objects.push(`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`);
let body = '%PDF-1.4\n';
const offsets = [0];
for (let i = 1; i < objects.length; i++) {
offsets[i] = Buffer.byteLength(body);
body += `${i} 0 obj\n${objects[i]}\nendobj\n`;
}
const xref = Buffer.byteLength(body);
body += `xref\n0 ${objects.length}\n0000000000 65535 f \n`;
for (let i = 1; i < objects.length; i++) {
body += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
body += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
fs.writeFileSync(file, body);
}
// main.js derives its development profile from appData rather than userData.
// Redirect both before requiring it so even its initial side effects stay isolated.
app.setPath('appData', TMP);
app.setPath('userData', TMP);
fs.writeFileSync(FIXTURE_FILE, 'PeopleLib library and notes integration fixture.\n');
makePdf(FIXTURE_PDF);
const results = [];
const rendererErrors = [];
let win = null;
let coverGenerator = null;
function check(name, condition, detail = '') {
results.push([condition ? 'OK' : 'FAIL', name, detail]);
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function js(source) {
if (!win || win.isDestroyed()) throw new Error('主窗口不可用');
return win.webContents.executeJavaScript(source);
}
// 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 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}`);
}
async function pollJs(name, source, timeout) {
return poll(name, () => js(source), timeout);
}
async function waitForModal(title) {
await pollJs(
`弹窗显示:${title}`,
`(() => {
const modal = document.getElementById('modal');
return modal && !modal.classList.contains('hidden')
&& document.getElementById('modalTitle').textContent === ${JSON.stringify(title)};
})()`
);
}
async function submitModal() {
await js("document.getElementById('modalOk').click()");
await pollJs(
'弹窗提交完成',
"document.getElementById('modal').classList.contains('hidden')"
);
}
async function libraryTitles() {
return js(`Array.from(document.querySelectorAll('#libGrid .card .card-title'))
.map((element) => element.textContent.trim())`);
}
async function noteCards() {
return js(`Array.from(document.querySelectorAll('#notesList .note-card')).map((card) => ({
book: (card.querySelector('.note-book-title') || {}).textContent || '',
title: (card.querySelector('.note-title') || {}).textContent || '',
text: (card.querySelector('.note-text') || {}).textContent || '',
quote: (card.querySelector('.note-quote') || {}).textContent || '',
source: (card.querySelector('.note-badge.source') || {}).textContent || '',
type: (card.querySelector('.note-badge.type') || {}).textContent || '',
pinned: card.classList.contains('pinned'),
tags: Array.from(card.querySelectorAll('.note-tags .note-tag')).map((tag) => tag.textContent),
meta: (card.querySelector('.note-meta') || {}).textContent || ''
}))`);
}
function finish() {
console.log('\n========== 书库整理与我的笔记集成验证 ==========');
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}`);
try {
if (coverGenerator) coverGenerator.close();
} catch (error) {
console.error('关闭封面任务失败:', error.message);
}
for (const window of BrowserWindow.getAllWindows()) {
try {
if (!window.isDestroyed()) window.destroy();
} catch (error) {
console.error('关闭窗口失败:', error.message);
}
}
app.exit(failed ? 1 : 0);
}
app.on('browser-window-created', (_event, window) => {
window.webContents.on('console-message', (consoleEvent) => {
const { level, message, lineNumber, sourceId } = consoleEvent;
if (level < 2 || /Autofill|Indexing all PDF objects/i.test(message)) return;
const detail = `${message}${sourceId ? ` (${sourceId}:${lineNumber || 0})` : ''}`;
rendererErrors.push(detail);
console.error('RENDERER:', detail);
});
});
app.whenReady().then(async () => {
require(path.join(ROOT, 'main.js'));
// main.js initializes singleton stores as a require-time side effect. Point
// every profile-backed singleton back at this test's isolated directory.
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 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'));
const library = require(path.join(ROOT, 'src', 'library', 'store'));
coverGenerator = require(path.join(ROOT, 'src', 'library', 'cover-generator'));
settings.init(TMP);
readerStore.init(TMP);
annotations.init(TMP);
noteAssets.init(TMP);
aiConfig.init(TMP, safeStorage);
zlibAuth.init(TMP, safeStorage);
semanticKey.init(TMP, safeStorage);
library.init(LIBRARY_DIR);
require(path.join(ROOT, 'src', 'sources', 'http')).setProxy('');
check(
'所有配置和书库数据使用隔离目录',
app.getPath('appData') === TMP
&& app.getPath('userData').startsWith(TMP)
&& library.getRoot() === LIBRARY_DIR,
TMP
);
const researchShelf = library.addShelf({ name: '研究书架' });
const reviewShelf = library.addShelf({ name: '待复核书架' });
const retainedBook = library.add({
title: 'Retained Research Book',
authors: ['Alice Author'],
shelfId: researchShelf.id,
tags: ['Methods', 'Shared'],
files: [
{ path: FIXTURE_FILE, name: 'retained-book.txt', format: 'TXT' },
{ path: FIXTURE_PDF, name: 'retained-book.pdf', format: 'PDF' }
]
});
const selectionBook = library.add({
title: 'Selection Source Book',
authors: ['Bob Author'],
shelfId: reviewShelf.id,
tags: ['Shared', 'Review'],
files: []
});
const purgeBook = library.add({
title: 'Purge Reading Data Book',
authors: ['Carol Author'],
shelfId: null,
tags: ['Archive'],
files: []
});
const notebook = readerStore.addCollection({ name: '研究笔记本' });
readerStore.setBookSnapshot(retainedBook.id, {
title: retainedBook.title,
authors: retainedBook.authors
});
const manualNote = readerStore.addNote(retainedBook.id, {
title: 'Pinned Research Note',
text: 'Alpha insight searchable body',
quote: '',
context: 'Manual note context',
source: 'manual',
locator: null,
documentKey: null,
fileIndex: null,
collectionId: notebook.id,
tags: ['focus', 'shared-note'],
pinned: true
});
readerStore.setBookSnapshot(selectionBook.id, {
title: selectionBook.title,
authors: selectionBook.authors
});
const selectionNote = readerStore.addNote(selectionBook.id, {
title: 'Selected Passage',
text: 'Selection commentary body',
quote: 'Quoted selection excerpt',
context: 'Around selection context',
source: 'selection',
locator: { type: 'pdf', page: 3 },
documentKey: 'fixture-document-key',
fileIndex: 0,
collectionId: null,
tags: ['excerpt'],
pinned: false
});
readerStore.setBookSnapshot(purgeBook.id, {
title: purgeBook.title,
authors: purgeBook.authors
});
const purgeNote = readerStore.addNote(purgeBook.id, {
title: 'Purge With Reading Data',
text: 'This note must disappear with explicit reading-data deletion',
source: 'manual',
locator: null,
collectionId: null,
tags: ['purge-note'],
pinned: false
});
const realNow = Date.now;
try {
Date.now = () => 1000;
readerStore.setProgress(retainedBook.id, { kind: 'pdf', page: 1 }, 0.1);
Date.now = () => 2000;
readerStore.setProgress(selectionBook.id, { kind: 'pdf', page: 2 }, 0.2);
} finally {
Date.now = realNow;
}
check(
'真实存储预置三本书、两个书架和三个结构化笔记',
library.list().length === 3
&& library.listShelves().length === 2
&& readerStore.listNotes({}).length === 3
&& readerStore.listCollections().length === 1
);
check(
'预置笔记包含人工与摘录结构字段',
manualNote.source === 'manual'
&& manualNote.collectionId === notebook.id
&& manualNote.pinned === true
&& selectionNote.source === 'selection'
&& selectionNote.quote === 'Quoted selection excerpt'
&& selectionNote.locator.page === 3
&& purgeNote.source === 'manual'
);
await poll('主窗口创建', async () => {
win = BrowserWindow.getAllWindows()
.find((candidate) => candidate.getTitle() === 'PeopleLib') || null;
return !!win;
});
win.hide();
win.webContents.setBackgroundThrottling(false);
await pollJs(
'主窗口书库渲染完成',
"document.readyState === 'complete' && document.querySelectorAll('#libGrid .card').length === 3"
);
check(
'顶部提供“我的笔记”页签',
await js(`(() => {
const tab = document.querySelector('.tab[data-tab="notes"]');
return !!tab && tab.textContent.trim() === '我的笔记';
})()`)
);
check(
'设置旁边提供明暗主题图标按钮',
await js(`(() => {
const settingsButton = document.querySelector('.tab[data-tab="settings"]');
const themeButton = document.getElementById('uiThemeBtn');
return !!themeButton && settingsButton.previousElementSibling === themeButton
&& !!themeButton.querySelector('.ui-theme-sun')
&& themeButton.title === '切换到明亮主题'
&& document.querySelector('.titlebar-left').textContent.includes('人民阅读器')
&& document.querySelector('.brand-logo-dark').naturalWidth > 0;
})()`)
);
await js("document.getElementById('uiThemeBtn').click()");
await pollJs(
'主窗口切换为明亮主题',
`document.documentElement.dataset.uiTheme === 'light'
&& document.getElementById('uiThemeBtn').title === '切换到暗色主题'
&& getComputedStyle(document.body).color === 'rgb(31, 41, 55)'
&& getComputedStyle(document.querySelector('.brand-logo-light')).display !== 'none'
&& document.querySelector('.brand-logo-light').naturalWidth > 0`
);
check(
'主窗口主题与阅读器主题偏好同步持久化',
settings.get('ui.theme', 'dark') === 'light'
&& settings.get('reader.uiTheme', 'dark') === 'light'
);
await js("document.querySelector('.tab[data-tab=\"settings\"]').click()");
await js("document.getElementById('zlibLoginBtn').click()");
await pollJs('Z-Library 登录弹窗打开', "!document.getElementById('modal').classList.contains('hidden')");
check(
'Z-Library 登录取消与确定按钮尺寸一致',
await js(`(() => {
const cancel = document.getElementById('modalCancel').getBoundingClientRect();
const ok = document.getElementById('modalOk').getBoundingClientRect();
return cancel.width === ok.width && cancel.height === ok.height;
})()`)
);
await js("document.getElementById('modalCancel').click()");
await js("document.querySelector('.tab[data-tab=\"library\"]').click()");
await pollJs('返回书库页', "document.querySelectorAll('#libGrid .card').length === 3");
const allTitles = await libraryTitles();
check(
'全部书籍显示三张卡和总数',
allTitles.length === 3
&& ['Purge Reading Data Book', 'Selection Source Book', 'Retained Research Book']
.every((title) => allTitles.includes(title))
&& (await js("document.getElementById('libStatus').textContent")) === '共 3 条'
);
const rescanMissingFile = path.join(TMP, 'rescan-missing.txt');
fs.writeFileSync(rescanMissingFile, 'rescan missing fixture');
const rescanMissingShelf = library.addShelf({ name: 'Rescan Missing Shelf' });
const rescanMissingBook = library.add({
title: 'Rescan Missing Book',
shelfId: rescanMissingShelf.id,
tags: ['Rescan Missing Tag'],
files: [{ path: rescanMissingFile, name: 'rescan-missing.txt', format: 'TXT' }]
});
await pollJs(
'缺失扫描夹具显示在书库',
`!!document.querySelector('#libGrid .card[data-id="${rescanMissingBook.id}"]')`
);
fs.unlinkSync(rescanMissingFile);
await js("document.getElementById('rescanBtn').click()");
await waitForModal('发现文件缺失');
check(
'重新扫描报告缺失数量并说明保留阅读资料',
await js(`(() => {
const text = document.getElementById('modalBody').textContent;
return text.includes('1 条书库内容的文件均已缺失')
&& text.includes('对应分类和标签也会一并清理')
&& text.includes('笔记、书签、进度和标注会保留');
})()`)
);
await js("document.getElementById('modalCancel').click()");
await pollJs(
'取消缺失清理后恢复扫描按钮',
"!document.getElementById('rescanBtn').disabled",
5000
);
check(
'取消清理保留缺失书库条目',
!!library.get(rescanMissingBook.id)
&& library.get(rescanMissingBook.id).missing
&& library.listShelves().some((shelf) => shelf.id === rescanMissingShelf.id)
&& library.listTags().some((tag) => tag.name === 'Rescan Missing Tag')
);
await js("document.getElementById('rescanBtn').click()");
await waitForModal('发现文件缺失');
await submitModal();
await poll(
'确认后清理缺失书库条目',
() => Promise.resolve(!library.get(rescanMissingBook.id))
);
await pollJs(
'缺失条目清理后书库恢复',
`!document.querySelector('#libGrid .card[data-id="${rescanMissingBook.id}"]')
&& document.querySelectorAll('#libGrid .card').length === 3`
);
check(
'缺失清理移除对应空分类和标签且不影响其余内容',
library.list().length === 3
&& library.list().every((item) => item.id !== rescanMissingBook.id)
&& !library.listShelves().some((shelf) => shelf.id === rescanMissingShelf.id)
&& !library.listTags().some((tag) => tag.name === 'Rescan Missing Tag')
);
await js(`(() => {
document.getElementById('librarySearchInput').value = 'Rsrch';
document.getElementById('librarySearchBtn').click();
})()`);
await pollJs(
'书库标题模糊搜索刷新',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.querySelector('#libGrid .card-title').textContent === 'Retained Research Book'`
);
check(
'书库模糊匹配标题',
(await js("document.getElementById('libStatus').textContent"))
=== '搜索“Rsrch”显示 1 条,当前分类 3 条'
);
check(
'书库搜索结果提示独占工具栏下一行',
await js(`(() => {
const search = document.querySelector('.library-toolbar').getBoundingClientRect();
const status = document.getElementById('libStatus').getBoundingClientRect();
return status.top >= search.bottom && status.left === search.left;
})()`)
);
await js(`(() => {
document.getElementById('librarySearchInput').value = 'bob auth';
document.getElementById('librarySearchBtn').click();
})()`);
await pollJs(
'书库作者模糊搜索刷新',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.querySelector('#libGrid .card-title').textContent === 'Selection Source Book'`
);
check('书库模糊匹配作者', JSON.stringify(await libraryTitles())
=== JSON.stringify(['Selection Source Book']));
await js("document.getElementById('libraryClearSearchBtn').click()");
await pollJs('清除书库搜索恢复全部卡片', "document.querySelectorAll('#libGrid .card').length === 3");
check(
'设置中提供最近阅读排序',
await js(`Array.from(document.getElementById('sortSelect').options)
.some((option) => option.value === 'recent' && option.textContent === '最近阅读')`)
);
await js(`(() => {
const select = document.getElementById('sortSelect');
select.value = 'recent';
select.dispatchEvent(new Event('change'));
})()`);
await pollJs(
'最近阅读排序刷新',
`Array.from(document.querySelectorAll('#libGrid .card-title'))
.map((node) => node.textContent).join('|')
=== 'Selection Source Book|Retained Research Book|Purge Reading Data Book'`
);
check(
'最近阅读按阅读进度时间降序且未读条目置后',
JSON.stringify(await libraryTitles()) === JSON.stringify([
'Selection Source Book',
'Retained Research Book',
'Purge Reading Data Book'
])
);
check(
'书架和标签过多时左侧栏独立滚动',
await js(`new Promise((resolve) => {
const list = document.getElementById('libraryShelfList');
for (let index = 0; index < 80; index++) {
const row = document.createElement('button');
row.className = 'library-filter';
row.textContent = \`滚动测试书架 \${index + 1}\`;
list.appendChild(row);
}
const sidebar = document.querySelector('.library-sidebar');
sidebar.scrollTop = sidebar.scrollHeight;
requestAnimationFrame(() => {
const last = list.lastElementChild;
resolve(
getComputedStyle(sidebar).overflowY === 'auto'
&& sidebar.scrollHeight > sidebar.clientHeight
&& last.getBoundingClientRect().bottom <= sidebar.getBoundingClientRect().bottom + 1
);
});
})`)
);
await js("Library.refresh(true)");
await js(`window.__pendingCoverCard = document.querySelector(
'#libGrid .card[data-id="${selectionBook.id}"]'
)`);
const stableTag = library.addTag({ name: '异步刷新占位标签' });
await pollJs(
'无关书库通知刷新侧栏',
`Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
.some((button) => button.textContent.includes('异步刷新占位标签'))`
);
check(
'其它封面或目录刷新不会重建未获取封面的卡片',
await js(`window.__pendingCoverCard === document.querySelector(
'#libGrid .card[data-id="${selectionBook.id}"]'
) && window.__pendingCoverCard.querySelector('.card-cover')
.dataset.coverState === 'pending'`)
);
library.removeTag(stableTag.id);
await pollJs(
'移除异步刷新占位标签',
`!Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
.some((button) => button.textContent.includes('异步刷新占位标签'))`
);
check(
'书库卡片操作使用纯图标和悬浮文字',
await js(`Array.from(document.querySelectorAll('#libGrid .lib-card-actions button')).every(
(button) => !!button.querySelector('svg')
&& !button.textContent.trim()
&& !!button.title
&& button.getAttribute('aria-label') === button.title
)`)
);
check(
'书库长标题单行省略并通过悬浮提示显示全文',
await js(`(() => {
const title = document.querySelector(
'#libGrid .card[data-id="${retainedBook.id}"] .card-title'
);
const style = getComputedStyle(title);
return title.title === title.textContent
&& style.whiteSpace === 'nowrap'
&& style.overflow === 'hidden'
&& style.textOverflow === 'ellipsis';
})()`)
);
await js(`document.querySelector(
'#libGrid .card[data-id="${retainedBook.id}"] .card-cover'
).click()`);
let readerWindow = null;
await poll('点击封面创建内置阅读器窗口', async () => {
readerWindow = BrowserWindow.getAllWindows().find((candidate) => (
candidate !== win && candidate.webContents.getURL().includes('/reader.html?')
)) || null;
return !!readerWindow;
});
// 这本书的文件顺序是 [txt, pdf],封面走的是"第一个可阅读文件",
// 也就是 txt(txt/md 同样能内置阅读,走 text-adapter 转 epub 渲染)。
// 这里断言"渲染出正文",不要写死 PDF 画布:那样等于把
// "txt 不可阅读所以退到 pdf" 这个旧缺陷当成期望行为锁死
await poll('封面打开的书在内置阅读器渲染出正文', async () => (
!readerWindow.isDestroyed()
&& 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(
'分类侧栏显示全部、未分类和各书架的真实书籍数量',
await js(`(() => {
const all = document.querySelector('#libraryTab .library-filter[data-shelf=""]');
const uncategorized = document.querySelector(
'#libraryTab .library-filter[data-shelf="__uncategorized__"]'
);
const shelves = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'));
const research = shelves.find((button) => button.textContent.includes('研究书架'));
const review = shelves.find((button) => button.textContent.includes('待复核书架'));
const actionButtons = Array.from(
document.querySelectorAll('.library-shelf-actions .notes-icon-btn')
);
return all.textContent.trim() === '全部书籍(3'
&& uncategorized.textContent.trim() === '未分类(1'
&& research.textContent.trim() === '研究书架(1'
&& review.textContent.trim() === '待复核书架(1'
&& actionButtons.every((button) => {
const style = getComputedStyle(button);
return style.backgroundColor === 'rgba(0, 0, 0, 0)'
&& style.borderTopColor === 'rgba(0, 0, 0, 0)';
});
})()`)
);
const longShelfName = 'TheArtOfComputerProgramming超长分类名称';
library.updateShelf(researchShelf.id, { name: longShelfName });
await pollJs(
'超长分类名称刷新',
`Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.some((button) => button.firstElementChild.textContent === ${JSON.stringify(longShelfName)})`
);
check(
'超长分类只省略名称并完整保留数量与操作区',
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.firstElementChild.textContent === ${JSON.stringify(longShelfName)});
const name = button.firstElementChild;
const count = button.querySelector('.library-filter-count');
const actions = button.closest('.library-shelf-row').querySelector('.library-shelf-actions');
return getComputedStyle(button).display === 'flex'
&& getComputedStyle(name).textOverflow === 'ellipsis'
&& name.scrollWidth > name.clientWidth
&& count.textContent === '1'
&& count.getBoundingClientRect().width > 0
&& count.getBoundingClientRect().right <= actions.getBoundingClientRect().left + 1;
})()`)
);
library.updateShelf(researchShelf.id, { name: researchShelf.name });
await pollJs(
'恢复研究书架名称',
`Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.some((button) => button.firstElementChild.textContent === ${JSON.stringify(researchShelf.name)})`
);
check(
'标签侧栏显示真实聚合计数',
await js(`(() => {
const buttons = Array.from(document.querySelectorAll('#libraryTagList .library-filter'));
const shared = buttons.find((button) => button.textContent.includes('# Shared'));
const methods = buttons.find((button) => button.textContent.includes('# Methods'));
return !!shared && shared.querySelector('.library-filter-count').textContent === '2'
&& !!methods && methods.querySelector('.library-filter-count').textContent === '1';
})()`)
);
await js("document.getElementById('addTagBtn').click()");
await waitForModal('新建标签');
check(
'新建标签使用应用内弹窗',
await js("!!document.getElementById('libraryTagName')")
);
await js("document.getElementById('libraryTagName').value = '界面标签'");
await submitModal();
await poll(
'新标签写入真实存储',
() => Promise.resolve(library.listTags().some((tag) => tag.name === '界面标签'))
);
let uiTag = library.listTags().find((tag) => tag.name === '界面标签');
check(
'新建零使用标签并自动选中',
!!uiTag && uiTag.count === 0
&& await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
.find((candidate) => candidate.textContent.includes('# 界面标签'));
return !!button && button.classList.contains('active')
&& document.getElementById('libStatus').textContent === '显示 0 条,共 3 条';
})()`)
);
await js(`document.querySelector('[aria-label="重命名界面标签"]').click()`);
await waitForModal('重命名标签');
await js("document.getElementById('libraryTagName').value = '界面标签已重命名'");
await submitModal();
await poll(
'标签重命名写入真实存储',
() => Promise.resolve(library.listTags().some((tag) => tag.name === '界面标签已重命名'))
);
uiTag = library.listTags().find((tag) => tag.name === '界面标签已重命名');
check(
'通过界面重命名标签',
!!uiTag && !library.listTags().some((tag) => tag.name === '界面标签')
&& await js("!!document.querySelector('[aria-label=\"删除界面标签已重命名\"]')")
);
await js(`document.querySelector('#libraryTab .library-filter[data-shelf="__uncategorized__"]').click()`);
await pollJs(
'未分类筛选刷新',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.querySelector('#libGrid .card-title').textContent === 'Purge Reading Data Book'`
);
check(
'未分类筛选改变卡片和状态',
JSON.stringify(await libraryTitles()) === JSON.stringify(['Purge Reading Data Book'])
&& (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
);
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.firstElementChild.textContent.trim() === '研究书架');
button.click();
})()`);
await pollJs(
'书架筛选刷新',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.querySelector('#libGrid .card-title').textContent === 'Retained Research Book'`
);
check(
'书架筛选只显示所属书籍和状态',
JSON.stringify(await libraryTitles()) === JSON.stringify(['Retained Research Book'])
&& (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
);
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryTagList .library-filter'))
.find((candidate) => candidate.textContent.includes('# Shared'));
button.click();
})()`);
await pollJs(
'标签筛选刷新',
"document.querySelectorAll('#libGrid .card').length === 2"
);
const sharedTitles = await libraryTitles();
check(
'标签筛选显示两本匹配书籍和状态',
sharedTitles.includes('Retained Research Book')
&& sharedTitles.includes('Selection Source Book')
&& (await js("document.getElementById('libStatus').textContent")) === '显示 2 条,共 3 条'
);
await js("document.getElementById('addShelfBtn').click()");
await waitForModal('新建书架');
check(
'新建书架使用应用内弹窗',
await js("!!document.getElementById('libraryShelfName')")
);
await js("document.getElementById('libraryShelfName').value = '界面书架'");
await submitModal();
await poll(
'新书架写入真实存储',
() => Promise.resolve(library.listShelves().some((shelf) => shelf.name === '界面书架'))
);
let uiShelf = library.listShelves().find((shelf) => shelf.name === '界面书架');
check(
'通过界面创建书架并自动选中',
!!uiShelf
&& await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.firstElementChild.textContent.trim() === '界面书架');
return !!button && button.classList.contains('active')
&& document.getElementById('libStatus').textContent === '显示 0 条,共 3 条';
})()`)
);
await js(`document.querySelector('[aria-label="重命名界面书架"]').click()`);
await waitForModal('重命名书架');
check(
'重命名弹窗预填原书架名',
(await js("document.getElementById('libraryShelfName').value")) === '界面书架'
);
await js("document.getElementById('libraryShelfName').value = '界面书架已重命名'");
await submitModal();
await poll(
'书架重命名写入真实存储',
() => Promise.resolve(library.listShelves().some((shelf) => shelf.name === '界面书架已重命名'))
);
uiShelf = library.listShelves().find((shelf) => shelf.name === '界面书架已重命名');
check(
'通过界面重命名书架',
!!uiShelf
&& !library.listShelves().some((shelf) => shelf.name === '界面书架')
&& await js("!!document.querySelector('[aria-label=\"删除界面书架已重命名\"]')")
);
await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
await pollJs(
'重置全部书籍筛选',
"document.querySelectorAll('#libGrid .card').length === 3"
);
await js(`document.querySelector(
'#libGrid .card[data-id="${purgeBook.id}"] [data-act="organize"]'
).click()`);
await waitForModal('整理书籍');
check(
'整理弹窗提供书架和标签多选下拉',
await js(`!!document.getElementById('libraryBookShelf')
&& document.getElementById('libraryBookTags').tagName === 'DETAILS'
&& document.querySelectorAll('#libraryBookTags input[type="checkbox"]').length >= 4`)
);
await js(`(() => {
document.getElementById('libraryBookShelf').value = ${JSON.stringify(uiShelf.id)};
document.querySelectorAll('#libraryBookTags input[type="checkbox"]').forEach((input) => {
input.checked = input.value === 'Shared' || input.value === '界面标签已重命名';
});
})()`);
await submitModal();
await poll(
'整理操作写入真实存储',
() => {
const entry = library.get(purgeBook.id);
return Promise.resolve(
!!entry
&& entry.shelfId === uiShelf.id
&& JSON.stringify(entry.tags) === JSON.stringify(['Shared', '界面标签已重命名'])
);
}
);
check(
'通过整理弹窗分配书架并多选标签',
library.get(purgeBook.id).shelfId === uiShelf.id
&& await js(`(() => {
const card = document.querySelector('#libGrid .card[data-id="${purgeBook.id}"]');
return Array.from(card.querySelectorAll('.library-card-tag'))
.map((tag) => tag.textContent).join('|') === 'Shared|界面标签已重命名';
})()`)
);
check(
'整理后标签侧栏聚合计数同步',
await js(`(() => {
const buttons = Array.from(document.querySelectorAll('#libraryTagList .library-filter'));
const shared = buttons.find((button) => button.textContent.includes('# Shared'));
const uiTag = buttons.find((button) => button.textContent.includes('# 界面标签已重命名'));
return !!shared && shared.querySelector('.library-filter-count').textContent === '3'
&& !!uiTag && uiTag.querySelector('.library-filter-count').textContent === '1';
})()`)
);
await js(`document.querySelector('[aria-label="删除界面标签已重命名"]').click()`);
await waitForModal('删除标签');
check(
'删除标签弹窗说明会从所有书籍移除',
(await js("document.getElementById('modalBody').textContent"))
.includes('该标签会从所有书籍中移除,书籍不会被删除')
);
await submitModal();
await poll(
'删除标签和书籍引用完成',
() => Promise.resolve(
!library.listTags().some((tag) => tag.id === uiTag.id)
&& JSON.stringify(library.get(purgeBook.id).tags) === JSON.stringify(['Shared'])
)
);
check(
'删除标签不删除书籍',
!!library.get(purgeBook.id)
&& !library.listTags().some((tag) => tag.name === '界面标签已重命名')
);
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.firstElementChild.textContent.trim() === '界面书架已重命名');
button.click();
})()`);
await pollJs(
'新书架筛选刷新',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.querySelector('#libGrid .card').dataset.id === ${JSON.stringify(purgeBook.id)}`
);
await js(`document.querySelector('[aria-label="删除界面书架已重命名"]').click()`);
await waitForModal('删除书架');
check(
'删除书架弹窗说明书籍移至未分类',
(await js("document.getElementById('modalBody').textContent"))
.includes('书籍不会被删除,将移至「未分类」')
);
await submitModal();
await poll(
'删除书架完成',
() => Promise.resolve(!library.listShelves().some((shelf) => shelf.id === uiShelf.id))
);
await pollJs(
'删除书架后未分类视图刷新',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.querySelector('#libGrid .card').dataset.id === ${JSON.stringify(purgeBook.id)}`
);
check(
'删除书架将书籍移至未分类',
library.get(purgeBook.id).shelfId === null
&& await js(`document.querySelector(
'#libraryTab .library-filter[data-shelf="__uncategorized__"]'
).classList.contains('active')`)
&& (await js("document.getElementById('libStatus').textContent")) === '显示 1 条,共 3 条'
);
await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
await pollJs('多选前重置为全部书籍', "document.querySelectorAll('#libGrid .card').length === 3");
check(
'默认不进入多选模式',
await js(`document.getElementById('librarySelectionBar').classList.contains('hidden')
&& document.querySelectorAll('#libGrid .card-select').length === 0`)
);
await js("document.getElementById('librarySelectModeBtn').click()");
await pollJs(
'进入多选模式后卡片出现复选框',
"document.querySelectorAll('#libGrid .card-select').length === 3"
);
check(
'多选模式隐藏单卡操作并停用封面阅读入口',
await js(`!document.getElementById('librarySelectionBar').classList.contains('hidden')
&& document.querySelectorAll('#libGrid .lib-card-actions').length === 0
&& document.querySelectorAll('#libGrid .card-cover[data-act="read"]').length === 0
&& document.getElementById('libraryBulkOrganizeBtn').disabled
&& document.getElementById('libraryBulkRemoveBtn').disabled`)
);
await js("document.querySelectorAll('#libGrid .card-select input')[0].click()");
await pollJs(
'勾选单项后启用批量操作',
`document.getElementById('librarySelectionCount').textContent.includes('已选择 1')
&& document.getElementById('librarySelectAll').indeterminate === true
&& !document.getElementById('libraryBulkOrganizeBtn').disabled`
);
await js("document.getElementById('librarySelectAll').click()");
await pollJs(
'全选覆盖当前筛选结果',
`document.getElementById('librarySelectionCount').textContent.includes('已选择 3')
&& document.getElementById('librarySelectAllLabel').textContent === '取消全选'`
);
// 全选只作用于当前筛选结果:切到只含一本的书架后,看不见的选中项必须失效,
// 否则批量操作会误伤用户看不到的书
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.firstElementChild.textContent.trim() === '研究书架');
button.click();
})()`);
await pollJs(
'切换筛选后丢弃不可见项的选中状态',
`document.querySelectorAll('#libGrid .card').length === 1
&& document.getElementById('librarySelectionCount').textContent.includes('已选择 1')`
);
await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
await pollJs('恢复全部书籍视图', "document.querySelectorAll('#libGrid .card').length === 3");
await js("document.getElementById('librarySelectAll').click()");
await pollJs(
'重新全选三本',
"document.getElementById('librarySelectionCount').textContent.includes('已选择 3')"
);
const beforeBulk = library.list().reduce((acc, item) => {
acc[item.id] = { shelfId: item.shelfId, tags: (item.tags || []).slice() };
return acc;
}, {});
const total = Object.keys(beforeBulk).length;
const holdersOf = (name) => Object.values(beforeBulk)
.filter((entry) => entry.tags.some((tag) => tag.toLocaleLowerCase() === name.toLocaleLowerCase()))
.length;
const expectedState = (name) => {
const held = holdersOf(name);
return held === 0 ? 'none' : (held === total ? 'all' : 'some');
};
const tagStateExpectations = ['Shared', 'Methods', 'Review', 'Archive']
.map((name) => [name, expectedState(name), holdersOf(name)]);
await js("document.getElementById('libraryBulkOrganizeBtn').click()");
await waitForModal('批量整理 3 本');
const actualStates = await js(`(() => {
const map = {};
document.querySelectorAll('#libraryBulkTags input[type="checkbox"]').forEach((box) => {
map[box.value] = { state: box.dataset.state, indeterminate: box.indeterminate, checked: box.checked };
});
return map;
})()`);
check(
'批量整理按持有比例给出标签三态',
tagStateExpectations.every(([name, state]) => {
const actual = actualStates[name];
if (!actual || actual.state !== state) return false;
if (state === 'some') return actual.indeterminate === true && actual.checked === false;
if (state === 'all') return actual.indeterminate === false && actual.checked === true;
return actual.indeterminate === false && actual.checked === false;
})
// 三种状态都要真实出现过,否则这条断言可能什么都没验到
&& new Set(tagStateExpectations.map(([, state]) => state)).size === 3,
tagStateExpectations.map(([n, s, h]) => `${n}=${s}(${h}/${total})`).join(' ')
);
check(
'所选书籍书架不一致时默认保持不变',
await js("document.getElementById('libraryBulkShelf').value === '__keep__'")
);
// 只勾选一个未被任何书持有的标签,其余保持部分选中
await js(`(() => {
const box = Array.from(document.querySelectorAll('#libraryBulkTags input[type="checkbox"]'))
.find((candidate) => candidate.value === 'Archive');
box.click();
})()`);
await submitModal();
await poll(
'批量整理写入真实存储',
() => Promise.resolve(library.list().every((item) => (item.tags || []).includes('Archive')))
);
const afterBulk = library.list().reduce((acc, item) => {
acc[item.id] = { shelfId: item.shelfId, tags: (item.tags || []).slice() };
return acc;
}, {});
check(
'新增标签应用到全部选中项',
Object.values(afterBulk).every((entry) => entry.tags.includes('Archive'))
);
check(
'部分选中的标签保持原样,未被覆盖抹掉',
Object.entries(beforeBulk).every(([id, before]) => (
before.tags.every((tag) => afterBulk[id].tags.includes(tag))
)),
JSON.stringify(Object.values(afterBulk).map((e) => e.tags))
);
check(
'书架保持不变时未被改动',
Object.entries(beforeBulk).every(([id, before]) => afterBulk[id].shelfId === before.shelfId)
);
check(
'批量整理完成后退出多选模式',
await js(`document.getElementById('librarySelectionBar').classList.contains('hidden')
&& document.querySelectorAll('#libGrid .card-select').length === 0`)
);
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
await pollJs(
'我的笔记页渲染完成',
"document.querySelectorAll('#notesList .note-card').length === 3"
);
const initialCards = await noteCards();
check(
'我的笔记显示正文、摘录、书名和笔记本',
initialCards.some((card) =>
card.book === 'Retained Research Book'
&& card.text === 'Alpha insight searchable body'
&& card.meta.includes('研究笔记本'))
&& initialCards.some((card) =>
card.book === 'Selection Source Book'
&& card.quote === 'Quoted selection excerpt')
&& (await js("document.getElementById('notesStatus').textContent")) === '共 3 条笔记'
);
check(
'笔记卡使用网格视图并显示类型、来源和置顶状态',
initialCards[0].title === 'Pinned Research Note'
&& initialCards[0].pinned === true
&& initialCards.every((card) => card.type === '读书笔记')
&& initialCards[0].source === '人工'
&& initialCards.some((card) => card.title === 'Selected Passage' && card.source === '摘录')
&& await js(`getComputedStyle(document.getElementById('notesList')).display === 'grid'`)
);
check(
'笔记类型 Tab 提供全部、画布笔记和读书笔记',
await js(`Array.from(document.querySelectorAll('#notesTypeTabs .notes-type-tab'))
.map((button) => button.textContent.trim()).join(',') === '全部,画布笔记,读书笔记'`)
);
await js(`document.querySelector('#notesTypeTabs [data-note-type="canvas"]').click()`);
check(
'画布笔记 Tab 在尚无画布笔记时显示空状态',
(await noteCards()).length === 0
&& (await js("document.getElementById('notesStatus').textContent")) === '显示 0 条,共 3 条笔记'
);
await js(`document.querySelector('#notesTypeTabs [data-note-type="reading"]').click()`);
check(
'读书笔记 Tab 仅显示读书笔记',
(await noteCards()).length === 3
&& (await noteCards()).every((card) => card.type === '读书笔记')
);
await js(`document.querySelector('#notesTypeTabs [data-note-type=""]').click()`);
await js(`(() => {
document.getElementById('notesSearchInput').value = 'Quoted selection excerpt';
document.getElementById('notesSearchBtn').click();
})()`);
await pollJs(
'笔记搜索筛选刷新',
"document.querySelectorAll('#notesList .note-card').length === 1"
);
check(
'搜索筛选命中摘录文本',
(await noteCards())[0].title === 'Selected Passage'
&& (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
);
await js("document.getElementById('notesClearSearchBtn').click()");
await pollJs(
'清除笔记搜索',
"document.querySelectorAll('#notesList .note-card').length === 3"
);
await js(`(() => {
const select = document.getElementById('notesSourceSelect');
select.value = 'selection';
select.dispatchEvent(new Event('change', { bubbles: true }));
})()`);
check(
'来源筛选只显示摘录笔记',
(await noteCards()).length === 1
&& (await noteCards())[0].title === 'Selected Passage'
&& (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
);
await js(`(() => {
const select = document.getElementById('notesSourceSelect');
select.value = '';
select.dispatchEvent(new Event('change', { bubbles: true }));
})()`);
await js(`(() => {
const tag = Array.from(document.querySelectorAll('#notesTagFilters .note-tag'))
.find((button) => button.textContent === 'focus');
tag.click();
})()`);
check(
'标签筛选只显示匹配笔记',
(await noteCards()).length === 1
&& (await noteCards())[0].title === 'Pinned Research Note'
&& (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
);
await js(`(() => {
const tag = Array.from(document.querySelectorAll('#notesTagFilters .note-tag'))
.find((button) => button.textContent === 'focus');
tag.click();
})()`);
await js(`(() => {
const notebook = Array.from(document.querySelectorAll('#notesCollectionList .notes-collection'))
.find((button) => button.textContent.trim() === '研究笔记本');
notebook.click();
})()`);
check(
'笔记本筛选只显示所属笔记',
(await noteCards()).length === 1
&& (await noteCards())[0].title === 'Pinned Research Note'
&& (await js("document.getElementById('notesStatus').textContent")) === '显示 1 条,共 3 条笔记'
);
await js(`document.querySelector('#notesTab .notes-collection[data-collection=""]').click()`);
await js(`(() => {
const card = Array.from(document.querySelectorAll('#notesList .note-card'))
.find((candidate) => {
const title = candidate.querySelector('.note-title');
return title && title.textContent === 'Pinned Research Note';
});
card.querySelector('.note-action.edit').click();
})()`);
await waitForModal('编辑读书笔记');
check(
'Quill 段落选择与格式按钮保持同一行且位于 B/I 前方',
await js(`(() => {
const toolbar = document.querySelector('#noteEditRich .rich-note-toolbar');
const picker = toolbar.querySelector('.ql-picker.ql-header');
const bold = toolbar.querySelector('.ql-bold');
const options = picker.querySelector('.ql-picker-options');
return !!picker && !!bold
&& (picker.compareDocumentPosition(bold) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
&& getComputedStyle(toolbar).flexWrap === 'nowrap'
&& getComputedStyle(options).backgroundColor
=== getComputedStyle(document.querySelector('.note-card')).backgroundColor;
})()`)
);
await js(`(() => {
document.getElementById('noteEditTitle').value = 'Edited Research Note';
const quill = Quill.find(document.querySelector('#noteEditRich .rich-note-quill'));
quill.setText('Edited alpha insight body');
quill.formatText(0, 'Edited alpha insight body'.length, 'bold', true);
document.getElementById('noteEditCollection').value = ${JSON.stringify(notebook.id)};
document.getElementById('noteEditTags').value = 'focus, edited';
document.getElementById('noteEditPinned').checked = false;
const bytes = Uint8Array.from(atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z9WQAAAAASUVORK5CYII='
), (char) => char.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], 'note-image.png', { type: 'image/png' }));
const input = document.querySelector('#noteEditRich input[type="file"]');
input.files = transfer.files;
input.dispatchEvent(new Event('change'));
})()`);
await pollJs(
'富文本编辑器插入图片',
"document.querySelectorAll('#noteEditRich .ql-editor img').length === 1"
);
await submitModal();
await poll(
'编辑笔记写入真实存储',
() => {
const note = readerStore.listNotes({ entryId: retainedBook.id })
.find((candidate) => candidate.id === manualNote.id);
return Promise.resolve(
!!note
&& note.title === 'Edited Research Note'
&& note.text === 'Edited alpha insight body'
&& note.richContent.ops.some((op) => op.insert && op.insert.image)
&& note.richContent.ops.some((op) => (
op.attributes && op.attributes.bold === true
&& op.insert === 'Edited alpha insight body'
))
&& note.collectionId === notebook.id
&& JSON.stringify(note.tags) === JSON.stringify(['focus', 'edited'])
&& note.pinned === false
);
}
);
check(
'通过界面编辑标题、正文、笔记本、标签和置顶状态',
(await noteCards()).some((card) =>
card.title === 'Edited Research Note'
&& card.text === 'Edited alpha insight body'
&& card.pinned === false
&& card.tags.join('|') === 'focus|edited')
);
check(
'富文本正文和内嵌图片安全渲染在笔记卡片',
await js(`(() => {
const card = Array.from(document.querySelectorAll('#notesList .note-card'))
.find((candidate) => candidate.querySelector('.note-title')?.textContent === 'Edited Research Note');
const image = card && card.querySelector('.note-text img');
return !!image
&& image.src.startsWith('data:image/png;base64,')
&& card.querySelector('.note-text strong')?.textContent === 'Edited alpha insight body';
})()`)
);
await js(`(() => {
const card = Array.from(document.querySelectorAll('#notesList .note-card'))
.find((candidate) => {
const title = candidate.querySelector('.note-title');
return title && title.textContent === 'Selected Passage';
});
card.querySelector('.note-action.delete').click();
})()`);
await waitForModal('删除笔记');
check(
'删除笔记使用应用内确认弹窗',
(await js("document.getElementById('modalBody').textContent")).includes('此操作无法撤销')
);
await submitModal();
await poll(
'删除笔记写入真实存储',
() => Promise.resolve(
!readerStore.listNotes({ entryId: selectionBook.id })
.some((note) => note.id === selectionNote.id)
)
);
await pollJs(
'删除笔记后页面刷新',
"document.querySelectorAll('#notesList .note-card').length === 2"
);
check(
'通过界面删除摘录笔记',
!(await noteCards()).some((card) => card.title === 'Selected Passage')
&& (await js("document.getElementById('notesStatus').textContent")) === '共 2 条笔记'
);
await js("document.getElementById('addGlobalNoteBtn').click()");
await waitForModal('选择笔记类型');
check(
'新建笔记先选择读书笔记或画布笔记',
await js(`Array.from(document.querySelectorAll('.note-type-choice-title'))
.map((item) => item.textContent.trim()).join(',') === '读书笔记,画布笔记'`)
);
await js("document.getElementById('modalOk').click()");
await waitForModal('新建读书笔记');
check(
'新建读书笔记可选择关联书籍',
await js(`(() => {
const select = document.getElementById('newNoteBook');
return !!select && Array.from(select.options)
.some((option) => option.value === ${JSON.stringify(retainedBook.id)}
&& option.textContent === 'Retained Research Book');
})()`)
);
await js(`(() => {
document.getElementById('newNoteBook').value = ${JSON.stringify(retainedBook.id)};
document.getElementById('newNoteTitle').value = 'UI Created Note';
Quill.find(document.querySelector('#newNoteRich .rich-note-quill'))
.setText('Created directly from My Notes page');
document.getElementById('newNoteCollection').value = ${JSON.stringify(notebook.id)};
document.getElementById('newNoteTags').value = 'ui-created, focus';
document.getElementById('newNotePinned').checked = true;
})()`);
await submitModal();
let createdNote = null;
await poll(
'页面新建笔记写入真实存储',
() => {
createdNote = readerStore.listNotes({ entryId: retainedBook.id })
.find((note) => note.text === 'Created directly from My Notes page') || null;
return Promise.resolve(!!createdNote);
}
);
await pollJs(
'页面新建笔记渲染完成',
`Array.from(document.querySelectorAll('#notesList .note-title'))
.some((title) => title.textContent === 'UI Created Note')`
);
check(
'页面新建笔记保存关联、标题、正文、笔记本、标签和置顶',
createdNote.title === 'UI Created Note'
&& createdNote.entryId === retainedBook.id
&& createdNote.collectionId === notebook.id
&& JSON.stringify(createdNote.tags) === JSON.stringify(['ui-created', 'focus'])
&& createdNote.pinned === true
&& createdNote.noteType === 'reading'
&& !createdNote.canvasContent
&& await js(`Array.from(document.querySelectorAll('.note-badge.type'))
.some((item) => item.textContent === '读书笔记')`)
&& (await noteCards())[0].title === 'UI Created Note'
);
await js(`document.querySelector('.tab[data-tab="library"]').click()`);
await js(`document.querySelector('#libraryTab .library-filter[data-shelf=""]').click()`);
await pollJs(
'返回全部书籍视图',
"document.querySelectorAll('#libGrid .card').length === 3"
);
check(
'匹配书库卡片显示真实笔记数徽标',
await js(`(() => {
const badge = document.querySelector(
'#libGrid .card[data-id="${retainedBook.id}"] .card-badge.note-count'
);
return !!badge && badge.textContent.trim() === '笔记 2';
})()`)
);
await js(`document.querySelector(
'#libGrid .card[data-id="${retainedBook.id}"] [data-act="remove"]'
).click()`);
await waitForModal('移除条目');
check(
'移除弹窗两个删除选项默认均未勾选',
await js(`(() => {
const files = document.getElementById('delFiles');
const reading = document.getElementById('delReadingData');
return !!files && !files.checked && !!reading && !reading.checked;
})()`)
);
check(
'移除弹窗明确说明默认保留阅读资料',
(await js("document.getElementById('modalBody').textContent"))
.includes('默认保留阅读资料,移除后仍可在「我的笔记」中查看')
);
await submitModal();
await poll(
'保留阅读资料移除书籍',
() => Promise.resolve(!library.get(retainedBook.id))
);
await pollJs(
'保留阅读资料移除后书库刷新',
`!document.querySelector('#libGrid .card[data-id="${retainedBook.id}"]')
&& document.querySelectorAll('#libGrid .card').length === 2`
);
check(
'默认移除保留该书全部笔记',
readerStore.listNotes({ entryId: retainedBook.id }).length === 2
&& fs.existsSync(FIXTURE_FILE)
);
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
await pollJs(
'默认移除后聚合笔记仍显示',
"document.querySelectorAll('#notesList .note-card').length === 3"
);
check(
'已移除书籍的笔记仍保留书名并出现在聚合页',
(await noteCards()).filter((card) => card.book === 'Retained Research Book').length === 2
&& (await js("document.getElementById('notesStatus').textContent")) === '共 3 条笔记'
);
await js(`document.querySelector('.tab[data-tab="library"]').click()`);
await pollJs(
'返回书库执行显式阅读数据删除',
`!!document.querySelector('#libGrid .card[data-id="${purgeBook.id}"]')`
);
await js(`document.querySelector(
'#libGrid .card[data-id="${purgeBook.id}"] [data-act="remove"]'
).click()`);
await waitForModal('移除条目');
check(
'无文件条目仍提供阅读数据删除且默认未勾选',
await js(`!document.getElementById('delFiles')
&& !!document.getElementById('delReadingData')
&& !document.getElementById('delReadingData').checked`)
);
await js("document.getElementById('delReadingData').checked = true");
await submitModal();
await poll(
'显式删除阅读数据完成',
() => Promise.resolve(
!library.get(purgeBook.id)
&& readerStore.listNotes({ entryId: purgeBook.id }).length === 0
)
);
check(
'勾选阅读数据删除会移除第二本书的笔记',
!library.get(purgeBook.id)
&& readerStore.listNotes({ entryId: purgeBook.id }).length === 0
);
await js(`document.querySelector('.tab[data-tab="notes"]').click()`);
await pollJs(
'显式删除阅读数据后笔记页刷新',
"document.querySelectorAll('#notesList .note-card').length === 2"
);
check(
'显式删除后聚合页仅保留默认保留的笔记',
(await noteCards()).every((card) => card.book === 'Retained Research Book')
&& !(await noteCards()).some((card) => card.title === 'Purge With Reading Data')
&& (await js("document.getElementById('notesStatus').textContent")) === '共 2 条笔记'
);
await js("document.getElementById('addGlobalNoteBtn').click()");
await waitForModal('选择笔记类型');
await js(`(() => {
document.querySelector('input[name="newNoteType"][value="canvas"]').checked = true;
document.getElementById('modalOk').click();
})()`);
await waitForModal('新建画布笔记');
await pollJs(
'独立画布笔记编辑器加载完成',
"!!document.querySelector('#newNoteRich .canvas-note-root')",
15000
);
check(
'主窗口画布仅工作区滚动且工具栏使用一致的图标按钮',
await js(`(() => {
const viewport = document.querySelector('#newNoteRich .canvas-note-viewport');
const toolbar = document.querySelector('#newNoteRich .canvas-note-toolbar');
const buttons = [...toolbar.querySelectorAll('.canvas-note-button')];
const scrollables = [];
for (let node = viewport; node && node.id !== 'modal'; 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('modalBody')).overflowY === 'hidden'
&& getComputedStyle(toolbar).flexWrap === 'wrap'
&& getComputedStyle(toolbar).overflowX === 'visible'
&& buttons.length >= 10
&& buttons.every((button) => !!button.querySelector('.canvas-note-icon'))
&& viewport.clientHeight > 0;
})()`)
);
check(
'新建画布笔记允许选择不关联书籍',
await js(`(() => {
const select = document.getElementById('newNoteBook');
return !!select && select.value === ''
&& select.options[0].textContent === '不关联书籍';
})()`)
);
await js(`(() => {
document.querySelector('#newNoteRich [data-tool="flow-text"]').click();
const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
const text = Array.from({ length: 90 }, (_, index) =>
\`第 \${index + 1} 段全局正文会在纸张边界自动流入下一页。\`
).join('\\n');
quill.setText(text, 'user');
quill.setSelection(quill.getLength() - 1, 0, 'silent');
})()`);
await pollJs(
'全局文本超过当前纸张后自动分页',
`Number(document.querySelector('#newNoteRich .canvas-note-page-counter')
.textContent.split('/')[1]) > 1`,
15000
);
await js("document.querySelector('#newNoteRich .canvas-note-undo').click()");
await pollJs(
'全局文本使用画布统一撤销',
`Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill')).getText().trim() === ''`,
10000
);
await js("document.querySelector('#newNoteRich .canvas-note-redo').click()");
await pollJs(
'全局文本使用画布统一重做',
`Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
.getText().includes('第 90 段全局正文')`,
10000
);
await js(`(() => {
const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
quill.formatText(0, 8, 'bold', true, 'user');
quill.formatLine(0, 1, 'header', 1, 'user');
})()`);
const flowBeforePageOperations = await js(`(() => {
const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
return {
text: quill.getText(),
pages: Number(document.querySelector('#newNoteRich .canvas-note-page-counter')
.textContent.split('/')[1])
};
})()`);
await js(`(() => {
const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
quill.setSelection(300, 0, 'silent');
document.querySelector('#newNoteRich .canvas-note-add-page').click();
})()`);
await pollJs(
'全局文本添加页面写入显式分页符',
`Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
.getContents().ops.some((op) => op.insert && op.insert.canvasPageBreak)`,
10000
);
await wait(600);
await pollJs(
'添加显式页面操作完成',
`!document.querySelector('#newNoteRich .canvas-note-delete-page').disabled`,
10000
);
check(
'显式分页符把后续正文移动到下一张纸',
await js(`(() => {
const pageBreak = document.querySelector('#newNoteRich .canvas-flow-page-break');
const nextBlock = pageBreak?.nextElementSibling;
return !!nextBlock
&& nextBlock.getBoundingClientRect().left
- pageBreak.getBoundingClientRect().left > 300;
})()`)
);
await js("document.querySelector('#newNoteRich .canvas-note-delete-page').click()");
await pollJs(
'删除空白显式页面仅移除分页符',
`!Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
.getContents().ops.some((op) => op.insert && op.insert.canvasPageBreak)`,
10000
);
const flowAfterPageOperations = await js(`(() => {
const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
return quill.getText();
})()`);
check(
'添加和删除页面不会删除全局正文',
flowAfterPageOperations === flowBeforePageOperations.text,
`${flowBeforePageOperations.text.length} -> ${flowAfterPageOperations.length}`
);
await js(`(() => {
const quill = Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'));
quill.setSelection(600, 0, 'silent');
document.querySelector('#newNoteRich .canvas-note-add-page').click();
})()`);
await pollJs(
'显式分页符保留到保存内容',
`Quill.find(document.querySelector('#newNoteRich .canvas-flow-quill'))
.getContents().ops.some((op) => op.insert && op.insert.canvasPageBreak)`,
10000
);
await wait(600);
await pollJs(
'再次添加显式页面操作完成',
`!document.querySelector('#newNoteRich .canvas-note-import-pdf').disabled`,
10000
);
await js(`(() => {
document.getElementById('newNoteTitle').value = 'Standalone Note';
document.getElementById('newNoteTags').value = 'standalone';
})()`);
const originalPdfPicker = dialog.showOpenDialog;
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [FIXTURE_PDF] });
await js("document.querySelector('#newNoteRich .canvas-note-import-pdf').click()");
await pollJs(
'PDF 导入为可标注底版',
`document.querySelector('#newNoteRich .canvas-note-template').value === '__pdf'
&& document.querySelector('#newNoteRich .canvas-note-background').width > 0
&& !document.querySelector('#newNoteRich .canvas-note-export-pdf').disabled`,
20000
);
dialog.showOpenDialog = originalPdfPicker;
const exportedCanvasPdf = path.join(TMP, 'exported-canvas-note.pdf');
const originalSaveDialog = dialog.showSaveDialog;
dialog.showSaveDialog = async () => ({ canceled: false, filePath: exportedCanvasPdf });
await js("document.querySelector('#newNoteRich .canvas-note-export-pdf').click()");
await poll(
'自由画布导出有效 PDF',
async () => {
if (fs.existsSync(exportedCanvasPdf)
&& fs.readFileSync(exportedCanvasPdf).subarray(0, 5).toString() === '%PDF-') {
return true;
}
const error = await js("document.getElementById('newNoteError').textContent");
if (error) throw new Error(error);
return false;
},
20000
);
dialog.showSaveDialog = originalSaveDialog;
await submitModal();
let standaloneNote = null;
await poll(
'无关联笔记写入独立存储',
() => {
standaloneNote = readerStore.listNotes({
entryId: readerStore.STANDALONE_ENTRY_ID
}).find((note) => note.title === 'Standalone Note') || null;
return Promise.resolve(!!standaloneNote);
}
);
await pollJs(
'无关联笔记渲染完成',
`Array.from(document.querySelectorAll('#notesList .note-title'))
.some((title) => title.textContent === 'Standalone Note')`
);
await js(`(() => {
const title = Array.from(document.querySelectorAll('#notesList .note-title'))
.find((item) => item.textContent === 'Standalone Note');
title.closest('.note-card').querySelector('.note-action.edit').click();
})()`);
await waitForModal('编辑画布笔记');
await pollJs(
'重开画布笔记恢复全局正文和显式分页符',
`Quill.find(document.querySelector('#noteEditRich .canvas-flow-quill'))
.getText().includes('第 90 段全局正文')
&& !!document.querySelector('#noteEditRich .canvas-flow-page-break')`,
15000
);
await js("document.getElementById('modalCancel').click()");
await pollJs('关闭重开画布笔记弹窗', "document.getElementById('modal').classList.contains('hidden')");
check(
'无关联笔记显示明确状态且不提供原文按钮',
standaloneNote.associated === false
&& standaloneNote.noteType === 'canvas'
&& standaloneNote.canvasContent.version === 2
&& standaloneNote.canvasContent.flow.ops.some((op) => (
typeof op.insert === 'string' && op.insert.includes('全局正文')
))
&& standaloneNote.canvasContent.flow.ops.some((op) => op.attributes?.bold === true)
&& standaloneNote.canvasContent.flow.ops.some((op) => op.attributes?.header === 1)
&& standaloneNote.canvasContent.flow.ops.some((op) => (
op.insert?.canvasPageBreak
&& standaloneNote.canvasContent.pages.some(
(page) => page.id === op.insert.canvasPageBreak
)
))
&& standaloneNote.canvasContent.pages.some((page) => page.background.type === 'pdf')
&& /^pdf_[a-f0-9]{64}$/.test(
standaloneNote.canvasContent.pages.find(
(page) => page.background.type === 'pdf'
).background.assetId
)
&& !Object.prototype.hasOwnProperty.call(
standaloneNote.canvasContent.pages.find(
(page) => page.background.type === 'pdf'
).background,
'draftToken'
)
&& readerStore.noteAssetIds().includes(
standaloneNote.canvasContent.pages.find(
(page) => page.background.type === 'pdf'
).background.assetId
)
&& (await noteCards()).some((card) => (
card.title === 'Standalone Note' && card.book === '未关联书籍'
))
&& await js(`(() => {
const title = Array.from(document.querySelectorAll('#notesList .note-title'))
.find((item) => item.textContent === 'Standalone Note');
const card = title && title.closest('.note-card');
return !!card && !card.querySelector('.note-action.open');
})()`)
);
await js(`document.querySelector('#notesTypeTabs [data-note-type="canvas"]').click()`);
check(
'画布笔记 Tab 只显示画布卡片',
(await noteCards()).length === 1
&& (await noteCards())[0].title === 'Standalone Note'
&& (await noteCards())[0].type === '画布笔记'
&& await js(`!!document.querySelector('#notesList .note-canvas-preview')`)
);
await js(`document.querySelector('#notesTypeTabs [data-note-type="reading"]').click()`);
check(
'读书笔记 Tab 隐藏画布卡片',
(await noteCards()).length === 2
&& (await noteCards()).every((card) => card.type === '读书笔记')
&& (await noteCards()).some((card) => card.title === 'Edited Research Note')
);
await js(`document.querySelector('#notesTypeTabs [data-note-type=""]').click()`);
const importRoot = path.join(TMP, 'existing-folder-layout');
const literatureDir = path.join(importRoot, '旧文学分类');
const technologyDir = path.join(importRoot, '旧技术分类');
fs.mkdirSync(literatureDir, { recursive: true });
fs.mkdirSync(technologyDir, { recursive: true });
fs.writeFileSync(path.join(literatureDir, 'Local Novel.epub'), 'epub fixture');
fs.writeFileSync(path.join(technologyDir, 'Local Manual.pdf'), 'pdf fixture');
const originalShowOpenDialog = dialog.showOpenDialog;
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [importRoot] });
await js("document.getElementById('addLocalBtn').click()");
await waitForModal('添加本地内容');
await js(`(() => {
document.querySelector('input[name="localImportSource"][value="folder"]').checked = true;
document.getElementById('modalOk').click();
})()`);
await waitForModal('导入本地图书');
check(
'本地导入支持递归文件夹和上级目录组织选项',
await js(`document.getElementById('modalBody').textContent.includes('发现 2 个支持的图书文件')
&& !!document.querySelector('input[name="localImportOrganization"][value="shelf"]')
&& !!document.querySelector('input[name="localImportOrganization"][value="tag"]')`)
);
await js(`document.querySelector(
'input[name="localImportOrganization"][value="shelf"]'
).checked = true`);
await submitModal();
await poll(
'文件夹导入写入书库和上级目录书架',
() => Promise.resolve(
!!library.list().find((item) => item.title === 'Local Novel')
&& !!library.list().find((item) => item.title === 'Local Manual')
&& library.listShelves().some((shelf) => shelf.name === '旧文学分类')
&& library.listShelves().some((shelf) => shelf.name === '旧技术分类')
)
);
const localNovel = library.list().find((item) => item.title === 'Local Novel');
const literatureShelf = library.listShelves().find((shelf) => shelf.name === '旧文学分类');
check(
'文件的上一级目录被复用为书架分类',
localNovel.shelfId === literatureShelf.id
&& library.list().filter((item) => item.importedByLocal).length === 2
);
const repeatSelection = await js("window.api.library.pickLocal('folder')");
const repeatId = JSON.stringify(repeatSelection.data.selectionId);
const repeatImport = await js(
`window.api.library.importLocal(${repeatId}, { organization: 'shelf' })`
);
const replayImport = await js(
`window.api.library.importLocal(${repeatId}, { organization: 'shelf' })`
);
check(
'重复文件被跳过且本地选择令牌不能重放',
repeatImport.ok
&& repeatImport.data.added === 0
&& repeatImport.data.skipped === 2
&& !replayImport.ok
&& replayImport.error.includes('已失效')
);
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(
'主渲染进程没有控制台错误',
rendererErrors.length === 0,
rendererErrors.slice(0, 3).join(' | ')
);
finish();
}).catch((error) => {
console.error('异常:', error && error.stack ? error.stack : error);
check('集成脚本无未处理异常', false, error && error.message ? error.message : String(error));
finish();
});