mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
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>
97 lines
5.1 KiB
TypeScript
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('我的');
|
|
});
|
|
});
|