Files
reader/web/test/reader-chrome.test.ts
T
hectorqin fbb6dc65cf feat(ui): one Sheet for every overlay; drop 来源与刮削
- ui/sheet.tsx Sheet/ConfirmSheet replace the centred Modal, SweetAlert confirms, DialogView overlays, the reader Panel, the shelf settings floating card, the two media dialogs and Mantine Modal.
- Phones: bottom sheet. Wide windows: right-edge drawer; compact sheets (confirm, prompt, pick) open next to the tap or focused control.
- One set of exits (close, Escape, backdrop), 关闭 label, focus starts on 取消, danger confirms.
- Remove sweetalert2 and the unused @mantine/notifications.
- Remove the 来源与刮削 media settings panel and its entries in 我的 and 影音设置; TMDB and MusicBrainz stay configurable in 系统设置 > 服务配置.
- docs/ui.md 3.7 describes the rule; tests and review scripts follow the new markup.
2026-10-21 07:30:27 +08:00

321 lines
15 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, 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);
});
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('.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('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');
});
});