Files
reader/web/test/shelf-login-parity.test.tsx
T
hectorqinandClaude Opus 5.5 b8e5102669 feat(web): 我的 opens every row as a page; preferences on one page
我的 mixed sheets (书架显示, 系统设置, OPDS nested in the shelf sheet) with pages
(外观与主题, 影音浏览, 播放), and the personal preferences were four separate rows.

- 偏好设置 (#/me/settings) holds 外观与主题, 书架, 影音浏览 and 播放 as sections of
  one page, with a jump bar and ?section=; every control applies at once (影音浏览
  loses its save button and unsaved-changes prompt). The shelf's gear still opens
  the same fields (ShelfSettingsFields) as a sheet shortcut.
- 系统设置 becomes three admin pages of 我的 › 管理: 用户管理, 注册与邀请, 服务配置
  (#/me/admin/*, lazily loaded).
- 连接外部阅读器 (OPDS) is a page (#/me/opds) under 账号, also reached from the
  书架 section and the shelf sheet; back returns to whoever opened it.
- Old /me/theme|browse|playback and /media/:channel/settings[/:panel] redirect to
  the matching section; /me/account keeps its page.
- Drops MediaSettingsPage and the CSS only it used; docs follow the new paths.

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

89 lines
4.3 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest';
import { act, type ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { render } from '../src/shared/ui/render-root.ts';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
import { ShelfPage } from '../src/features/shelf/pages/ShelfPage.tsx';
import { LoginPage } from '../src/features/auth/pages/LoginPage.tsx';
vi.mock('../src/features/shelf/queries/shelf.queries.ts', () => ({
useShelf: () => ({ data: { items: [], total: 0, pageSize: 60 }, isPending: false, error: null }),
useShelfGroups: () => ({ data: undefined, isPending: false, error: null }),
}));
const root = document.createElement('div');
document.body.append(root);
if (typeof ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
function runtime() {
const api = {
baseUrl: 'https://reader.test',
currentSession: () => ({ user: { id: 'u1', username: 'admin', displayName: '管理员', role: 'admin' } }),
refreshQueries: vi.fn(),
continueReading: vi.fn(async () => []),
browseBatchShelf: vi.fn(async () => ({ books: [] })),
setBaseUrl: vi.fn(),
instance: vi.fn(async () => ({ name: 'reader', apiVersion: 1, userCount: 1, registrationOpen: false, invitationRequired: false })),
opdsCredentials: vi.fn(async () => ({ credentials: [], catalogUrl: '/opds' })),
createOpdsCredential: vi.fn(),
revokeOpdsCredential: vi.fn(),
adminUsers: vi.fn(async () => ({ users: [] })),
registrationSettings: vi.fn(async () => ({ mode: 'closed', invites: [] })),
};
return {
api,
updateSettings: vi.fn(async () => undefined),
settings: { setServerUrl: vi.fn(async () => undefined) },
} as any;
}
function mount(node: ReactNode, value = runtime(), initialEntry = '/') {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
act(() => render(<RuntimeContext value={value}><QueryClientProvider client={queryClient}><><MemoryRouter initialEntries={[initialEntry]}>{node}</MemoryRouter></></QueryClientProvider></RuntimeContext>, root));
return value;
}
afterEach(() => {
act(() => render(null, root));
useAuthStore.setState({ session: null, verifiedUser: null });
vi.restoreAllMocks();
});
function OpdsProbe() { return <p data-opds-return={useLocation().state?.returnTo} />; }
it('keeps the shelf entry points available and restores clear, settings and OPDS actions', async () => {
mount(<Routes><Route path="/shelf" element={<ShelfPage />} /><Route path="/me/opds" element={<OpdsProbe />} /></Routes>, runtime(), '/shelf?q=三体');
expect(root.textContent).toContain('书源');
expect(root.textContent).not.toContain('刷新');
expect(root.querySelector('button[aria-label^="书架设置"]')).not.toBeNull();
expect(root.querySelector('[aria-label="清除搜索"]')).not.toBeNull();
const search = root.querySelector<HTMLInputElement>('input[type="search"]')!;
await act(async () => root.querySelector<HTMLButtonElement>('[aria-label="清除搜索"]')!.click());
expect(search.value).toBe('');
const settingsButton = root.querySelector<HTMLButtonElement>('button[aria-label^="书架设置"]');
await act(async () => settingsButton?.click());
expect(root.textContent).toContain('连接外部阅读器');
await act(async () => [...root.querySelectorAll('button')].find(button => button.textContent === '连接外部阅读器')?.click());
// The OPDS page of 我的, which returns to the shelf.
await vi.waitFor(() => expect(root.querySelector('[data-opds-return]')?.getAttribute('data-opds-return')).toBe('/shelf'));
});
it('collapses the server address and explains that sessions are remembered on login', () => {
mount(<LoginPage />);
const details = root.querySelector<HTMLDetailsElement>('.login-connection')!;
expect(details.open).toBe(false);
expect(root.textContent).toContain('登录状态自动保持,无需每天重新登录');
details.querySelector('summary')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(details.open).toBe(true);
});