Ctrl K
ComunidadGuías…

frontend/e2e/smoke.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 每条主路由在桌面与 390×844 下都要守住的不变量。
 *
 * 这些是界面验收里每一轮都要重测的几何与运行期事实:写成用例之后由测试入口执行,
 * 浏览器面板只留给新布局首次成形和观感判断。新发现的同类问题先在这里补一条再修。
 *
 * 每条路由先等它自己的主体出现,再 settle、再量几何。报错、失败请求、横向溢出和等待态
 * 这几条在页面完全没渲染时全都成立,不写主体的话白屏也是绿的。主体按 `tests/test_web_e2e.py`
 * 生成的演示库写:12 条视频,没有播放列表和关注来源,这两页等到的是各自的空态。
 * 任务中心可能已有自动修复记录,主体认明确的任务空态或任务卡。
 * 女优、厂牌与标签的索引在一轮里会从空变成有:前面的路由打开过作品之后条目就出现了,
 * 所以索引页的主体是「条目或空态」二选一,不绑死其中一种。标题只能拿来认页面,不能单独
 * 当主体:管理页的 `[data-manage-title]` 由外壳先写好,内容区失败时它照样在。
 *
 * 分区 tab(`.ui-board-local-nav`)只显示当前那一格,其余面板不参与布局;每一格都切过去
 * 再测一遍,否则配置页「网络与访问」这类不在首屏的分区永远不会被量到。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

import type { Browser, Locator, Page } from 'playwright-core';

import { configurationBody, expectBody, layout, launch, requiredEnv, settle, unmanagedScrollers, visit, VIEWPORTS } from './harness.ts';
import { mockJavDetail } from './jav-detail-fixture.ts';
import { CLEANUP_HASH_REDIRECTS } from '../src/cleanup-routes.ts';

interface Route {
  path: string;
  /** 目标页面主体:每个定位器都要可见。骨架屏的占位标题带 `aria-hidden`,按角色找不到。 */
  body(page: Page): Locator[];
}

const heading = (page: Page, scope: string, name: string): Locator =>
  page.locator(scope).getByRole('heading', { name, exact: true });
/* 数据管理二级页的卡片标题与页头同名;只认页头那一层 h2,React 卡片何时挂载都只匹配一个。 */
const pageTitle = (page: Page, name: string): Locator =>
  page.locator('#main').getByRole('heading', { name, exact: true, level: 2 });

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

/** 索引条目(`data-k`)或明确的空态,先出现哪个算哪个。两样都只有 React 那棵树画,
 * 壳铺的骨架一样都不带。选择器里就带 `:visible`:不然 `.first()` 可能落在一个隐藏的
 * 匹配上,等它可见等到超时,主体其实早画好了。 */
const indexEntries = (page: Page): Locator =>
  page.locator('#index [data-k]:visible, #index [data-empty-state]:visible').first();

/** 任务数据已渲染:明确空态或真实任务卡都属于任务页的 React 根。 */
const tasksBody = (page: Page): Locator[] => [
  heading(page, '#main', '任务中心'),
  page.locator('#stats .peach-react'),
  heading(page, '#stats .peach-react [data-empty-state]', '还没有任务记录')
    .or(page.locator('#stats .peach-react [data-run-id]:visible')).first(),
];

/** 统计页画完的标志:四张读数卡兼页签,第一张是馆藏。名字里还带读数和体积,按前缀匹配。 */
const statsInventoryTab = (page: Page): Locator =>
  page.locator('#stats').getByRole('tab', { name: /^馆藏视频/ });

const ROUTES: readonly Route[] = [
  { path: '/works/EXM-001', body: page => [page.locator('#stage [data-jav-detail] h1'), page.getByRole('img', { name: 'EXM-001 封面', exact: true })] },
  { path: '/jav', body: page => [heading(page, '#javLibraryHeader', 'JAV'), page.getByRole('tab', { name: '馆藏', exact: true, selected: true }), page.locator('#grid [data-media-card]:visible, #grid [data-media-empty]:visible').first()] },
  { path: '/jav?tab=discover', body: page => [heading(page, '#stats', 'JAV'), page.getByRole('tab', { name: '发现', exact: true, selected: true }), page.getByText('暂无匹配口味的作品', { exact: true })] },
  { path: '/jav?tab=wants', body: page => [heading(page, '#stats', 'JAV'), page.getByRole('tab', { name: '想要', exact: true, selected: true }), page.getByText('还没有想要的作品', { exact: true })] },
  { path: '/', body: (page) => [page.locator('#grid [data-media-card]').first()] },
  { path: '/wall', body: (page) => [page.locator('[data-wall-tile]').first(), page.getByRole('button', { name: '退出播放墙' })] },
  { path: '/performers', body: (page) => [heading(page, '#index', '艺人'), indexEntries(page)] },
  { path: '/creators', body: (page) => [heading(page, '#index', '卖家'), indexEntries(page)] },
  { path: '/creators/', body: (page) => [heading(page, '#index', '卖家'), indexEntries(page)] },
  { path: '/performers?category=animation', body: (page) => [heading(page, '#index', '艺人'), indexEntries(page)] },
  { path: '/studios', body: (page) => [heading(page, '#index', '厂牌'), indexEntries(page)] },
  { path: '/agencies', body: (page) => [heading(page, '#index', '事务所'), indexEntries(page)] },
  { path: '/series', body: (page) => [heading(page, '#index', '系列'), indexEntries(page)] },
  { path: '/tags', body: (page) => [heading(page, '#index', '标签'), indexEntries(page)] },
  {
    path: '/playlists',
    body: (page) => [heading(page, '#stats', '播放列表'), heading(page, '#stats', '还没有播放列表')],
  },
  {
    path: '/follow',
    body: (page) => [heading(page, '#stats', '关注'), heading(page, '#stats', '还没有关注任何来源')],
  },
  {
    // 骨架里也有「关注列表」那一排分段,可整块带 `aria-hidden`;按角色找得到页签的只有真页面。
    path: '/subscriptions',
    body: (page) => [heading(page, '#main', '关注管理'),
      page.locator('#stats').getByRole('tab', { name: '添加关注', exact: true })],
  },
  {
    // 订阅管理链接进入统一关注列表。
    path: '/subscriptions?tab=feeds',
    body: (page) => [page.locator('#stats').getByRole('tab', { name: '关注列表', exact: true, selected: true }),
      page.locator('#stats').getByRole('button', { name: '立即拉取', exact: true })],
  },
  {
    path: '/subscriptions?tab=add',
    body: (page) => [page.locator('#stats').getByRole('region', { name: '添加创作者关注', exact: true }),
      page.locator('#stats').getByRole('region', { name: '添加 JAV 订阅', exact: true })],
  },
  {
    // 骨架里那条指标带也写着「馆藏视频」,认不出画完没有;只有真页面把四张读数卡做成页签。
    path: '/stats',
    body: (page) => [heading(page, '#main', '统计'), statsInventoryTab(page)],
  },
  {
    // 骨架那条指标带也写着「浏览记录」,认不出画完没有;把两套证据做成页签的只有真页面。
    path: '/taste',
    body: (page) => [heading(page, '#main', '口味'),
      page.locator('#stats').getByRole('tab', { name: '浏览器记录', exact: true })],
  },
  {
    // 骨架那一列分类也写着「复核分类」,认不出画完没有;把十个分类做成页签的只有真页面。
    path: '/review',
    body: (page) => [heading(page, '#main', '人工复核'),
      page.locator('#stats').getByRole('tab', { name: '元数据字段' })],
  },
  {
    path: '/data-cleanup',
    // 挂载前的遗留骨架用 heading,最终 React 卡片用带名字的 section;等最终语义才能证明挂载完成。
    body: (page) => [heading(page, '#main', '数据管理'),
      page.locator('#stats section[aria-label="扫描与采集"]'),
      page.locator('#cleanup-pending'), page.locator('#cleanup-maintenance'), page.locator('#cleanup-deletion')],
  },
  { path: '/data-cleanup/organize', body: page => [pageTitle(page, '整理文件'), page.locator('#stats #organize')] },
  { path: '/data-cleanup/media-repair', body: page => [pageTitle(page, '媒体修复'), page.locator('#stats section[aria-label="媒体修复"]')] },
  { path: '/data-cleanup/resource-sync', body: page => [pageTitle(page, '资源同步'), page.locator('#stats #resource-sync')] },
  { path: '/data-cleanup/orphan-records', body: page => [pageTitle(page, '文件已丢失的记录'), page.locator('#stats #orphan-records')] },
  { path: '/data-cleanup/links', body: page => [pageTitle(page, '链接管理'), page.locator('#stats #link-manager')] },
  { path: '/data-cleanup/batch-delete', body: page => [pageTitle(page, '批量删除'), page.locator('#stats #batch-delete')] },
  {
    // 演示库里没有垃圾候选,等到的是垃圾队列那一页的空态标题;壳铺的骨架只有卡片占位。
    path: '/junk-files',
    body: (page) => [heading(page, '#main', '垃圾文件'), heading(page, '#grid', '没有待判断的垃圾文件')],
  },
  {
    // 骨架只写「正在比对重复内容」;汇总块上那一格「重复内容」有没有重复组都会画,只有真页面有。
    path: '/duplicates',
    body: (page) => [heading(page, '#main', '重复文件'), page.locator('#stats').getByText('重复内容', { exact: true })],
  },
  {
    // 旧直达地址进入资源同步二级页。
    path: '/resource-sync',
    body: (page) => [pageTitle(page, '资源同步'), page.locator('#stats section#resource-sync')],
  },
  {
    path: '/quality-goals',
    // 演示库里没有标记中的目标,等到的是空态那一句:它是真的标题元素,不是一段文字。
    body: (page) => [heading(page, '#main', '高清版'), heading(page, '#stats', '没有标记中的高清版目标')],
  },
  {
    // 「高清图片可能需要代理」那句遗留骨架里也有,认不出画完没有;「高清封面」那一张只有真页面有。
    path: '/sources',
    body: (page) => [heading(page, '#main', '来源和凭证'), page.locator('#stats form[aria-label="高清封面"]')],
  },
  // 名字对不上任何一家时要换成空态;骨架一直留着,读起来就是还在取。
  { path: '/agencies/不存在的事务所', body: (page) => [heading(page, '#index', '找不到这个事务所')] },
  { path: '/settings/library', body: configurationBody },
  { path: '/diagnostics', body: page => [page.getByRole('tab', { name: '系统诊断', selected: true }), page.locator('#stats section[aria-label="库健康"]')] },
  { path: '/settings/diagnostics', body: page => [page.getByRole('tab', { name: '系统诊断', selected: true }), page.locator('#stats section[aria-label="库健康"]')] },
  { path: '/tasks', body: tasksBody },
  ...['', '/appearance', '/playback', '/jav', '/search', '/content'].map(section => ({ path: '/settings' + section,
    body: (page: Page) => [page.getByRole('dialog', { name: /外观与导航|浏览与播放|JAV 与新作|搜索与推荐|内容显示/ })] })),
  ...['subscriptions', 'downloads', 'network', 'maintenance'].map(section => ({ path: `/settings/${section}`, body: configurationBody })),
  { path: '/settings/maintenance/repairs', body: page => [pageTitle(page, '修复与迁移'), page.locator('#main [data-manage-crumb] a[href="/settings/maintenance"]'),
    page.locator('#stats section[aria-labelledby="repair-items"]')] },
  {
    path: `/assets/${requiredEnv('PEACH_E2E_ITEM')}`,
    body: (page) => {
      const stage = page.getByRole('dialog', { name: '作品详情' });
      return [stage, stage.locator('video.vjs-tech')];
    },
  },
];

const LEGACY_ROUTES: readonly Route[] = [
  ...Object.entries(CLEANUP_HASH_REDIRECTS).map(([hash, path]) => ({
    path: '/data-cleanup' + hash, body: ROUTES.find(route => route.path === path)!.body,
  })),
  { path: '/subscriptions?tab=wants', body: ROUTES.find(route => route.path === '/jav?tab=wants')!.body },
  { path: '/subscriptions?tab=source', body: page => [page.locator('#stats form[aria-label="资源搜索"]')] },
];

async function assertHolds(page: Page, problems: string[], where: string): Promise<void> {
  assert.deepEqual(problems, [], `${where}:页面报错或有失败请求`);
  const box = await layout(page);
  assert.ok(box.scrollWidth <= box.viewportWidth,
    `${where}:横向溢出,scrollWidth ${box.scrollWidth} > 视口 ${box.viewportWidth}`);
  assert.deepEqual(box.offenders, [], `${where}:有元素越出视口右边缘`);
  assert.deepEqual(await unmanagedScrollers(page), [], `${where}:存在未接入统一实现的滚动区`);
  if (where === '/') {
    const images = await page.locator('img[loading="lazy"]').evaluateAll(nodes => ({
      count: nodes.length,
      missing: nodes.filter(node => !(Number(node.getAttribute('width')) > 0 && Number(node.getAttribute('height')) > 0))
        .map(node => node.getAttribute('src')),
    }));
    assert.ok(images.count > 0, '首页没有懒加载图片');
    assert.deepEqual(images.missing, [], '首页懒加载图片需要声明尺寸');
  }
}

describe('路由冒烟', () => {
  let browser: Browser;

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

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

  it('入库 toast 打开真实详情,标题使用番号或文件名,返回与前进恢复对应页面', { timeout: 60_000 }, async () => {
    const id = Number(requiredEnv('PEACH_E2E_ITEM'));
    let items: Array<{ id: number; kind: string; asset_id: number; label: string; at: number }> = [];
    let reads = 0;
    const opened = await visit(browser, '/stats', DESKTOP, async page => {
      await page.route('**/api/result-updates', route => {
        reads += 1;
        return route.fulfill({ json: { ok: true, busy: true, result_versions: {}, ingest_notices: { epoch: 'toast-navigation', items } } });
      });
    });
    try {
      await expectBody(opened.page, '/stats', [statsInventoryTab(opened.page)]);
      await opened.page.waitForFunction(() => document.title === '统计 · Peach');
      assert.ok(reads > 0, '通知基线没有读取');
      const response = await opened.page.request.get(`${requiredEnv('PEACH_E2E_ORIGIN')}/api/item?id=${id}`);
      const item = await response.json();
      const label = [item.display_code, item.code, item.name].map(value => String(value || '').trim()).find(Boolean);
      assert.ok(label, '样本作品缺少番号与文件名');
      items = [{ id: 1, kind: 'download', asset_id: id, label, at: 0 }];
      const open = opened.page.locator('[data-sonner-toast]').filter({ hasText: '已入库并整理完成' }).getByRole('button', { name: '打开', exact: true });
      await open.click({ timeout: 15_000 });
      const stage = opened.page.getByRole('dialog', { name: '作品详情', exact: true });
      await stage.locator('[data-item-detail] [data-item-title]').waitFor({ state: 'visible' });
      assert.equal(new URL(opened.page.url()).pathname, `/assets/${id}`);
      await opened.page.waitForFunction(expected => document.title === expected, `${label} · Peach`);
      await opened.page.goBack();
      await statsInventoryTab(opened.page).waitFor({ state: 'visible' });
      await opened.page.waitForFunction(() => document.title === '统计 · Peach');
      await opened.page.goForward();
      await stage.locator('[data-item-detail] [data-item-title]').waitFor({ state: 'visible' });
      await opened.page.waitForFunction(expected => document.title === expected, `${label} · Peach`);
    } finally { await opened.close() }
  });

  for (const viewport of VIEWPORTS) {
    for (const route of [...ROUTES, ...LEGACY_ROUTES]) {
      // 单条超时远小于 Python 侧的整轮超时:卡住的那一条自己报名字,不拖垮整轮输出。
      it(`${route.path} @ ${viewport.name}`, { timeout: 60_000 }, async () => {
        const opened = await visit(browser, route.path.startsWith('/works/') ? '/stats' : route.path, viewport);
        try {
          if (route.path.startsWith('/works/')) {
            await mockJavDetail(opened.page);
            await opened.page.goto(requiredEnv('PEACH_E2E_ORIGIN') + route.path);
          }
          await expectBody(opened.page, route.path, route.body(opened.page));
          await settle(opened.page);
          await assertHolds(opened.page, opened.problems, route.path);
          // 设置浮层的那一排 tab 常驻 DOM、关着时不可见;只点页面上看得见的那一排。
          const tabs = opened.page.locator('.ui-board-local-nav [role="tab"]:visible');
          const count = await tabs.count();
          // 选择器一旦对不上,循环一格都不跑也照样绿;配置页至少有媒体、网络与访问、维护三格。
          if (route.path === '/settings/library') assert.ok(count >= 3, `配置页只看到 ${count} 个分区 tab`);
          for (let index = 0; index < count; index += 1) {
            const tab = tabs.nth(index);
            const name = (await tab.textContent())?.trim() || `第 ${index + 1} 格`;
            await tab.click({ timeout: 5_000 });
            await settle(opened.page);
            assert.equal(await tab.getAttribute('aria-selected'), 'true', `${route.path}「${name}」点了没有选中`);
            await assertHolds(opened.page, opened.problems, `${route.path}「${name}」`);
          }
        } finally {
          await opened.close();
        }
      });
    }
  }

  /* 任务页读完整任务列表,常驻的结果更新只读轻量版本:离页卸掉任务页后不再读任务列表,
     结果更新继续低频读取。固定空态让轮询保持十秒间隔;回到任务页前撤掉拦截,再读取服务端真实任务。
     换页走 pushState + popstate:侧栏按钮、抽屉和浏览器后退最后走的都是这一条路。 */
  it('离开任务页卸载旧根,全局结果轮询持续,返回读取真实任务', { timeout: 120_000 }, async () => {
    const polls: number[] = [];
    const updates: number[] = [];
    const opened = await visit(browser, '/tasks', DESKTOP, async (page) => {
      await page.route('**/api/tasks', route => route.fulfill({ json: {
        available: true, running: [], skipped: [], finished: [], finished_has_more: false,
      } }));
      await page.route('**/api/result-updates', route => route.fulfill({ json: {
        ok: true, busy: false, result_versions: {}, ingest_notices: { epoch: 'smoke', items: [] },
      } }));
      page.on('request', (request) => {
        const path = new URL(request.url()).pathname;
        if (path === '/api/tasks') polls.push(performance.now());
        if (path === '/api/result-updates') updates.push(performance.now());
      });
    });
    try {
      const waitForPolls = async (least: number, what: string) => {
        for (let step = 0; step < 80; step += 1) {
          if (polls.length >= least) return;
          await opened.page.waitForTimeout(250);
        }
        assert.ok(polls.length >= least, `${what}:只看到 ${polls.length} 次 /api/tasks,要 ${least} 次`);
      };
      await expectBody(opened.page, '/tasks', tasksBody(opened.page));
      // 先取得首屏与下一次轮询,离页后才能核对共享查询的节律。
      await waitForPolls(2, '活动页的轮询没有跑起来');
      const taskRoot = await opened.page.locator('#stats .peach-react').elementHandle();
      assert.ok(taskRoot, '任务页的 React 根没有挂载');

      await opened.page.evaluate(() => {
        history.pushState({}, '', '/stats');
        window.dispatchEvent(new PopStateEvent('popstate'));
      });
      await expectBody(opened.page, '/stats', [statsInventoryTab(opened.page)]);
      await settle(opened.page);
      const leftAt = polls.length;
      const updatesAt = updates.length;
      // 统计页自己也是 React 档:管理区正文里应当只剩它那一棵根。
      assert.equal(await opened.page.locator('#stats .peach-react').count(), 1,
        '管理区正文里不是只剩统计页那一棵 React 根');
      assert.equal(await taskRoot.evaluate(root => root.isConnected), false,
        '换页之后活动页的 React 根还留在文档里');
      await taskRoot.dispose();
      // 十二秒覆盖一次十秒轮询;允许观察窗口跨过两次,但每次必须至少相隔八秒。
      // 两秒余量容纳导航与计时误差,也能拒绝活跃任务的两秒节律或并行的重复请求。
      await opened.page.waitForTimeout(12_000);
      // 离开任务页后,全局轮询只读轻量的结果版本,不再组装整张任务列表。
      assert.equal(polls.length, leftAt, `离开任务页后仍在读取完整任务列表:${polls.length - leftAt} 次`);
      const continued = updates.slice(updatesAt);
      assert.ok(continued.length >= 1 && continued.length <= 2,
        `离开任务页后全局结果轮询应持续单路低频读取,十二秒内看到 ${continued.length} 次请求`);
      for (let index = Math.max(1, updatesAt); index < updates.length; index += 1) {
        assert.ok(updates[index] - updates[index - 1] >= 8_000,
          `全局结果轮询间隔过短:${Math.round(updates[index] - updates[index - 1])}ms`);
      }

      await opened.page.unroute('**/api/tasks');
      const beforeReturn = polls.length;
      const returningTasks = opened.page.waitForResponse(response =>
        new URL(response.url()).pathname === '/api/tasks' && response.status() === 200);
      await opened.page.goBack();
      await returningTasks;
      await expectBody(opened.page, '/tasks', tasksBody(opened.page));
      await waitForPolls(beforeReturn + 2, '回到任务页之后真实任务轮询没有接上');
      await assertHolds(opened.page, opened.problems, '/tasks 换页往返');
    } finally {
      await opened.close();
    }
  });

  /* 首页那一行新作只属于目录页:从首页进统计页要收起保留,取数还没回来就离开的那一轮
     也不许回头把它画到管理区上。演示库没有订阅,新作由拦截的接口给一条。 */
  it('离开首页之后新作那一行收起,晚到的取数也不画回来', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/stats', DESKTOP);
    try {
      let hold: Promise<void> | null = null;
      let served = 0;
      await opened.page.route(/\/api\/feeds\/discoveries\?/, async (route) => {
        if (hold) await hold;
        // 请求可能已随换页撤掉,那时回包会报错;要核对的只是回包之后页面长什么样。
        await route.fulfill({ json: { ok: true, more: false, items: [{
          id: 1, code: 'MCSR-191', title: 'MCSR-191 示例作品', link: null, cover_url: null,
          has_cover: false, cover_frame: null, poster_box: null, release_date: '2025-07-19',
          studio: null, performers: null, read: false, ignored: false, scrape_error: null,
        }] } }).catch(() => undefined);
        served += 1;
      });
      const go = (path: string) => opened.page.evaluate((target) => {
        history.pushState({}, '', target);
        window.dispatchEvent(new PopStateEvent('popstate'));
      }, path);
      const feedCards = opened.page.locator('#feedNew article.feednewcard');
      await expectBody(opened.page, '/stats', [statsInventoryTab(opened.page)]);

      await go('/');
      await expectBody(opened.page, '/', [feedCards.first()]);
      await go('/stats');
      await expectBody(opened.page, '/stats', [statsInventoryTab(opened.page)]);
      assert.equal(await opened.page.locator('#feedNew').isHidden(), true, '进统计页之后新作那一行还露着');
      assert.equal(await feedCards.filter({ visible: true }).count(), 0, '进统计页之后新作卡片仍可见');

      const beforeReturn = served;
      await go('/');
      await expectBody(opened.page, '/', [feedCards.first()]);
      assert.equal(served, beforeReturn, '返回首页沿用新作缓存');
      await go('/junk-files');
      await opened.page.locator('#count [data-junk-filters]').waitFor();
      assert.equal(await opened.page.locator('#feedNew').isHidden(), true, '垃圾文件页不展示订阅新作');
      assert.equal(await feedCards.filter({ visible: true }).count(), 0);

      for (const target of ['/stats', '/junk-files']) {
        hold = null;
        const reloadPath = new URL(opened.page.url()).pathname;
        await opened.page.reload({ waitUntil: 'load' });
        await expectBody(opened.page, reloadPath, [reloadPath === '/stats'
          ? statsInventoryTab(opened.page) : opened.page.locator('#count [data-junk-filters]')]);
        let release!: () => void;
        hold = new Promise((resolve) => { release = resolve; });
        const asked = opened.page.waitForRequest(/\/api\/feeds\/discoveries\?/);
        await go('/');
        await asked;
        await go(target);
        const body = target === '/stats' ? statsInventoryTab(opened.page) : opened.page.locator('#count [data-junk-filters]');
        await expectBody(opened.page, target, [body]);
        const before = served;
        release();
        for (let step = 0; step < 40 && served === before; step += 1) await opened.page.waitForTimeout(100);
        assert.ok(served > before, '压住的那一次新作取数没有放行');
        // 回包之后页面只剩一次同步重画;目录区的旧加载态不属于当前页面。
        await opened.page.waitForTimeout(500);
        assert.equal(await opened.page.locator('#feedNew').isHidden(), true);
        assert.equal(await feedCards.filter({ visible: true }).count(), 0, `晚到的新作取数画到了 ${target} 上`);
        await assertHolds(opened.page, opened.problems, `首页与 ${target} 往返`);
      }
    } finally {
      await opened.close();
    }
  });

  /* 还没装进本机的新作只有来源给的封套地址,没有边车。贴右缘的话 0.75 的卡片比正封宽,
     左边带进一条书脊;按正封先验比例 0.704 从右缘量回去才只剩正封。 */
  it('远程封套按正封先验比例切,不带进书脊', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/stats', DESKTOP);
    try {
      await expectBody(opened.page, '/stats', [statsInventoryTab(opened.page)]);
      const remote = 'https://covers.example.test/sleeve.svg';
      await opened.page.route('**/cover-image?code=ABF-348', (route) => route.fulfill({
        contentType: 'image/svg+xml',
        body: '<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1000">'
          + '<rect width="1500" height="1000" fill="#888"/></svg>',
      }));
      await opened.page.route(/\/api\/feeds\/discoveries\?/, (route) => route.fulfill({ json: {
        ok: true, more: false, items: [{
          id: 1, code: 'ABF-348', title: 'ABF-348 示例作品', link: null, cover_url: remote,
          has_cover: false, cover_frame: null, poster_box: null, release_date: '2026-09-30',
          studio: null, performers: null, read: false, ignored: false, scrape_error: null,
        }] } }));
      await opened.page.evaluate(() => {
        history.pushState({}, '', '/');
        window.dispatchEvent(new PopStateEvent('popstate'));
      });
      const cover = opened.page.locator('#feedNew img.cover.panel');
      await expectBody(opened.page, '/', [cover]);
      // 1500×1000 的封套,正封宽 704,左缘在 796 列:切掉左边 53.07%,右缘不动。
      assert.equal(await cover.evaluate((img) => img.style.getPropertyValue('--panel-clip')),
        '0% 0% 0% 53.07%');
    } finally {
      await opened.close();
    }
  });

  /* 最近一轮取封面断在连接上、这一行又还有没封面的卡时,行上方挂一条 Note 指去采集页。
     一排「无封面」自己说不清是官方没出还是线路不通。 */
  it('封面连不上图片主机时新作那一行上方指去配连接方式', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/stats', DESKTOP);
    try {
      await opened.page.route(/\/api\/feeds\/discoveries\?/, (route) => route.fulfill({ json: {
        ok: true, more: false, cover_network: 3, items: [{
          id: 1, code: 'MCSR-191', title: 'MCSR-191 示例作品', link: null, cover_url: null,
          has_cover: false, cover_frame: null, poster_box: null, release_date: '2025-07-19',
          studio: null, performers: null, read: false, ignored: false, scrape_error: null,
        }] } }));
      await opened.page.evaluate(() => {
        history.pushState({}, '', '/');
        window.dispatchEvent(new PopStateEvent('popstate'));
      });
      const note = opened.page.locator('#feedNew .feednetwork');
      await expectBody(opened.page, '/', [note]);
      assert.match(await note.innerText(), /3 部新作的封面连不上 DMM 图片主机/);
      assert.equal(await note.locator('a').getAttribute('href'), '/sources');
      await assertHolds(opened.page, opened.problems, '新作连不上图片主机的提示');
    } finally {
      await opened.close();
    }
  });
});