import { render } from '../src/shared/ui/render-root.ts'; // @vitest-environment jsdom /** * The reader's chrome, as a function of its state. * * Two of the reported defects live entirely in this tree, and both are the kind a * screenshot review cannot separate from a styling problem: * * - the quick-action rail held five sheet-opening destinations, so it was a second * settings panel floating over the text (竖排工具栏去掉 目录、字体、行距、边距、设置); * - the read-aloud bar's controls were a single flex row, so on a phone the last * of them — stop — was pushed off the right edge and the reader could not stop * the voice at all (没法停止). */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, createElement as h } from 'react'; import { ReaderChrome, type ChromeState, type ChromeHandlers, type ReaderThemeState } from '../src/ui/reader-chrome.tsx'; import { defaultThemePreference, type ThemePreference } from '../src/shared/theme/theme.ts'; const themeState = (preference: Partial = {}, dark = false): ReaderThemeState => { const merged = { ...defaultThemePreference, ...preference }; const mode: 'dark' | 'light' = dark ? 'dark' : 'light'; const reading = { theme: merged.reading === 'gray' ? 'gray' as const : 'warm' as const, mode }; return { preference: merged, app: { theme: 'warm', mode }, reading, surface: { ...reading, paper: null } }; }; function chromeState(overrides: Partial = {}): ChromeState { return { title: '书', author: '', chromeVisible: true, statusText: '', statusState: 'idle', progress: 0.2, chapterLabel: '第 1 章', tocOpen: false, settingsOpen: false, toc: [], currentSectionId: '', pageInChapter: 1, chapterPages: 3, chapterIndex: 1, chapterCount: 10, navigating: false, tts: { active: false, state: 'idle', label: '', chip: '从头朗读', index: 0, total: 0, sentenceIndex: -1, sentenceTotal: 0, }, layout: 'reflowable', format: 'epub', mode: 'scroll', fontScale: 1, lineHeight: 'inherit', theme: 'light', fit: 'contain', fontFamily: 'inherit', pageMargin: 1.5, textAlign: 'inherit', brightness: 1, pageAnimation: 'slide', tapZone: 'standard', txtEncoding: '', txtIndent: 2, txtParagraphGap: 0.55, comicDirection: 'ltr', paper: 'theme', paperFollowsDark: true, ttsRate: 1, ttsPitch: 1, ttsVolume: 1, ttsVoice: '', ttsAutoAdvance: true, ttsEngine: 'auto', showFitRow: false, showDirectionRow: false, showEncodingRow: false, showTxtRows: false, showEngineRow: false, showPitchRow: true, showVoiceRow: true, speechUnavailable: false, engineOptions: [{ value: 'auto', label: '自动' }], voices: [{ value: '', label: '跟随系统' }], ...overrides, } as ChromeState; } /** Every handler is a no-op: this file asserts the tree, not the behaviour. */ function handlers(theme: ReaderThemeState = themeState(), onTheme: ChromeHandlers['onTheme'] = () => undefined): ChromeHandlers { const noop = (): void => undefined; return { onBack: noop, toggleToc: noop, toggleSettings: noop, onSetting: noop, theme: () => theme, onTheme, onSpeechSetting: noop, onVoice: noop, onSpeakFromHere: noop, onSwitchEngine: noop, onTocEntry: noop, onChapter: noop, onScrubPage: noop, onTurnPage: noop, onSpeechToggle: noop, onSpeechPrevious: noop, onSpeechNext: noop, onSpeechScrub: noop, onStopSpeech: noop, } as unknown as ChromeHandlers; } describe('ReaderChrome', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.append(container); }); // Unmount before jsdom is torn down: a root left mounted can still commit a // scheduled update afterwards, which surfaced in CI as an unhandled // "Right-hand side of 'instanceof' is not an object" from react-dom. afterEach(() => { render(null, container); container.remove(); }); function paint(state: Partial): HTMLElement { const stage = document.createElement('div'); stage.className = 'stage'; render(h(ReaderChrome, { state: chromeState(state), stage, handlers: handlers() }), container); return container; } it('pages a comic through the whole book, opening the page only when the thumb is let go', () => { const stage = document.createElement('div'); const onScrubChapter = vi.fn(); render(h(ReaderChrome, { state: chromeState({ layout: 'fixed', chapterPages: 1, pageInChapter: 1, chapterIndex: 3, chapterCount: 10 }), stage, handlers: { ...handlers(), onScrubChapter } }), container); const slider = container.querySelector('.progress-scrubber')!; expect([slider.disabled, slider.max, slider.value]).toEqual([false, '10', '3']); act(() => { slider.value = '7'; slider.dispatchEvent(new Event('input', { bubbles: true })); }); expect(container.querySelector('.progress-page')?.textContent).toBe('第 7/10 页'); expect(onScrubChapter).not.toHaveBeenCalled(); act(() => { slider.dispatchEvent(new Event('pointerup', { bubbles: true })); }); expect(onScrubChapter).toHaveBeenCalledWith(7); }); it('offers AI 总结 only when the server says it can answer', () => { expect(paint({}).querySelector('[aria-label="AI 总结"]')).toBeNull(); expect(paint({ aiSummaryAvailable: false }).querySelector('[aria-label="AI 总结"]')).toBeNull(); expect(paint({ aiSummaryAvailable: true }).querySelector('[aria-label="AI 总结"]')).not.toBeNull(); }); it('keeps only the page-local controls on the rail', () => { // 主题 / 字号 / 听书. The five that were removed all open or reach the settings // sheet, so having them on the rail as well made it a worse copy of that sheet // floating over the text. const tree = paint({}); const rail = tree.querySelector('.reader-rail'); expect(rail).not.toBeNull(); const labels = [...(rail?.querySelectorAll('.rail-button') ?? [])].map((button) => button.getAttribute('aria-label'), ); expect(labels).toEqual(['夜间']); for (const gone of ['目录', '字体', '行距', '页边距', '阅读设置']) { expect(labels).not.toContain(gone); } }); it('offers refresh only in the chapter publication contents panel and disables it while busy', () => { expect(paint({ tocOpen: true }).textContent).not.toContain('刷新目录'); let tree = paint({ tocOpen: true, canRefresh: true }); expect([...tree.querySelectorAll('button')].find((button) => button.getAttribute('aria-label') === '刷新目录')?.disabled).toBe(false); tree = paint({ tocOpen: true, canRefresh: true, refreshing: true }); expect([...tree.querySelectorAll('button')].find((button) => button.getAttribute('aria-label') === '刷新目录')?.disabled).toBe(true); }); it('uses chapter pages for the slider even when the book has many fixed pages', () => { const tree = paint({ layout: 'fixed', chapterIndex: 50, chapterCount: 100, chapterPages: 1, pageInChapter: 1 }); const slider = tree.querySelector('.progress-scrubber')!; expect(slider.max).toBe('1'); expect(slider.value).toBe('1'); expect(slider.disabled).toBe(true); expect(slider.getAttribute('aria-label')).toBe('章节内页数'); }); it('separates appearance and behavior settings and omits text controls for comics', () => { let tree = paint({ settingsOpen: true, settingsTab: 'appearance' }); expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰'); expect(tree.querySelector('.sheet')?.textContent).not.toContain('点击区域'); tree = paint({ settingsOpen: true, settingsTab: 'behavior' }); expect(tree.querySelector('.sheet')?.textContent).toContain('点击区域'); expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号'); tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' }); expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号'); }); it('sets each of the nine tap cells, and keeps the last 菜单 cell a menu', () => { const onSetting = vi.fn(); const stage = document.createElement('div'); const grid = ['previous', 'menu', 'next', 'previous', 'next', 'next', 'previous', 'next', 'next']; render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'behavior', tapZones: grid as NonNullable }), stage, handlers: { ...handlers(), onSetting } }), container); const cells = [...container.querySelectorAll('.tap-zones-grid .tap-zone-cell')]; expect(cells.map(cell => cell.textContent)).toEqual(['上一页', '菜单', '下一页', '上一页', '下一页', '下一页', '上一页', '下一页', '下一页']); act(() => cells[8]!.click()); const nextChapter = [...document.querySelectorAll('.action-menu [role^=menuitem]')].find(item => item.textContent === '下一章')!; act(() => nextChapter.click()); expect(onSetting).toHaveBeenLastCalledWith({ tapZones: [...grid.slice(0, 8), 'next-chapter'] }); act(() => cells[1]!.click()); const items = [...document.querySelectorAll('.action-menu [role^=menuitem]')]; expect(items.filter(item => !item.disabled).map(item => item.getAttribute('aria-label') ?? item.textContent)).toEqual(['菜单']); }); it('puts the most-used appearance rows first and folds the rarely changed ones', () => { const tree = paint({ settingsOpen: true, settingsTab: 'appearance' }); const body = tree.querySelector('.sheet-body')!; const order = [...body.children].map((element) => element.matches('details.panel-fold') ? `fold:${element.querySelector('.panel-fold-title')?.textContent}` : (element.querySelector('label')?.textContent ?? '').split(' ')[0], ); // Size, spacing, theme and brightness fill the first screen; the readouts that // used to come first are behind a fold, closed by default. expect(order).toEqual(['纸色', '字号', '行距', '主题', '明暗', '亮度', 'fold:更多排版', 'fold:页眉页脚']); for (const fold of body.querySelectorAll('details.panel-fold')) expect(fold.open).toBe(false); expect(body.querySelector('details.panel-fold input[aria-label="页边距"]')).not.toBeNull(); expect(body.querySelector('details.panel-fold select[aria-label="顶部左侧"]')).not.toBeNull(); }); it('offers the reading themes as swatches, follow-app or 黑白灰, and marks the current one', () => { const tree = paint({ settingsOpen: true, settingsTab: 'appearance' }); const swatches = [...tree.querySelectorAll('[aria-label="主题"] .theme-swatch')]; expect(swatches.map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随应用', '黑白灰']); expect(swatches.filter((button) => button.getAttribute('aria-pressed') === 'true').map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随应用']); // Each chip is drawn in that theme's own background (light mode here): 黑白灰 #F5F5F5. const chip = swatches[1]!.querySelector('.theme-swatch-chip')!; expect(chip.style.background).toContain('rgb(245, 245, 245)'); const modes = [...tree.querySelectorAll('[aria-label="明暗"] button')]; expect(modes.map((button) => button.textContent)).toEqual(['跟随系统', '浅色', '深色']); expect(modes.find((button) => button.getAttribute('aria-pressed') === 'true')?.textContent).toBe('跟随系统'); }); it('saves reading-theme and day/night choices as theme preference patches', () => { const patches: Array> = []; render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'appearance' }), stage: document.createElement('div'), handlers: handlers(themeState({}, true), (patch) => patches.push(patch)) }), container); const tree = container; [...tree.querySelectorAll('[aria-label="主题"] .theme-swatch')][1]!.click(); [...tree.querySelectorAll('[aria-label="明暗"] button')][1]!.click(); // The rail's day/night button: dark now, so it offers day and leaves follow-system. const rail = tree.querySelector('.reader-rail button')!; expect(rail.getAttribute('aria-label')).toBe('日间'); rail.click(); expect(patches).toEqual([{ reading: 'gray' }, { mode: 'light', theme: 'warm' }, { mode: 'light', theme: 'warm' }]); }); it('offers the reading papers as a page setting, and the follow-dark switch only for a light paper', () => { const settings: Array> = []; const paint2 = (state: Partial) => { const h2 = handlers(); (h2 as { onSetting: ChromeHandlers['onSetting'] }).onSetting = (patch) => settings.push(patch); render(h(ReaderChrome, { state: chromeState({ settingsOpen: true, settingsTab: 'appearance', ...state }), stage: document.createElement('div'), handlers: h2 }), container); return container; }; let tree = paint2({}); const papers = [...tree.querySelectorAll('[aria-label="纸色"] .theme-swatch')]; expect(papers.map((button) => button.querySelector('.theme-swatch-label')?.textContent)).toEqual(['跟随主题', '白纸', '暖黄', '浅绿', '灰调', '夜间']); expect(papers[0]!.getAttribute('aria-pressed')).toBe('true'); // 暖黄 is drawn in its own paper: #F4ECD8. expect(papers[2]!.querySelector('.theme-swatch-chip')!.style.background).toContain('rgb(244, 236, 216)'); expect(tree.textContent).not.toContain('跟随系统深色模式'); papers[2]!.click(); expect(settings).toEqual([{ paper: 'warm' }]); tree = paint2({ paper: 'warm' }); expect(tree.textContent).toContain('跟随系统深色模式'); tree.querySelector('.switch')!.click(); expect(settings).toEqual([{ paper: 'warm' }, { paperFollowsDark: false }]); tree = paint2({ paper: 'night' }); expect(tree.textContent).not.toContain('跟随系统深色模式'); }); it('keeps typography folded away for fixed layouts and offers only the readouts', () => { const tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' }); const folds = [...tree.querySelectorAll('details.panel-fold .panel-fold-title')].map((element) => element.textContent); expect(folds).toEqual(['页眉页脚']); expect(tree.querySelector('.sheet')?.textContent).not.toContain('字号'); expect(tree.querySelector('.sheet')?.textContent).toContain('黑白灰'); }); it('puts the start action first in the read-aloud sheet and folds pitch and volume', () => { const tree = paint({ settingsOpen: true, settingsTab: 'speech', ttsRate: 1.2, showPitchRow: true }); const body = tree.querySelector('.sheet-body')!; // The action the sheet exists for is not buried under five sliders. expect(body.firstElementChild?.textContent).toBe('从当前位置朗读'); expect(body.firstElementChild?.classList.contains('primary')).toBe(true); const presets = [...body.querySelectorAll('[aria-label="常用"] button')]; expect(presets.map((button) => button.textContent)).toEqual(['0.8×', '1.0×', '1.2×', '1.5×', '2.0×']); expect(presets.filter((button) => button.getAttribute('aria-pressed') === 'true').map((button) => button.textContent)).toEqual(['1.2×']); expect(body.querySelector('details.panel-fold input[aria-label="音量"]')).not.toBeNull(); expect(body.querySelector('details.panel-fold input[aria-label="音调"]')).not.toBeNull(); }); it('draws every read-aloud control, including stop, in their own row', () => { const tree = paint({ settingsOpen: true, settingsTab: 'speech', tts: { active: true, state: 'playing', label: '正文', chip: '3/10', index: 2, total: 10, sentenceIndex: 2, sentenceTotal: 10, }, }); const bar = tree.querySelector('.tts-bar'); expect(bar).not.toBeNull(); // The controls are one row of their own, so they divide the width evenly and // none of them is clipped — which is what made 停止 unreachable. const controls = bar?.querySelector('.tts-controls'); expect(controls).not.toBeNull(); const labels = [...(controls?.querySelectorAll('button') ?? [])].map((button) => button.getAttribute('aria-label'), ); expect(labels).toContain('停止朗读'); expect(labels).toContain('暂停朗读'); expect(labels).toContain('上一句'); expect(labels).toContain('下一句'); // The sentence and its scrubber are the second row, so the sentence can never // push a control out of the bar. expect(bar?.querySelector('.tts-progress .tts-range')).not.toBeNull(); }); it('stops the voice with a square, not an ✕', () => { // ✕ read as "close this bar", which is not what stop does: the reader who wanted // the voice to stop pressed it, the bar disappeared, and the book kept reading. const tree = paint({ settingsOpen: true, settingsTab: 'speech', tts: { active: true, state: 'playing', label: '正文', chip: '1/2', index: 0, total: 2, sentenceIndex: 0, sentenceTotal: 2, }, }); const stop = [...tree.querySelectorAll('.tts-controls button')].find( (button) => button.getAttribute('aria-label') === '停止朗读', ); expect(stop?.getAttribute('class')).toContain('tts-stop'); }); });