Files
reader/web/test/media-settings.test.tsx
hectorqin 92f26ba122 feat(web): ⋯ menus drop down; drop 影音设置; rebuild 插件管理
- ActionMenu is an anchored dropdown on every width (popover, no title bar or backdrop; closes on pick, Escape, Tab or a press outside; arrow keys). ActionSheet stays for question-style picks. Styles in styles/action-menu.css.
- Remove the media settings home and its ⋯ entry: panels live only at /me/:panel; /media/:channel/settings[/:panel] redirects to 我的. Library and task pages return to 我的 or the channel.
- 插件管理: one list with status tags and a per-row ⋯ menu (启用/停用, 卸载 with confirmation); install moves into a Sheet (npm / local file, trust switch). Old plugin styles removed from reader.css.
- 首页: drop the redundant 继续阅读 pill on the reading card.
- Docs, unit tests and ui:review updated (stub admin settings in the media review).
2026-10-21 15:26:49 +08:00

68 lines
5.3 KiB
TypeScript

import { render } from '../src/shared/ui/render-root.ts';
// @vitest-environment jsdom
import {afterEach,expect,it,vi} from 'vitest';
import {act} from 'react';
import {MediaSettings} from '../src/features/media/components/settings.tsx';
import {defaultMediaPreferences,readMediaPreferences,saveMediaPreferences} from '../src/features/media/services/preferences.ts';
import type {MediaPlayer} from '../src/features/media/services/player.ts';
import {MediaApi} from '../src/features/media/api/media-api.ts';
import type {ReaderApi} from '../src/api/client.ts';
const root=document.createElement('div');document.body.append(root);
afterEach(()=>{act(()=>render(null,root));localStorage.clear();vi.restoreAllMocks();});
it('isolates device preferences by server/account and leaves reading storage untouched',()=>{
localStorage.setItem('reader.settings','unchanged');
const first='["server-a","user"]',second='["server-b","user"]';
saveMediaPreferences(first,{density:'comfortable',showContinue:false,showLibraryName:false});
expect(readMediaPreferences(first).density).toBe('comfortable');expect(readMediaPreferences(second)).toEqual(defaultMediaPreferences);
expect(localStorage.getItem('reader.settings')).toBe('unchanged');
localStorage.setItem('reader.media.preferences.v1:'+first,'broken');expect(readMediaPreferences(first)).toEqual(defaultMediaPreferences);
});
const player={active:false} as MediaPlayer;
const change=(select:HTMLSelectElement,value:string)=>act(()=>{select.value=value;select.dispatchEvent(new Event('change',{bubbles:true}));});
const back=()=>act(()=>root.querySelector<HTMLButtonElement>('[aria-label="返回我的"]')!.click());
it('saves explicit changes and reports storage errors without applying them',async()=>{
const onSaved=vi.fn();
await act(async()=>render(<MediaSettings scope="test" panel="browse" preferences={defaultMediaPreferences} player={player} onSaved={onSaved} onBack={vi.fn()}/>,root));
change(root.querySelector('select')!,'comfortable');
expect(onSaved).not.toHaveBeenCalled();
await act(async()=>{root.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});
expect(onSaved).toHaveBeenCalledWith({...defaultMediaPreferences,density:'comfortable'});
vi.spyOn(Storage.prototype,'setItem').mockImplementation(()=>{throw new Error('quota');});
await act(async()=>{root.querySelector('form')!.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));});
expect(root.querySelector('[role=alert]')?.textContent).toContain('无法保存');expect(onSaved).toHaveBeenCalledTimes(1);
});
it('uses the active player for live playback options',async()=>{
const speed=vi.fn(),sleep=vi.fn(),onBack=vi.fn();
await act(async()=>render(<MediaSettings scope="test" panel="playback" preferences={defaultMediaPreferences} player={{active:true,playbackRate:1,sleepAt:0,speed,sleep} as unknown as MediaPlayer} onSaved={vi.fn()} onBack={onBack}/>,root));
expect(root.querySelector('[aria-label="默认倍速"]')).not.toBeNull();expect(root.textContent).toContain('连续播放');
const current=root.querySelector<HTMLSelectElement>('[aria-label="播放倍速"]')!;
act(()=>{current.value='1.5';current.dispatchEvent(new Event('change',{bubbles:true}));Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='30分钟')!.click();});
expect(speed).toHaveBeenCalledWith(1.5);expect(sleep).toHaveBeenCalledWith(30);
back();expect(onBack).toHaveBeenCalled();
});
it('shows account identity without exposing credentials',async()=>{
const api=new MediaApi({baseUrl:'https://reader.example',currentSession:()=>({user:{username:'lin',displayName:'林间',role:'member'},accessToken:'secret-access',refreshToken:'secret-refresh'})} as unknown as ReaderApi);
expect(api.accountInfo()).toEqual({username:'lin',displayName:'林间',role:'member',server:'https://reader.example'});
await act(async()=>render(<MediaSettings scope="member" panel="account" preferences={defaultMediaPreferences} player={player} account={api.accountInfo()} onSaved={vi.fn()} onBack={vi.fn()}/>,root));
expect(root.textContent).toContain('@lin');expect(root.textContent).toContain('https://reader.example');expect(root.textContent).not.toContain('secret');
});
it('asks before dropping an unsaved preference draft',async()=>{
const onBack=vi.fn();
await act(async()=>render(<MediaSettings scope="draft" panel="browse" preferences={defaultMediaPreferences} player={player} onSaved={vi.fn()} onBack={onBack}/>,root));
change(root.querySelector('select')!,'comfortable');
back();
expect(root.querySelector('[role=alertdialog]')?.textContent).toContain('尚未保存');expect(onBack).not.toHaveBeenCalled();
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='继续编辑')!.click());
expect(root.querySelector('select')!.value).toBe('comfortable');
back();
act(()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='放弃修改')!.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
it('leaves without asking when nothing changed',async()=>{
const onBack=vi.fn();
await act(async()=>render(<MediaSettings scope="clean" panel="browse" preferences={defaultMediaPreferences} player={player} onSaved={vi.fn()} onBack={onBack}/>,root));
back();
expect(root.querySelector('[role=alertdialog]')).toBeNull();expect(onBack).toHaveBeenCalledTimes(1);
});