Ctrl K
ComunidadGuías…

frontend/e2e/design-fixture.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* 设计决定用例(`design-*.test.ts`)共用的桩数据、打开页面的入口与浏览器生命周期。
 *
 * 设计决定按浏览器里的计算值断言,不绑定 CSS 源码的写法。页面迁到 React 时,旧的源码字符串
 * 断言按 ADR-0031 分三类:设计决定落在 `design-*.test.ts` 或 lint 规则,行为落在 vitest,
 * 布局与运行期问题归 `smoke.test.ts`。每条用例守一个决定,读的是 `getComputedStyle`,
 * 类名或样式写法换了照样成立。 */
import assert from 'node:assert/strict';
import { after, before, beforeEach } from 'node:test';
import type { Browser, Locator, Page } from 'playwright-core';
import {
  configurationBody, expectBody, launch, settle, visit, VIEWPORTS, type Viewport, type Visit,
} from './harness.ts';

export const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;
export const MOBILE = VIEWPORTS.find((viewport) => viewport.mobile)!;

/** 在 `scope` 里解析一个颜色 token:临时挂一个元素读背景色,读完就移除。 */
export async function tokenColor(page: Page, scope: string, token: string): Promise<string> {
  return page.evaluate(([selector, name]) => {
    const probe = document.createElement('div');
    probe.style.backgroundColor = `var(${name})`;
    document.querySelector(selector)!.append(probe);
    const value = getComputedStyle(probe).backgroundColor;
    probe.remove();
    return value;
  }, [scope, token] as const);
}

/** 配置页「网络与访问」下的访问密码分区。演示库没有 access.json,分区处于系统口令状态。
 * 与冒烟同一口径:先等配置页主体,再 settle、再切分区。 */
export async function openAccess(browser: Browser): Promise<Visit & { form: Locator }> {
  const opened = await visit(browser, '/settings/library', DESKTOP);
  await expectBody(opened.page, '/settings/library', configurationBody(opened.page));
  await settle(opened.page);
  await opened.page.getByRole('tab', { name: '网络与访问' }).click({ timeout: 5_000 });
  await settle(opened.page);
  const form = opened.page.locator('form[aria-label="访问密码"]');
  await form.waitFor({ timeout: 10_000 });
  return { ...opened, form };
}

/** 一轮跑完的任务。字段以 `/api/tasks`(`src/peach/routes_tasks.py`)为准。 */
export const settledRun = (id: number, status: string, label: string) => ({
  id, task_key: `task-${id}`, task_label: label, trigger: 'manual', status, host: 'desk',
  started_at: '2026-09-11T10:00:00Z', finished_at: '2026-09-11T10:01:00Z', elapsed_seconds: 60,
  progress_current: null, progress_total: null, progress_label: '',
  result_summary: {}, error: status === 'failed' ? 'RuntimeError: 上游挡回来了' : '',
});

/** 活动页按给定的一份 `/api/tasks` 打开:演示库里凑不齐失败、被挡下和成功三种状态。 */
export async function openActivity(browser: Browser, runs: unknown[]): Promise<Visit> {
  const opened = await visit(browser, '/tasks', DESKTOP);
  await opened.page.route('**/api/tasks', (route) => route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ available: true, running: [], skipped: [], finished: runs }),
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('li[data-status]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 一条待升级的目标。字段以 `/api/quality-goals`(`src/peach/web_contract.py`)为准。 */
export const qualityGoal = (id: number, name: string) => ({
  id, name, code: null, location: 'local', size: 2147483648, duration: 3725,
  reason: '只有 720p', cost: 'free', has_thumb: true, has_cover: false,
});

/** 1×1 的透明 PNG,够让 `<img>` 走完一次加载。 */
export const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';

/** 口味页浏览器画像的五个维度:头一个是其余的十倍,和真实馆藏的分布一样。 */
export const TASTE_CATEGORIES = [['剧情', 657], ['写真', 68], ['素人', 56], ['偶像', 4], ['企划', 1]]
  .map(([name, score]) => ({ name, score }));

/** 一张全身站姿照的人脸框:脸落在画面上半截的一小块里,小圆框要放大好几倍才看得清。 */
export const TASTE_FACE = { cx: .439, cy: .224, faceW: 67, imgW: 640, imgH: 960 };

/** 口味页按改写过的 `/api/taste` 打开:演示库没有浏览记录,也没有带人脸框的实体图。
 * 字段以 `src/peach/taste_history.py` 与 `src/peach/web_stats.py` 为准;实体图换成与人脸框同尺寸的纯色图。 */
export async function openTaste(browser: Browser, viewport: typeof DESKTOP): Promise<Visit> {
  const opened = await visit(browser, '/taste', viewport);
  const face = (id: number, name: string) => ({
    name, peach_items: 3, peach_score: 5, entity_id: id, has_image: true, avatar_focus: { box: TASTE_FACE },
  });
  await opened.page.route(/\/api\/taste\?/, async (route) => {
    const json = await (await route.fetch()).json();
    await route.fulfill({ json: { ...json, rankings: { ...json.rankings,
      browser_categories: TASTE_CATEGORIES,
      peach_performers: [face(901, '演示女优')],
      peach_creators: [face(902, '演示创作者')],
    } } });
  });
  await opened.page.route(/\/entity-image\?/, (route) => route.fulfill({
    contentType: 'image/svg+xml',
    body: `<svg xmlns="http://www.w3.org/2000/svg" width="${TASTE_FACE.imgW}" height="${TASTE_FACE.imgH}">`
      + `<rect width="${TASTE_FACE.imgW}" height="${TASTE_FACE.imgH}" fill="#888"/></svg>`,
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await expectBody(opened.page, '/taste', [
    opened.page.locator('#stats').getByRole('tab', { name: '浏览器记录', exact: true }),
  ]);
  return opened;
}

/** 高清版目标页按给定的一份 `/api/quality-goals` 打开:演示库里凑不齐很长的标题。 */
export async function openQualityGoals(browser: Browser, items: unknown[]): Promise<Visit> {
  const opened = await visit(browser, '/quality-goals', DESKTOP);
  await opened.page.route('**/api/quality-goals?limit=200', (route) => route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ total: items.length, items, offset: 0, has_more: false }),
  }));
  /* 这几条目标是造出来的,演示库里没有对应的抽帧,预览图会 404——而失败请求本身是另一
     条判据。这一条量的是封面那块的几何,给它一张能加载完的图就够。 */
  await opened.page.route('**/poster**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('li[data-goal-id]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 一个采集来源。字段以 `/api/scraping`(`src/peach/web_scraping.py`)为准。 */
export const scrapingSource = (source: string, label: string, cookie: boolean) => ({
  source, label, login: `https://${source}.example/login`,
  accepts_cookie: cookie, network: 'peach', cookie_saved: cookie,
});

/** 来源和凭证页按给定的一份 `/api/scraping` 打开:演示库里未必同时有收 Cookie 和不收的来源。
 *
 * 站标走服务端的 `/site-mark`,而这几个来源是造出来的,那一趟必然取不到;那是另一条判据,
 * 这里给它一张能加载完的图,免得运行期问题名单里混进与本条无关的失败。 */
export async function openScraping(browser: Browser): Promise<Visit> {
  const opened = await visit(browser, '/sources', DESKTOP);
  await opened.page.route('**/api/scraping', (route) => route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      sources: [scrapingSource('demoa', '演示来源甲', true), scrapingSource('demob', '演示来源乙', false)],
    }),
  }));
  await opened.page.route('**/site-mark**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('form[aria-label="演示来源甲"]').waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 一行等人判的元数据候选。字段以 `src/peach/web_review.py` 的 `_review_rows` 为准。 */
export const REVIEW_ROW = {
  item_key: 'metadata_fields:ABC-123:studio',
  field: 'studio',
  field_label: '厂牌',
  code: 'ABC-123',
  query: 'ABC-123',
  evidence: '当前值:尚无;1 个匹配资产;2 个资料来源',
  candidates: [{ candidate_key: 'javdb:studio', source: 'javdb', display_value: '示例厂牌' }],
};

/** 人工复核页按一行造出来的队列打开:演示库里这一格未必正好有候选,而队列空了就没有卡。 */
export async function openReview(browser: Browser, rows = [REVIEW_ROW], viewport = DESKTOP): Promise<Visit> {
  const opened = await visit(browser, '/review', viewport);
  await opened.page.route('**/api/review', (route) => route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      sections: { metadata_fields: rows }, counts: { metadata_fields: rows.length }, genre_tags: [],
      queues: { later: { sections: {}, counts: {} }, ignored: { sections: {}, counts: {} } },
    }),
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('section[data-review-key]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** CIE L*。一条 1px 的线看不看得见跟的是明度差,不是对比度比值:同样 1.48:1,浅色底上
 *  是一条灰线,深色底上两头的绝对亮度都贴着 0,什么都看不出来。 */
export function lightness([red, green, blue]: number[]): number {
  const linear = (channel: number) => {
    const value = channel / 255;
    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
  };
  const y = 0.2126 * linear(red!) + 0.7152 * linear(green!) + 0.0722 * linear(blue!);
  return y > 0.008856 ? 116 * Math.cbrt(y) - 16 : 903.3 * y;
}

/** 按给定的一份 `/api/library-processing` 打开某一页:演示库里那趟任务早就跑完了,
 *  而运行态和失败态正是这两条要看的东西。字段以 `src/peach/web_library_processing.py` 为准。 */
export async function openProcessing(
  browser: Browser, path: '/' | '/data-cleanup', job: Record<string, unknown>,
): Promise<Visit> {
  const opened = await visit(browser, path, DESKTOP, (page) =>
    page.route('**/api/library-processing', (route) => route.fulfill({
      status: 200, contentType: 'application/json', body: JSON.stringify(job),
    })));
  try {
    const body = opened.page.locator(path === '/data-cleanup'
      ? '#libraryProcessing section[aria-label="扫描与采集"]'
      : '[data-catalog-filter] [data-count-readout]');
    await expectBody(opened.page, path, [body], opened.problems);
    return opened;
  } catch (error) {
    await opened.close();
    throw error;
  }
}

/** 1280 的桌面视口比 `--board-content` 还窄,量不出「网格铺满、标题居中」这类差别。 */
export const WIDE = { name: 'wide', width: 1600, height: 900, mobile: false };

/** 垃圾文件页。计数行由垃圾队列那一页画,演示库里一条候选都没有时它照样在;壳铺的
 *  骨架(`[data-junk-count-skeleton]`)也有这两块,等的是页面接管之后的那一版。 */
export const JUNK_COUNT = '#count > .peach-react:not([data-junk-count-skeleton])';
export async function openJunk(browser: Browser): Promise<Visit> {
  const opened = await visit(browser, '/junk-files', WIDE);
  await expectBody(opened.page, '/junk-files', [
    opened.page.locator(`${JUNK_COUNT} [data-collection-summary]`),
    opened.page.locator(`${JUNK_COUNT} [data-junk-filters]`),
  ]);
  await settle(opened.page);
  return opened;
}

/** 一条垃圾候选。字段以 `/api/ads`(`src/peach/web_batch.py` 的 `q_ads`)为准;类型都挑没有预览
 *  的,造出来的 id 取不到缩略图。 */
export const junkItem = (id: number, kind: string, name: string) => ({
  id, name, junk_kind: kind, why: '文件名像推广', size: 1048576 * id, location: 'local', cost: '',
});

/** 垃圾文件页按给定的候选打开:演示库里没有垃圾候选。`/api/ads` 每次都按当前名单回话,
 *  `/api/batch` 记下请求体、把处置掉的那条从名单里拿掉。 */
export async function openJunkWith(browser: Browser, items: ReturnType<typeof junkItem>[], viewport = WIDE) {
  const opened = await visit(browser, '/junk-files', viewport);
  const state = { items: [...items], batches: [] as { ids: number[]; operation: string }[], reads: [] as string[] };
  await opened.page.route('**/api/ads?**', (route) => {
    const url = new URL(route.request().url());
    state.reads.push(url.search);
    const kind = url.searchParams.get('kind');
    const shown = state.items.filter((item) => !kind || item.junk_kind === kind);
    const counts: Record<string, number> = {};
    for (const item of state.items) counts[item.junk_kind] = (counts[item.junk_kind] || 0) + 1;
    return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
      items: shown, total: shown.length, all_total: state.items.length, pending_total: state.items.length,
      dismissed_total: 0, counts,
    }) });
  });
  await opened.page.route('**/api/batch', (route) => {
    const body = JSON.parse(route.request().postData() || '{}');
    state.batches.push(body);
    state.items = state.items.filter((item) => !body.ids.includes(item.id));
    return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, updated: body.ids.length }) });
  });
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('#grid [data-junk-card]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return { ...opened, state };
}

/** 一个重复文件。字段以 `/api/duplicates`(`src/peach/web_contract.py`)为准。 */
export const duplicateFile = (id: number, location: string, patch: Record<string, unknown> = {}) => ({
  id, name: `DUPE-${id}.mp4`, path: `R:\\media\\DUPE-${id}.mp4`, location, drive: location === '115' ? 'B:' : 'R:',
  size: 1073741824, duration: 3600, is_largest: false, is_longest: false, ...patch,
});

/** 重复文件页按给定的两组打开:演示库里没有重复组。一组字节一致,一组本地与 115 混着。
 *  文件是造出来的,抽帧那一趟必然取不到,给它一张能加载完的图。 */
export async function openDuplicates(browser: Browser): Promise<Visit> {
  const opened = await visit(browser, '/duplicates', DESKTOP);
  const groups = [
    { code: 'DUPE-001', count: 2, identical: true, drives: ['R:'], cross_drive: false, reclaimable: 1073741824,
      files: [duplicateFile(9001, 'local', { is_largest: true }), duplicateFile(9002, 'local', { is_longest: true })] },
    { code: 'DUPE-002', count: 2, identical: false, drives: ['R:', 'B:'], cross_drive: true, reclaimable: 1073741824,
      files: [duplicateFile(9003, 'local', { is_largest: true, is_longest: true }), duplicateFile(9004, '115')] },
  ];
  await opened.page.route('**/api/duplicates?**', (route) => route.fulfill({
    status: 200, contentType: 'application/json',
    body: JSON.stringify({ total: 2, files: 4, reclaimable: 2147483648, groups }),
  }));
  await opened.page.route('**/thumb?**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('#stats section[aria-label="DUPE-002"] .duplicate-row').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 打开一张索引页,名册与词表都换成桩:演示库的索引在一轮里会从空变成有,不桩的话
 * 这一条量到的可能是空态。名册第一格带一张桩图,其余只落首字母。 */
export async function openIndexPage(browser: Browser, path: string): Promise<Visit> {
  const opened = await visit(browser, path, DESKTOP);
  const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
  await opened.page.route('**/api/index?**', (route) => {
    const kind = new URL(route.request().url()).searchParams.get('kind');
    return route.fulfill(json(kind === 'tags'
      ? { items: [{ k: '痴女', n: 4, cat: 'role' }, { k: '秘书OL', n: 2, cat: 'scene' }], has_more: false,
        categories: { role: 1, scene: 1 } }
      : { items: [{ k: '甲', n: 3, members: 2, entity_id: 90_101, has_image: true }, { k: '乙', n: 1, members: 1 }],
        has_more: false }));
  });
  // 桩图要比框大:比框小的图按原尺寸摆,走的是另一条规则。
  await opened.page.route('**/entity-image?**', (route) => route.fulfill({
    status: 200, contentType: 'image/svg+xml',
    body: '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800"><rect width="600" height="800" fill="#888"/></svg>',
  }));
  await opened.page.route('**/api/follow/tags?**', (route) => route.fulfill(json({
    items: [{ k: 'some_artist', n: 5, cat: 'artist' }, { k: 'long_hair', n: 9, cat: 'general' }], has_more: false,
    categories: { artist: 1, general: 1 } })));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('#index [data-index-cell], #index [data-alpha-tag], #index [data-tag-chip]').first()
    .waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 一串 `box-shadow`(计算值或 token 原文)里最大的那个像素数:落影三层里最远那层的模糊半径。 */
export const farthestShadow = (shadow: string) =>
  Math.max(0, ...[...shadow.matchAll(/(\d+(?:\.\d+)?)px/g)].map((match) => Number(match[1])));

/** 播放列表页按一份造出来的 `/api/playlists`(`src/peach/web_playlists.py`)打开:演示库里
 * 没有播放列表。第一份带三位署名,第二份没人(画标题首字),第三份是空列表(写「无预览」)。
 * 封面是造出来的编号,给一张能加载完的图。 */
export async function openPlaylistsPage(browser: Browser, viewport = DESKTOP): Promise<Visit> {
  const opened = await visit(browser, '/playlists', viewport);
  const face = (id: number, name: string, kind = 'performer') => ({ kind, id, name, has_image: false });
  const row = (id: number, patch: Record<string, unknown> = {}) => ({
    id, name: `列表${id}`, source_kind: 'manual', source_seed_asset_id: null, current_asset_id: null,
    created_at: '2026-09-01 10:00:00', updated_at: '2026-09-01 10:00:00', item_count: 2,
    preview_asset_id: 11, preview_ids: [11, 12], faces: [], ...patch,
  });
  await opened.page.route((url) => url.pathname === '/api/playlists', (route) => route.fulfill({ json: { items: [
    row(1, { source_kind: 'mix', faces: [face(901, '甲'), face(902, '乙'), face(903, '丙', 'creator')] }),
    row(2),
    row(3, { item_count: 0, preview_asset_id: null, preview_ids: [] }),
  ] } }));
  await opened.page.route('**/poster**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('#stats [data-playlist-card]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 打开目录并等到读数与卡片一起替下首屏骨架。 */
export async function openCatalog(browser: Browser): Promise<Visit> {
  const opened = await visit(browser, '/', DESKTOP);
  await opened.page.locator('[data-catalog-filter] [data-count-readout]').waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 打开目录,两排头像与标签条用桩数据铺满:演示库那十几部作品凑不出一排标签。 */
export async function openCatalogBars(browser: Browser, viewport: Viewport = DESKTOP): Promise<Visit> {
  const opened = await visit(browser, '/', viewport);
  const performers = Array.from({ length: 6 }, (_, at) => ({
    id: 96_000 + at, k: `演示女优${at + 1}`, n: 30 - at, has_image: false, has_avatar: false, avatar_focus: null, rep: null,
  }));
  const studios = Array.from({ length: 3 }, (_, at) => ({ k: `演示厂牌${at + 1}`, n: 20 - at, has_logo: false }));
  const tags = Array.from({ length: 8 }, (_, at) => ({ k: `演示标签${at + 1}`, n: 90 - at }));
  await opened.page.route((url) => url.pathname === '/api/tops', (route) =>
    route.fulfill({ json: Number(new URL(route.request().url()).searchParams.get('page') || 0)
      ? { performers: [], studios: [] } : { performers, studios } }));
  await opened.page.route((url) => url.pathname === '/api/facets', async (route) => {
    const response = await route.fetch();
    await route.fulfill({ json: { ...await response.json(), tags } });
  });
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('[data-catalog-filter] [data-catalog-tag]').first().waitFor({ timeout: 15_000 });
  await opened.page.locator('[data-catalog-filter] [data-tier-studio]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

export interface CatalogFixture {
  total: number;
  items: Array<Record<string, unknown>>;
  [key: string]: unknown;
}

/** 从演示库取一份能完整渲染的真实目录响应,只替换当前判据需要的字段。 */
export async function openCatalogFixture(
  browser: Browser,
  change: (payload: CatalogFixture, url: URL) => void,
  { completeFirstPage = false }: { completeFirstPage?: boolean } = {},
): Promise<Visit> {
  const opened = await visit(browser, '/', DESKTOP);
  const baselines = new Map<string, CatalogFixture>();
  await opened.page.route(/\/api\/items\?/, async (route) => {
    const url = new URL(route.request().url());
    /* `visit()` 返回时,首屏还可能在后台续取下一页。路由接管之后若先撞上 offset>0,
       那份响应本来就可能没有卡片,不能拿它当首屏基线;让旧请求原样完成,reload 后
       再以 offset=0 的响应建立夹具。 */
    if (url.searchParams.get('offset') !== '0') {
      await route.continue();
      return;
    }
    const key = url.searchParams.get('orient') || '';
    let baseline = baselines.get(key);
    if (!baseline) {
      const fixtureUrl = new URL(url);
      if (completeFirstPage && !fixtureUrl.searchParams.has('orient')) fixtureUrl.searchParams.set('limit', '60');
      const response = await route.fetch({ url: fixtureUrl.href });
      baseline = await response.json() as CatalogFixture;
      baselines.set(key, baseline);
    }
    const payload = structuredClone(baseline);
    change(payload, url);
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(payload),
    });
  });
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('[data-catalog-filter] [data-count-readout]').waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 一趟跑到一半的扫描与采集。 */
export const RUNNING_JOB = { status: 'running', stage: '采集缺失资料', checked: 38, total: 100 };

/** 一趟断在半路、攒下一份长问题清单的扫描与采集:演示库里这两样都凑不出来。 */
export const FAILED_JOB = {
  status: 'failed', job_id: 'one', error: '处理被中断,请检查媒体目录后重试。',
  issue_count: 347, issues_truncated: true, retryable_asset_ids: [1, 2],
  issues_log: 'C:\\peach-data\\state\\library-processing-demo.issues.jsonl',
  issue_preview: Array.from({ length: 20 }, (_, at) => ({
    asset_id: at + 1, title: `示例-${at + 1}.mp4`, path: `B:\\番号\\示例-${at + 1}.mp4`,
    message: '封面未取得:javdb:本趟采集次数已用完,再跑一次接着采;已有图片保留',
  })),
};

/** 一条关注来源。字段以 `_source_payload`(`src/peach/web_follow.py`)为准。 */
export const followSource = (id: number, author: string, provider: string, label: string, status = 'ok') => ({
  id, provider: provider.toLowerCase(), provider_label: provider, ref: `ref/${id}`, label,
  url: `https://example.com/${id}`, enabled: true, last_status: status,
  last_checked_at: '2026-09-01T00:00:00Z', created_at: '2026-08-01T00:00:00Z',
  author_key: `name:${author}`, author_name: author,
});

/** 一个站的凭据状态。字段以 `/api/follow/credentials` 为准。 */
export const followCredential = (provider: string, requirement: string, present: boolean, missing: string[]) => ({
  provider: provider.toLowerCase(), provider_label: provider, followable: true, requirement,
  needs: missing, fields: present ? ['cookie'] : [], missing, present,
});

/** 关注管理页按一份造好的来源与凭据打开:演示库没有关注来源,也凑不齐四种凭据处境。
 *  站标同 `openScraping`:造出来的来源取不到图标,给一张能加载完的图。 */
export async function openFollowManage(browser: Browser, viewport = DESKTOP, data: FollowManageData = {}): Promise<Visit> {
  const opened = await visit(browser, '/subscriptions', viewport);
  await stubFollowManage(opened.page, data);
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator('section[aria-label="kou 的关注来源"]').waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 换掉计数、在三条来源之后再加几条:量极端内容时用。 */
interface FollowManageData {
  counts?: Record<string, number>;
  extra?: Record<string, unknown>[];
}

export async function stubFollowManage(page: Page, { counts, extra = [] }: FollowManageData = {}): Promise<void> {
  const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
  await page.route('**/api/follow?summary=1', (route) => route.fulfill(json({
    sources: [
      followSource(1, 'kou', 'Kemono', 'kou · Kemono'),
      followSource(2, 'kou', 'Pawchive', 'kou · Pawchive'),
      followSource(3, 'mira', 'Kemono', 'mira · Kemono', 'error'),
      ...extra,
    ],
    counts: counts || { new: 0, seen: 0, saved: 0, ignored: 0 },
    author_aliases: [{ canonical_key: 'kou', canonical_name: 'kou', aliases: [{ key: 'kou_art', name: 'kou_art' }] }],
    alias_suggestions: [],
    suggestions: [],
  })));
  await page.route('**/api/follow/credentials', (route) => route.fulfill(json({
    root: 'C:\\peach\\creds',
    providers: [
      followCredential('Fanbox', 'required', false, ['FANBOXSESSID']),
      followCredential('Patreon', 'optional', true, []),
      followCredential('OnlyFans', 'blocked', false, []),
      followCredential('Kemono', 'none', false, []),
    ],
  })));
  await page.route('**/source-icon**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  /* 服务端的定时检查可能正在跑,那一趟会让每颗检查键挂着 aria-busy,settle 等不到头。 */
  await page.route('**/api/follow/check', (route) => (route.request().method() === 'GET'
    ? route.fulfill(json({ status: 'idle' })) : route.fallback()));
}

/** 把 `/api/` 请求挂住,直到调用返回的 `release()`:首屏骨架停在屏幕上,量完再放行。
 *  先注册的桩照常生效:放行走 `fallback()`,交给它们或真实服务端。 */
export async function holdApi(page: Page): Promise<() => void> {
  let release!: () => void;
  const gate = new Promise<void>((resolve) => { release = resolve; });
  await page.route((url) => url.pathname.startsWith('/api/') && url.pathname !== '/api/settings', async (route) => {
    await gate;
    await route.fallback().catch(() => {});
  });
  return release;
}

/** 一组控件此刻的长相:面色、字色、边线、圆角与外框尺寸,以及按钮组的选中标记。 */
export async function controlFaces(page: Page, selectors: Record<string, string>) {
  return page.evaluate((map) => Object.fromEntries(Object.entries(map).map(([name, selector]) => {
    const node = document.querySelector(selector);
    if (!node) return [name, null];
    const style = getComputedStyle(node);
    const box = node.getBoundingClientRect();
    return [name, {
      face: `${style.backgroundColor} ${style.backgroundImage}`, ink: style.color,
      edge: `${style.borderTopWidth} ${style.borderTopColor}`, radius: style.borderTopLeftRadius,
      size: `${Math.round(box.width)}x${Math.round(box.height)}`, pressed: node.getAttribute('aria-pressed'),
    }];
  })), selectors);
}

/** 打开一位订了新作、有一排同台艺人的人物页。演示库里没有人物实体,资料照服务端下发的形状写。 */
export async function openPerformer(browser: Browser, viewport = DESKTOP): Promise<Visit> {
  const name = '七沢みあ';
  const opened = await visit(browser, '/', viewport);
  const costar = (id: number, k: string) => ({ id, k, n: 1, rep: null, has_image: false, has_avatar: false, avatar_focus: null });
  await opened.page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
    id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [],
    user_aliases: [], asset_count: 0, tags: [],
    related_performers: ['本田愛华', '枢木葵', '美谷朱音', '高杉麻里', '高桥圣子', '今井夏帆'].map((k, at) => costar(90_002 + at, k)),
    links: [], metadata: {}, has_image: false, has_avatar: false, avatar_focus: null, representative_asset_id: null,
    entry_links: [
      { site: 'javdb', label: 'JavDB', ordinal: '', slot: 'mark', mark: 'mark-javdb', url: 'https://javdb.com/actors/NPD3' },
      { site: 'missav', label: 'MISSAV', ordinal: '', slot: 'mark', mark: '', url: 'https://missav.ai/actresses/x' },
    ],
    feed: { following: true },
  } }));
  await opened.page.goto(new URL(`/performers/${encodeURIComponent(name)}`, opened.page.url()).href, { waitUntil: 'load' });
  await opened.page.locator('[data-entry-feed]').waitFor({ timeout: 15_000 });
  await opened.page.locator('[data-entity-foot] [data-related-performer]').first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  await opened.page.evaluate(() => {
    document.documentElement.dataset.theme = 'light';
    document.documentElement.classList.remove('dark');
  });
  return opened;
}

/** 照片档:一部作品的两张官方样张加六张本地图片,缩略图都是 1×1 的图,量的是墙的几何。 */
export async function openPhotoWall(browser: Browser, viewport = DESKTOP): Promise<Visit> {
  const name = '七沢みあ';
  const opened = await visit(browser, '/', viewport);
  const { page } = opened;
  await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
    id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [], user_aliases: [],
    asset_count: 0, tags: [], related_performers: [], links: [], metadata: {}, has_image: false, has_avatar: false,
    avatar_focus: null, representative_asset_id: null, entry_links: [], feed: { following: false },
  } }));
  // 下一页一直不回:「载入更多」停在忙态,墙不再变,量得到那枚键的几何。
  await page.route(/\/api\/photos\?/, (route) => Number(new URL(route.request().url()).searchParams.get('offset') || 0) ? undefined : route.fulfill({ json: {
    kind: 'performer', name, entity_id: 90_001, total: 12, seed: '', has_more: true, sample_total: 2,
    items: Array.from({ length: 6 }, (_, i) => ({ id: 200 + i, name: `${200 + i}.jpg`, size: 1, location: 'media' })),
    sets: [{ id: 'code:SSIS-057', kind: 'code', code: 'SSIS-057', name: '雨の日', title: 'SSIS-057 雨の日', n: 2,
      release_date: '2021-05-18', site: 'dmm', site_label: 'DMM', has_cover: true }],
  } }));
  await page.route(/\/(?:photo-thumb|sample-thumb)\?/, (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await page.goto(new URL(`/performers/${encodeURIComponent(name)}?media=photos`, page.url()).href, { waitUntil: 'load' });
  await page.locator('[data-local-wall] [data-photo-cell] img').first().waitFor({ timeout: 15_000 });
  // 续页哨兵提前一屏开始取,这面短墙一出来就会去取那一页;它被上面压着,那枚键的忙态不算页面在等。
  await settle(page, 'body', { except: '[data-entity-more]' });
  return opened;
}

/** 本地图片那面墙的排法:网格数列宽,瀑布流数 column-count。 */
export const photoWallFaces = (page: Page) => page.evaluate(() => {
  const wall = document.querySelector<HTMLElement>('[data-local-wall]')!;
  const style = getComputedStyle(wall);
  const cell = wall.querySelector<HTMLElement>('[data-photo-cell]')!;
  const box = cell.getBoundingClientRect();
  return {
    display: style.display,
    columns: style.display === 'grid' ? style.gridTemplateColumns.split(' ').length : Number(style.columnCount),
    gap: style.columnGap,
    square: Math.abs(box.width - box.height) <= 1,
  };
});

/** 照片档里 `count` 张本地图,点开第一张的灯箱。大图是 `width`×`height` 的 SVG;取图口全部回桩图,
 *  `/photo` 原图走计费来源,设计用例也不碰真的。 */
export async function openLightbox(browser: Browser, { count, width, height }: { count: number; width: number; height: number }):
  Promise<Visit & { box: Locator }> {
  const name = '七沢みあ';
  const opened = await visit(browser, '/', DESKTOP);
  const { page } = opened;
  const svg = (w: number, h: number) => ({ status: 200, contentType: 'image/svg+xml',
    body: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#3a4a5a"/></svg>` });
  await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
    id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [], user_aliases: [],
    asset_count: 0, tags: [], related_performers: [], links: [], metadata: {}, has_image: false, has_avatar: false,
    avatar_focus: null, representative_asset_id: null, entry_links: [], feed: { following: false },
  } }));
  await page.route(/\/api\/photos\?/, (route) => route.fulfill({ json: {
    kind: 'performer', name, entity_id: 90_001, total: count, seed: '', has_more: false, sample_total: 0, sets: [],
    items: Array.from({ length: count }, (_, i) => ({ id: 300 + i, name: `${300 + i}.jpg`, size: 1, location: 'media' })),
  } }));
  await page.route(/\/photo\?/, (route) => route.fulfill(svg(width, height)));
  await page.route(/\/photo-thumb\?/, (route) => route.fulfill(svg(160, 120)));
  await page.goto(new URL(`/performers/${encodeURIComponent(name)}?media=photos`, page.url()).href, { waitUntil: 'load' });
  await page.locator('[data-photo-cell] img').first().waitFor({ timeout: 15_000 });
  await settle(page);
  await page.locator('[data-photo-cell]').first().click();
  const box = page.locator('dialog[data-photo-lightbox][open]');
  await box.waitFor();
  await page.waitForFunction(() => document.querySelector<HTMLImageElement>('[data-photo-main] .swiper-slide-active img')!
    .naturalWidth > 0, undefined, { timeout: 10_000 });
  return { ...opened, box };
}

/** 有 minnano-av 资料的女优。`profile` 与 `name_groups` 照 `peach.entity_profile.header` 的形状写,
 *  出道片名故意很长:资料表那一格要单行截断。 */
export const PROFILED = {
  name: '篠田ゆう',
  profile: {
    birth_date: '1991-07-21', age: 35, height: 155, bust: 86, waist: 60, hip: 87, cup: 'F',
    debut_date: '2010-12-02', debut_title: 'セキララ 〜今どき世代のゆるい性事情〜 03 はじめての撮影でとまどう素人娘の記録',
    active: { from: '2010', to: '2023' },
    tags: ['美乳', '美臀', '百合', '肛交', '巨乳', '高颜值', '苗条', '高个', '剛毛'],
  },
  name_groups: {
    reading: 'しのだゆう', shown: ['篠崎ゆう子', '高木早希', '橋本真紀'], total: 7,
    groups: [
      { label: '旧名义', names: [{ name: '篠崎ゆう子', reading: 'しのざきゆうこ' }] },
      { label: '舞ワイフ', names: [{ name: '橋本真紀' }, { name: '桧山彩音' }, { name: '篠田杏奈' }] },
      { label: 'ラグジュTV', names: [{ name: '高木早希' }] },
      { label: '其它', names: [{ name: '城田優子' }] },
    ],
  },
};

/** 打开一位有资料的女优:资料表、名字行和外链照服务端下发的形状写,主题按参数切好。
 *  `extra` 覆盖资料里的字段:默认没有看片那一行(`entry_links` 只有 minnano-av 那枚 pill、没有 `feed`),
 *  要验两行都在的几何就从这里补。 */
export async function openProfiledPerformer(browser: Browser, viewport = DESKTOP, theme: 'light' | 'dark' = 'light',
  extra: Record<string, unknown> = {}): Promise<Visit> {
  const opened = await visit(browser, '/', viewport);
  await opened.page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
    id: 90_101, kind: 'performer', canonical_name: PROFILED.name, aliases: ['篠崎ゆう子'], display_aliases: ['篠崎ゆう子'],
    user_aliases: [], asset_count: 42, tags: [], related_performers: [], metadata: {},
    has_image: false, has_avatar: false, avatar_focus: null, representative_asset_id: null,
    agency: { id: 90_102, canonical_name: 'New Actor eXperience', source: 'test', checked_at: '' },
    links: [
      { link_id: 90_201, link_kind: 'official', clickable: true, label: 'New Actor eXperience',
        url: 'https://official.nax-pro.com/actress/shinoda' },
      { link_id: 90_202, link_kind: 'social', clickable: true, label: 'X @shinoda_yu', url: 'https://x.com/shinoda_yu' },
    ],
    entry_links: [{ site: 'minnano-av', label: 'みんなのAV', ordinal: '', slot: 'pill', mark: 'brand-minnano',
      url: 'https://www.minnano-av.com/actress12345.html' }],
    profile: PROFILED.profile, name_groups: PROFILED.name_groups,
    ...extra,
  } }));
  // 这几条链接是造出来的,服务端的圆标取不到;那是另一条判据,这里给一张能加载完的图。
  await opened.page.route('**/link-mark**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await opened.page.goto(new URL(`/performers/${encodeURIComponent(PROFILED.name)}`, opened.page.url()).href, { waitUntil: 'load' });
  await opened.page.locator('[data-entity-card] [data-entity-links]').waitFor({ timeout: 15_000 });
  await settle(opened.page);
  await opened.page.evaluate((dark) => {
    document.documentElement.dataset.theme = dark ? 'dark' : 'light';
    document.documentElement.classList.toggle('dark', dark);
  }, theme === 'dark');
  return opened;
}

/** 资料卡浮层的落影要和遗留 `.popmenu` 同一副:在同一页造一个空 `.popmenu` 取它的计算值。 */
export async function popmenuShadow(page: Page): Promise<string> {
  return page.evaluate(() => {
    const menu = document.body.appendChild(Object.assign(document.createElement('div'), { className: 'popmenu' }));
    const shadow = getComputedStyle(menu).boxShadow;
    menu.remove();
    return shadow;
  });
}

/** 下拉栏里一部作品的小图。字段以 `_suggest_work_card`(`src/peach/web_entity.py`)为准。 */
export const suggestCard = (id: number) => ({ id, code: `ABW-${id % 1000}`, has_cover: true, has_thumb: false, cover_frame: null, poster_box: null });

/** 一段输入命中女优、厂牌与视频三类的补全,字段以 `q_suggest` 为准。演示库里没有女优实体。 */
export const SUGGEST = {
  q: '涼森',
  groups: [
    { kind: 'performer', label: '女优', total: 2, items: [
      { value: '涼森れむ', n: 128, matched: '', id: null, entity_id: 90_301, has_image: true, avatar_focus: null, rep: null,
        agency: 'Capsule Agency', works: [1, 2, 3, 4].map((at) => suggestCard(90_400 + at)) },
      { value: '涼森ひより', n: 3, matched: 'すずもりひより', id: null, entity_id: 90_302, has_image: false, rep: null,
        agency: '', works: [] },
    ] },
    { kind: 'studio', label: '厂牌', total: 1, items: [
      { value: 'Prestige', n: 900, matched: '', id: null, entity_id: 90_303, has_image: false, has_logo: true },
    ] },
    { kind: 'asset', label: '视频', total: 12, items: [1, 2, 3, 4, 5].map((at) => ({
      value: `ABW-${500 + at}`, n: 0, matched: '', id: 90_500 + at, code: `ABW-${500 + at}`,
      title: '出演作', who: '涼森れむ', card: suggestCard(90_500 + at),
    })) },
  ],
};

/** 补全、下拉里的图和点进去的资料页都按上面那份造好的数据答。返回每次补全请求问的种类。
 *  图是造出来的实体和番号,服务端取不到;那是另一条判据,这里给一张能加载完的图。 */
export async function stubSuggest(page: Page): Promise<string[]> {
  const kinds: string[] = [];
  await page.route(/\/api\/suggest\?/, (route) => {
    const kind = new URL(route.request().url()).searchParams.get('kind') || '';
    kinds.push(kind);
    return route.fulfill({ json: { q: SUGGEST.q, groups: SUGGEST.groups.filter((group) => !kind || group.kind === kind) } });
  });
  await page.route(/\/(?:cover|entity-image|avatar|logo)\?/, (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
    id: 90_301, kind: 'performer', canonical_name: '涼森れむ', aliases: [], display_aliases: [], user_aliases: [],
    asset_count: 0, tags: [], related_performers: [], links: [], metadata: {}, entry_links: [],
    has_image: false, has_avatar: false, avatar_focus: null, representative_asset_id: null,
  } }));
  return kinds;
}

/** 资料卡此刻的几何:身份列、资料表与卡本身的外框,以及资料表那道分隔线落在哪一边。 */
export async function heroGeometry(page: Page) {
  return page.evaluate(() => {
    const rect = (selector: string) => {
      const box = document.querySelector(selector)?.getBoundingClientRect();
      return box ? { left: box.left, right: box.right, top: box.top, bottom: box.bottom } : null;
    };
    const hero = document.querySelector('[data-entity-card]')!;
    const facts = document.querySelector('[data-entity-facts]');
    const style = facts ? getComputedStyle(facts) : null;
    return {
      hero: rect('[data-entity-card]'), identity: rect('[data-entity-identity]'), facts: rect('[data-entity-facts]'),
      heroScrolls: hero.scrollWidth > hero.clientWidth + 1,
      rule: style ? { left: style.borderLeftWidth, top: style.borderTopWidth } : null,
      labels: [...document.querySelectorAll('[data-entity-facts] dt')].map((dt) => dt.textContent!.trim()),
    };
  });
}

/** 禁用档的三样颜色(peach-web-ui「按钮悬停只抬填充」那条):`--surface` 底、`--border-15` 边、`--muted` 字。 */
export async function disabledTokens(page: Page) {
  return {
    face: await tokenColor(page, '#main', '--surface'),
    ink: await tokenColor(page, '#main', '--muted'),
    ring: await tokenColor(page, '#main', '--border-15'),
  };
}

/** 骨架里等数据的操作键此刻的长相与状态,按文档顺序。 */
export async function waitingActionFaces(page: Page, selector: string) {
  return page.locator(selector).evaluateAll((buttons) => buttons.map((button) => {
    const style = getComputedStyle(button);
    return {
      name: (button.getAttribute('aria-label') || button.textContent || '').trim(),
      disabled: (button as HTMLButtonElement).disabled, cursor: style.cursor, opacity: style.opacity,
      face: style.backgroundColor, image: style.backgroundImage, ink: style.color, ring: style.boxShadow,
      split: !!button.closest('[data-split-button]'),
    };
  }));
}

export function assertDisabledFace(face: Awaited<ReturnType<typeof waitingActionFaces>>[number],
  expected: Awaited<ReturnType<typeof disabledTokens>>): void {
  assert.equal(face.disabled, true, `${face.name} 在骨架里没有禁用`);
  assert.equal(face.cursor, 'not-allowed', `${face.name} 在骨架里的光标不是禁用那一种`);
  assert.equal(face.opacity, '1', `${face.name} 的禁用态靠透明度,而不是换颜色`);
  assert.equal(face.face, expected.face, `${face.name} 在骨架里不是禁用底色`);
  assert.equal(face.image, 'none', `${face.name} 在骨架里还铺着渐变`);
  assert.equal(face.ink, expected.ink, `${face.name} 在骨架里不是禁用字色`);
  // 分体键的外圈画在整组上,两半各画一圈的话中缝会出现两道线。
  if (!face.split) assert.ok(face.ring.includes(expected.ring), `${face.name} 在骨架里没有禁用描边:${face.ring}`);
}

/** 切到关注管理页的另一栏。栏名后面可能挂着待配置的数目,按开头认。 */
export async function followTab(opened: Visit, name: string): Promise<void> {
  await opened.page.locator('#stats').getByRole('tab', { name: new RegExp(`^${name}`) }).click({ timeout: 5_000 });
  await settle(opened.page);
}

/** 每个设计决定文件一个浏览器,至多运行 8 项就换一个,页面与图像缓存的累积占用受限。
 * `assign` 在每次换上新浏览器时收到它,文件里的用例照旧读同一个 `browser` 变量。 */
export function installDesignBrowser(assign: (browser: Browser) => void): void {
  let browser: Browser;
  let browserTests = 0;

  before(async () => {
    browser = await launch();
    assign(browser);
  });

  beforeEach(async () => {
    if (browserTests === 8) {
      await browser.close();
      browser = await launch();
      assign(browser);
      browserTests = 0;
    }
    browserTests += 1;
  });

  after(async () => {
    await browser.close();
  });
}