Files
reader/web/test/media-shell.test.tsx
hectorqinandClaude Opus 5.5 a634e9e955 fix(web): every search field says its scope
The side-nav global search and the shelf's own field sat side by side on
desktop and both read "搜索书名、…"; the shelf one was even labelled
搜索书库 while it only filters the shelf.

- Global (side nav, /search): 搜索书籍和影音 — also no longer truncated in
  the 216px rail.
- Shelf: 在书架中筛选书名、作者、系列, labelled 筛选书架; results are 筛选结果.
- Library: 在书库中搜索书名、作者.
- ui.md §4 records the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:15:20 +08:00

97 lines
5.1 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render } from '../src/shared/ui/render-root.ts';
import { AppShell } from '../src/app/App.tsx';
import { connectionLabel, navMode } from '../src/app/AppNav.tsx';
import { useSyncStore } from '../src/shared/stores/sync.store.ts';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
const root = document.createElement('div');
document.body.append(root);
const listeners = { addEventListener: () => undefined, removeEventListener: () => undefined };
const runtime = {
api: { baseUrl: 'https://reader.example.com' },
mediaApi: { preferenceScope: () => 'shell-test' },
player: { ...listeners, setVisible: () => undefined },
playback: { snapshot: () => ({}) },
} as any;
const user = { id: 'u', username: 'lin', displayName: '林间', role: 'member', createdAt: 0 };
const paint = (path: string) => {
act(() => render(null, root)); // a fresh router per call
act(() => render(
<RuntimeContext.Provider value={runtime}>
<MemoryRouter initialEntries={[path]}>
<Routes><Route element={<AppShell />}><Route path="*" element={<main data-testid="page">页面</main>} /></Route></Routes>
</MemoryRouter>
</RuntimeContext.Provider>,
root,
));
};
beforeEach(() => useAuthStore.setState({ session: { user } as any, verifiedUser: user as any }));
afterEach(() => {
act(() => render(null, root));
useAuthStore.setState({ session: null, verifiedUser: null });
delete document.body.dataset.appNav;
delete document.documentElement.dataset.theme;
delete document.documentElement.dataset.mode;
});
describe('shell navigation', () => {
it('decides where the bar shows: top-level pages, pushed pages, and the full-screen ones', () => {
expect(['/', '/shelf', '/me', '/media/video', '/media/music/albums', '/media/audiobook/books'].map(navMode)).toEqual(Array(6).fill('top'));
expect(['/library', '/sources', '/media/video/items/x', '/media/video/settings', '/media/video/favorites', '/media/queue'].map(navMode)).toEqual(Array(6).fill('push'));
expect(['/book/abc', '/media/video/player', '/media/music/player/lyrics'].map(navMode)).toEqual(Array(3).fill('hidden'));
});
it('puts the navigation after the page, marks 影音 and the channel current, and themes the page', () => {
paint('/media/music/albums');
const page = root.querySelector('[data-testid="page"]')!;
const nav = root.querySelector<HTMLElement>('nav.app-nav')!;
expect(nav.getAttribute('aria-label')).toBe('主导航');
expect(page.closest('.app-shell-body')).not.toBeNull();
expect(page.compareDocumentPosition(nav) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect([...nav.querySelectorAll('.app-nav-item')].map(a => a.textContent)).toEqual(['首页', '书架', '影音', '我的']);
expect(nav.querySelector('.app-nav-item[aria-current="page"]')?.textContent).toBe('影音');
expect(nav.querySelector('.app-nav-subitem[aria-current="page"]')?.textContent).toBe('音乐');
expect(document.body.dataset.appNav).toBe('top');
expect(document.documentElement.dataset.theme).toBe('warm');
});
it('keeps the sidebar on pushed pages, and removes the bar for the book and the player', () => {
paint('/media/video/items/film-1');
expect(root.querySelector('nav.app-nav')?.getAttribute('data-mode')).toBe('push');
expect(document.body.dataset.appNav).toBe('push');
paint('/media/video/player');
expect(root.querySelector('nav.app-nav')).toBeNull();
expect(document.body.dataset.appNav).toBeUndefined();
});
it('gives the book the same theme as every other page', () => {
paint('/book/abc');
expect(root.querySelector('nav.app-nav')).toBeNull();
expect(document.documentElement.dataset.theme).toBe('warm');
});
it('says whether the server is reachable under the account, and searches books and media together', () => {
expect(['idle', 'syncing', 'offline', 'error', 'signed-out'].map(state => connectionLabel(state as never).label)).toEqual(['已连接', '正在同步', '离线', '同步出错', '已连接']);
act(() => useSyncStore.getState().update({ state: 'offline', lastSyncAt: null, pending: false, message: '' }));
paint('/media/video');
expect(root.querySelector('.app-nav-user small')?.textContent).toBe('离线');
act(() => useSyncStore.getState().update({ state: 'idle', lastSyncAt: null, pending: false, message: '' }));
expect(root.querySelector('.app-nav-user small')?.textContent).toBe('已连接');
expect(root.querySelector<HTMLInputElement>('.app-nav-search input')?.placeholder).toBe('搜索书籍和影音');
});
it('marks the reading destinations under 书架 and the account page under 我的', () => {
paint('/sources');
expect(root.querySelector('.app-nav-item[aria-current="page"]')?.textContent).toBe('书架');
paint('/me');
expect(root.querySelector('.app-nav-item[aria-current="page"]')?.textContent).toBe('我的');
});
});