Files
reader/web/test/reader-chrome.test.ts
T
Claude bf325ec072 feat(reader): reorder appearance and read-aloud sheets by use
Appearance: font size, line height, theme (drawn as swatches in each theme's
own paper and ink) and brightness now fill the first screen. Margins,
alignment, font, TXT rows and the four readouts move into closed <details>.

Read aloud: the start button is first and primary; rate gains preset steps;
pitch and volume are folded under 更多. Capability hiding is unchanged.

Spec updated in docs/ui.md; chrome tests cover order, folds and swatches.
2026-10-19 22:33:17 +08:00

267 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { beforeEach, describe, expect, it } from 'vitest';
import { createElement as h } from 'react';
import { ReaderChrome, type ChromeState, type ChromeHandlers } from '../src/ui/reader-chrome.tsx';
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',
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(): ChromeHandlers {
const noop = (): void => undefined;
return {
onBack: noop,
toggleToc: noop,
toggleSettings: noop,
onSetting: noop,
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);
});
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('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('.panel')?.textContent).toContain('浅绿');
expect(tree.querySelector('.panel')?.textContent).not.toContain('点击区域');
tree = paint({ settingsOpen: true, settingsTab: 'behavior' });
expect(tree.querySelector('.panel')?.textContent).toContain('点击区域');
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
});
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('.panel-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('draws the theme choice as swatches and marks the selected one', () => {
const tree = paint({ settingsOpen: true, settingsTab: 'appearance', theme: 'sepia' });
const swatches = [...tree.querySelectorAll<HTMLButtonElement>('.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.textContent)).toEqual(['Aa米黄']);
// The chip is scoped to its own theme so it is drawn in that theme's colours.
expect(swatches.map((button) => button.querySelector('.theme-swatch-chip')?.getAttribute('data-theme'))).toEqual(['light', 'sepia', 'green', 'dark']);
});
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('.panel')?.textContent).not.toContain('字号');
expect(tree.querySelector('.panel')?.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('.panel-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');
});
});