/* 设置面板(`settings-panel` 岛)的桩与入口:`settings-panel.test.ts` 与设计决定用例(`design-*.test.ts`)共用。 * * 跟数据库走的 `/api/settings` 与两份机器状态(`/api/follow/schedule`、`/api/thumbnail-jobs`)各给一份 * 内存桩:读写都由它接住,好看写进去的是什么,也让「刷新后保持」不依赖临时服务上的状态。 */ import type { Browser, Page, Request } from 'playwright-core'; import { settle, visit, VIEWPORTS, type Viewport, type Visit } from './harness.ts'; export const PANEL = '#settingsPanel'; export const ORDER = ['', 'follow', 'jav', 'performers', 'tags', 'studios', 'flagged', 'manage']; const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!; export interface Server { settings: Record; schedule: Record; thumbnails: Record; /** 三个写接口收到的请求体,按到达顺序。 */ posts: { path: string; body: Record }[]; } const body = (request: Request) => JSON.parse(request.postData() || '{}') as Record; export async function stubServer(page: Page): Promise { const server: Server = { settings: { sidebarOrder: [...ORDER], metadataRefreshDays: 30, followInitialDays: 30, postSetupTutorialDone: true, organizeTemplates: {}, feedHideGroupCompilations: true, feedHideSoloCompilations: false, feedHideExcerpts: true, searchHistoryLimit: 10, }, schedule: { available: true, enabled: true, interval_minutes: 180, running: false, last_error: null, last_finished_at: '2026-09-30T08:00:00Z', last_added: 3, next_run_at: null, }, thumbnails: { mode: 'precise', status: 'idle' }, posts: [], }; const route = (path: string, merge: (sent: Record) => Record) => page.route((url) => url.pathname === path, (handled) => { const request = handled.request(); if (request.method() === 'GET') return handled.fulfill({ json: merge({}) }); const sent = body(request); server.posts.push({ path, body: sent }); return handled.fulfill({ json: merge(sent) }); }); await route('/api/settings', (sent) => { Object.assign(server.settings, sent); return { ok: true, ...server.settings }; }); await route('/api/follow/schedule', (sent) => Object.assign(server.schedule, sent)); await route('/api/thumbnail-jobs', (sent) => Object.assign(server.thumbnails, sent)); return server; } /** 齿轮打开面板并停在 `section` 那一格。窄屏下齿轮收在抽屉底部,直接派发点击,两种宽度走同一条入口。 */ export async function openPanel(page: Page, section = '外观与导航'): Promise { await page.locator('#settingsBtn').evaluate((button: HTMLElement) => button.click()); await page.locator(`${PANEL}:not([hidden])`).waitFor({ timeout: 10_000 }); if (section !== '外观与导航') await page.locator(PANEL).getByRole('tab', { name: section, exact: true }).click(); await page.locator(PANEL).getByRole('tab', { name: section, exact: true }) .and(page.locator('[aria-selected="true"]')).waitFor({ timeout: 5_000 }); } /** 首页挂上桩之后重载一次,桩从第一次取设置起就接住。 */ export async function openHome(browser: Browser, viewport: Viewport = DESKTOP): Promise { const opened = await visit(browser, '/', viewport); const server = await stubServer(opened.page); await opened.page.reload({ waitUntil: 'load' }); await opened.page.locator('#settingsBtn').waitFor({ state: 'attached', timeout: 15_000 }); await settle(opened.page); return { ...opened, server }; }