Files
reader/web/test/reader-chrome.test.ts
hectorqinandClaude Opus 5.5 a904bb5a83 feat(web): 点击区域设置 — a 3×3 tap grid with a per-cell action
The two-way 左退右进 / 左进右退 switch becomes nine cells, each 上一页, 下一页,
菜单, 上一章, 下一章 or 无操作, edited as a small page in 阅读设置 → 翻页 with
左右互换 and 恢复默认. The default grid is the old standard layout and a stored
`tapZone: 'reversed'` is folded into its grid. At least one 菜单 cell is kept,
right-to-left books mirror the grid, and the gesture layer now reports the row
as well as the column, from layout geometry like before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:27:51 +00:00

365 lines
18 KiB
TypeScript
Raw Permalink 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.
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<ThemePreference> = {}, 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> = {}): 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<ChromeState>): 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<HTMLInputElement>('.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<HTMLInputElement>('.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<ChromeState['tapZones']> }), stage, handlers: { ...handlers(), onSetting } }), container);
const cells = [...container.querySelectorAll<HTMLButtonElement>('.tap-zones-grid .tap-zone-cell')];
expect(cells.map(cell => cell.textContent)).toEqual(['上一页', '菜单', '下一页', '上一页', '下一页', '下一页', '上一页', '下一页', '下一页']);
act(() => cells[8]!.click());
const nextChapter = [...document.querySelectorAll<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLDetailsElement>('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<HTMLButtonElement>('[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<HTMLElement>('.theme-swatch-chip')!;
expect(chip.style.background).toContain('rgb(245, 245, 245)');
const modes = [...tree.querySelectorAll<HTMLButtonElement>('[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<Partial<ThemePreference>> = [];
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<HTMLButtonElement>('[aria-label="主题"] .theme-swatch')][1]!.click();
[...tree.querySelectorAll<HTMLButtonElement>('[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<HTMLButtonElement>('.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<Record<string, unknown>> = [];
const paint2 = (state: Partial<ChromeState>) => {
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<HTMLButtonElement>('[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<HTMLElement>('.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<HTMLButtonElement>('.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');
});
});