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(); });