Ctrl K
ComunidadGuías…

frontend/e2e/item-detail.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 作品详情岛(`item-detail`)在真浏览器里的行为:从目录卡进出、深链、后退回到详情与刷新后关掉回哪一页、
 * 播放列表换条后关掉回列表页、舞台在媒体框里挂 Video.js、
 * 脱盘说明、共演收起、标题折叠、评分与标签的撤销、反馈键态、四种队列、播放列表排序与移出、保存 Mix、接着看、
 * 实体页入口与手机布局。
 *
 * 桩数据见 `item-fixture.ts`;读写接口全部由桩接住,演示库数据库一次都不写。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

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

import { layout, launch, VIEWPORTS } from './harness.ts';
import { CATALOG, EDITIONS, ITEM, NAMES, PARTS, PLAYLIST, RELATED, card as itemCard, openItemPage } from './item-fixture.ts';

const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;
const MOBILE = VIEWPORTS.find((viewport) => viewport.mobile)!;
const DETAIL_READY = '#stage[open] [data-item-side]';
/** 主网格卡点标题打开分卷;竖屏带中的同条目卡点标题进入沉浸。 */
const PART_CARD = `#grid [data-media-card][data-variant="grid"][data-id="${ITEM.part}"] [data-media-text] > button[data-media-title][data-open]`;
/** 桩里的片源没有正片,播放器拿到空响应会报一条 VIDEOJS 错误;量的不是它。 */
const withoutPlayer = (problems: string[]) => problems.filter((line) => !line.includes('VIDEOJS'));
const pathIs = (page: Page, path: string) => page.waitForFunction(
  (wanted) => (wanted.includes('?') ? location.pathname + location.search : location.pathname) === wanted, path, { timeout: 10_000 });
/** 回执栈在 body 下,舞台是模态 dialog,外面整页惰性,鼠标点不到撤销键;这里量的是撤销本身。 */
const undo = (page: Page) => page.locator('#toasts').getByRole('button', { name: '撤销' }).last().dispatchEvent('click');
const current = (page: Page) => page.locator('#stage [data-queue-item][aria-current="true"]').getAttribute('data-queue-item');
const ids = (page: Page, selector: string, attribute: string) => page.locator(selector).evaluateAll(
  (nodes, name) => nodes.map((node) => Number(node.getAttribute(name))), attribute);


/** 换条前记下舞台节点并盯住骨架:原地换条时浮窗还是同一个节点,中途不回骨架,焦点留在浮窗里。 */
const watchStage = (page: Page) => page.evaluate(() => {
  const watch = { stage: document.getElementById('stage'), skeleton: false, heights: [] as number[] };
  const sample = () => {
    const media = document.querySelector('#stage [data-stage-media]');
    if (media) watch.heights.push(media.getBoundingClientRect().height);
    if (watch.heights.length < 120) requestAnimationFrame(sample);
  }; sample();
  new MutationObserver(() => { if (document.querySelector('#stage [data-skeleton="detail"]')) watch.skeleton = true })
    .observe(document.body, { childList: true, subtree: true });
  Object.assign(window, { stageWatch: watch });
});
const stageKept = (page: Page) => page.evaluate(() => {
  const watch = (window as unknown as { stageWatch: { stage: Element | null; skeleton: boolean; heights: number[] } }).stageWatch;
  if (Math.max(...watch.heights) - Math.min(...watch.heights) > 1)
    throw new Error(`作品切换画框高度不稳定:${JSON.stringify(watch.heights)}`);
  return { same: document.getElementById('stage') === watch.stage, skeleton: watch.skeleton,
    focused: !!watch.stage?.contains(document.activeElement) };
});

describe('作品详情岛', () => {
  let browser: Browser;

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

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

  it('推荐加载保持详情高度稳定,理由与悬停圆角适配默认视口', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    let release = () => {};
    try {
      const page = opened.page;
      let requested!: () => void;
      const received = new Promise<void>(resolve => { requested = resolve });
      const waiting = new Promise<void>(resolve => { release = resolve });
      await page.route(url => url.pathname === '/api/related', async route => {
        requested();
        await waiting;
        await route.fulfill({ json: { items: RELATED.map(id => ({ ...itemCard(id), why: '标签接近' })) } });
      });
      await page.reload();
      await received;
      const media = page.locator('#stage [data-item-media]');
      const initialHeight = await media.evaluate(node => node.getBoundingClientRect().height);
      release();
      const reasons = '#stage [data-item-related] [aria-label^="查看推荐理由"]';
      await page.locator(reasons).first().waitFor();
      const visible = () => page.waitForFunction(selector => {
        const scroll = document.querySelector('#stage [data-stage-scroll]')!;
        const bottom = scroll.getBoundingClientRect().bottom;
        return scroll.scrollTop === 0 && [...document.querySelectorAll(selector)]
          .every(reason => reason.getBoundingClientRect().bottom <= bottom + 1);
      }, reasons);
      await visible();
      const finalHeight = await media.evaluate(node => node.getBoundingClientRect().height);
      assert.ok(Math.abs(initialHeight - finalHeight) <= 1, `${initialHeight} → ${finalHeight}`);
      const card = page.locator('#stage [data-item-related] [data-media-card]').first();
      await card.hover();
      const shape = await card.evaluate(node => ({
        shadow:getComputedStyle(node).boxShadow,
        outer:parseFloat(getComputedStyle(node,'::before').borderTopLeftRadius),
        inner:parseFloat(getComputedStyle(node).getPropertyValue('--surface-radius')),
        height:node.getBoundingClientRect().height,
      }));
      assert.equal(shape.shadow, 'none');
      assert.equal(shape.outer, shape.inner + 8);
      assert.ok(shape.height < 220, JSON.stringify(shape));
      await page.locator('#stage [data-item-related] h3').evaluate(node => {
        node.textContent = '接着看 '.repeat(60);
      });
      await visible();
      await page.setViewportSize({ width: 1280, height: 720 });
      await visible();
      assert.deepEqual((await layout(page)).offenders, []);
    } finally { release(); await opened.close() }
  });

  for (const viewport of VIEWPORTS) it(`本地标签选择器复用覆盖式滚动条(${viewport.name})`, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, viewport);
    try {
      const { page } = opened;
      await page.locator('#stage [data-tag-plus]').evaluate(node => node.scrollIntoView({ block:'center', behavior:'instant' }));
      await page.locator('#stage [data-tag-plus]').click();
      const menu = page.locator('#stage [data-tag-picker]');
      await menu.waitFor({ state:'visible' });
      const scrollbar = await menu.locator('[data-tag-body]').evaluate(node => ({
        hidden:getComputedStyle(node).scrollbarWidth,
        track:!!node.parentElement?.querySelector('.ovtrack.ov-y'),
        positioned:getComputedStyle(node.parentElement!).position,
      }));
      assert.deepEqual(scrollbar, { hidden:'none', track:true, positioned:'relative' });
    } finally { await opened.close(); }
  });

  for (const kind of ['parts', 'editions'] as const) for (const count of [1, 2, 3]) {
    it(`${kind} 队列的 ${count} 张卡按内容分配空间,推荐卡等宽对齐`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, '/', DESKTOP, { ready: '#grid [data-media-card]' });
      try {
        const page = opened.page;
        const seed = kind === 'parts' ? ITEM.part : ITEM.edition;
        const group = kind === 'parts' ? PARTS : EDITIONS;
        await page.route(url => url.pathname === `/api/${kind}`, route => route.fulfill({ json: {
          ...group, items: Array.from({ length: count }, (_, index) => ({ ...group.items[index % group.items.length], id: seed + index })),
        } }));
        await page.goto(new URL(`/assets/${seed}?${kind}=${seed}`, page.url()).toString());
        await page.locator('#stage [data-queue-row]').first().waitFor();
        await page.locator('#stage [data-item-related] [data-media-card]').first().waitFor();
        const measured = await page.locator('[data-detail-continuation]').evaluate(node => {
          const queue = node.querySelector('[data-mix-queue]')!.getBoundingClientRect();
          const cards = [...node.querySelectorAll('[data-queue-row]')].map(card => card.getBoundingClientRect());
          const related = node.querySelector('[data-item-related] [data-media-card]')!.getBoundingClientRect();
          return { width: queue.width, full: node.getBoundingClientRect().width, cards: cards.map(card => ({ width: card.width, y: card.y })), related: { width: related.width, y: related.y } };
        });
        const width = measured.cards[0]!.width;
        if (count < 3) assert.ok(Math.abs(measured.width - (count * width + (count - 1) * 10 + 28)) < 1, JSON.stringify(measured));
        else assert.ok(Math.abs(measured.width - measured.full / 2) < 1, JSON.stringify(measured));
        assert.ok(measured.cards.every(card => Math.abs(card.width - measured.related.width) < 1 && Math.abs(card.y - measured.related.y) < 1), JSON.stringify(measured));
        if (count === 2) await page.screenshot({ path: `../build/detail-two-${kind}.png` });
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally { await opened.close() }
    });
  }

  for (const destination of ['/tasks', '/performers']) {
    it(`分卷等待响应时后退到 ${destination}:舞台撤回,晚到的响应不能改变地址或历史深度`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, '/', DESKTOP, { ready: PART_CARD, catalogIds: [...CATALOG, ITEM.part] });
      let release = () => {};
      try {
        const page = opened.page;
        await page.evaluate(async (path) => {
          const moduleUrl = '/dist/peach-app.js';
          const { peachHistory } = await import(moduleUrl);
          peachHistory.push(path);
        }, destination);
        await pathIs(page, destination);
        await page.evaluate(async () => { const moduleUrl = '/dist/peach-app.js'; (await import(moduleUrl)).peachHistory.push('/') });
        await page.locator(PART_CARD).waitFor();
        const depth = await page.evaluate(() => history.length);
        let arrived = () => {};
        const requested = new Promise<void>(resolve => { arrived = resolve });
        const gate = new Promise<void>(resolve => { release = resolve });
        let finished = () => {};
        const responded = new Promise<void>(resolve => { finished = resolve });
        await page.route((url) => url.pathname === '/api/parts', async route => {
          arrived();
          await gate;
          try { await route.fallback() }
          catch (error) { assert.match(route.request().failure()?.errorText || '', /ERR_ABORTED/, String(error)) }
          finally { finished() }
        });
        await page.locator(PART_CARD).click();
        await requested;
        await page.locator('#stage[open]').waitFor();
        assert.equal(await page.evaluate(() => location.pathname), '/');
        assert.equal(await page.evaluate(() => history.length), depth, '队列未取齐时没有临时历史条目');
        await page.goBack();
        await pathIs(page, destination);
        await page.waitForFunction(() => !document.querySelector('#stage[open]'));
        const seq = await page.evaluate(async () => { const moduleUrl = '/dist/peach-app.js'; return (await import(moduleUrl)).peachHistory.navigation.seq });
        release();
        await responded;
        await page.waitForTimeout(500);
        assert.equal(await page.locator('#stage[open]').count(), 0);
        assert.equal(await page.evaluate(() => location.pathname), destination);
        assert.equal(await page.evaluate(() => history.length), depth);
        assert.equal(await page.evaluate(async () => { const moduleUrl = '/dist/peach-app.js'; return (await import(moduleUrl)).peachHistory.navigation.seq }), seq);
        assert.deepEqual(opened.stub.writes, []);
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally { release(); await opened.close() }
    });
  }

  it('点分卷的同一任务里重放源页 POP:队列意图不取数、不铺舞台、不写条目', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, '/', DESKTOP, { ready: PART_CARD, catalogIds: [...CATALOG, ITEM.part] });
    try {
      const page = opened.page;
      const depth = await page.evaluate(() => history.length);
      await page.locator(PART_CARD).evaluate(node => {
        (node as HTMLElement).click();
        window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
      });
      await page.waitForTimeout(500);
      assert.equal(opened.stub.reads.get('/api/parts') || 0, 0);
      assert.equal(await page.locator('#stage[open]').count(), 0);
      assert.deepEqual(await page.evaluate(() => [location.pathname, history.length]), ['/', depth]);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally { await opened.close() }
  });

  it('首次舞台宿主接上时重放源页 POP:包已到货的旧队列请求也不能开舞台', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, '/', DESKTOP, { ready: PART_CARD, catalogIds: [...CATALOG, ITEM.part] });
    try {
      const page = opened.page;
      assert.equal(await page.locator('[data-stage-host]').count(), 0);
      const depth = await page.evaluate(() => history.length);
      await page.evaluate(() => {
        const append = document.body.append.bind(document.body);
        document.body.append = (...nodes: (Node | string)[]) => {
          append(...nodes);
          if (nodes.some(node => node instanceof HTMLElement && node.hasAttribute('data-stage-host'))) {
            document.body.append = append;
            document.body.dataset.queueStagePop = '1';
            window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
          }
        };
      });
      await page.locator(PART_CARD).click();
      await page.locator('body[data-queue-stage-pop]').waitFor({ state: 'attached' });
      await page.waitForTimeout(500);
      assert.equal(opened.stub.reads.get('/api/parts') || 0, 0);
      assert.equal(await page.locator('#stage[open]').count(), 0);
      assert.deepEqual(await page.evaluate(() => [location.pathname, history.length]), ['/', depth]);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally { await opened.close() }
  });

  it('目录点卡进详情:地址换成这一条;关掉回列表不重取', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, '/', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` });
    try {
      const page = opened.page;
      await page.emulateMedia({reducedMotion:'no-preference'});
      await page.route(/\/api\/item\?/, async route => {
        await new Promise(resolve => setTimeout(resolve, 400));
        await route.fallback();
      });
      await page.evaluate(() => {
        const heights: number[] = [];
        Object.assign(window, { coldCatalogHeights: heights });
        const sample = () => {
          const dialog = document.querySelector<HTMLDialogElement>('#stage[open]');
          if (dialog) heights.push(dialog.clientHeight);
          if (heights.length < 180) requestAnimationFrame(sample);
        }; requestAnimationFrame(sample);
      });
      const listed = opened.stub.reads.get('/api/items') || 0;
      const backgroundPlayState=()=>page.locator('.top .search').first().evaluate(
        node=>getComputedStyle(node).animationPlayState);
      const initialPlayState=await backgroundPlayState();
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().click();
      await pathIs(page, `/assets/${ITEM.plain}`);
      await page.locator(DETAIL_READY).waitFor();
      const heights = await page.evaluate(() => (window as unknown as { coldCatalogHeights: number[] }).coldCatalogHeights);
      assert.ok(heights.length > 5 && Math.max(...heights) - Math.min(...heights) < 1, JSON.stringify(heights));
      assert.ok((await backgroundPlayState()).split(',').every(state=>state.trim()==='paused'));
      assert.equal(await page.evaluate(() => document.activeElement?.id), 'closeStage', '内容到了焦点不在关闭键上');
      await page.locator('#closeStage').click();
      await pathIs(page, '/');
      await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
      assert.equal(await page.locator('#stage[open]').count(), 0);
      assert.equal(await backgroundPlayState(),initialPlayState,'关闭详情恢复背景动画状态');
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor();
      assert.equal(opened.stub.reads.get('/api/items') || 0, listed, '关掉详情回列表重取了一遍');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('深链进详情:单条取这一条;关掉之后照地址补画列表', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      assert.equal(opened.stub.reads.get('/api/item'), 1);
      await page.locator('#closeStage').click();
      await pathIs(page, '/');
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor({ timeout: 15_000 });
      assert.ok((opened.stub.reads.get('/api/items') || 0) >= 1, '深链关掉之后没有补取列表');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('后退回到详情再关掉:回到这一条打开时压着的那一页,地址与画面一致', { timeout: 90_000 }, async () => {
    const opened = await openItemPage(browser, '/trash', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` });
    try {
      const page = opened.page;
      const openCard = async (id: number) => {
        await page.locator(`#grid [data-media-card][data-id="${id}"] [data-media-title]`).first().click();
        await pathIs(page, `/assets/${id}`);
        await page.locator(DETAIL_READY).waitFor();
      };
      await openCard(ITEM.plain);
      await page.locator('#closeStage').click();
      await pathIs(page, '/trash');
      await page.locator('[data-nav=""]:visible').first().click();
      await pathIs(page, '/');
      await page.locator(`#grid [data-media-card][data-id="${ITEM.cast}"]`).first().waitFor({ timeout: 15_000 });
      await openCard(ITEM.cast);
      for (const path of ['/', '/trash', `/assets/${ITEM.plain}`]) {
        await page.goBack();
        await pathIs(page, path);
      }
      await page.locator(DETAIL_READY).waitFor();
      await page.locator('#closeStage').click();
      await pathIs(page, '/trash');
      await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
      assert.equal(await page.locator('#stage[open]').count(), 0);
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor();
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('刷新落在详情上不读条目记的来处:下面补画目录网格,关掉回首页', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, '/trash', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` });
    try {
      const page = opened.page;
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().click();
      await pathIs(page, `/assets/${ITEM.plain}`);
      await page.locator(DETAIL_READY).waitFor();
      assert.deepEqual(await page.evaluate(() => history.state?.usr?.backgroundLocation), { pathname: '/trash', search: '' });
      await page.reload({ waitUntil: 'load' });
      await page.locator(DETAIL_READY).waitFor({ timeout: 15_000 });
      await page.locator('#closeStage').click();
      await pathIs(page, '/');
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor({ timeout: 15_000 });
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('播放列表里换条再关掉回到列表页并重读;后退回到其中一条再关掉也一样', { timeout: 90_000 }, async () => {
    const opened = await openItemPage(browser, '/', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` });
    try {
      const page = opened.page;
      let listed = 0;
      // 列表摘要反映成功写入的续播点,关闭后卡片与再次打开都应使用它。
      await page.route((url) => url.pathname === '/api/playlists', (route) => {
        listed += 1;
        const progress = opened.stub.writes.findLast(write => write.url === '/api/playlist'
          && write.body.action === 'progress' && write.body.id === PLAYLIST.id);
        const current = Number(progress?.body.asset_id || PLAYLIST.items[0]);
        return route.fulfill({ json: { items: [{
          id: PLAYLIST.id, name: PLAYLIST.name, item_count: PLAYLIST.items.length,
          current_asset_id: current, preview_asset_id: current,
        }] } });
      });
      await page.goto(new URL('/playlists', page.url()).toString(), { waitUntil: 'load' });
      const card = page.locator(`[data-playlist-card="${PLAYLIST.id}"]`);
      const step = async (id: number) => {
        await page.locator(`#stage [data-queue-item="${id}"]`).click();
        await pathIs(page, `/assets/${id}?playlist=${PLAYLIST.id}`);
        await page.locator(`#stage [data-queue-item="${id}"][aria-current="true"]`).waitFor();
      };
      const enter = async (resume: number) => {
        await card.locator('[data-mix-open]').click();
        await pathIs(page, `/assets/${resume}?playlist=${PLAYLIST.id}`);
        await page.locator(DETAIL_READY).waitFor();
        await step(PLAYLIST.items[1]!);
        await step(PLAYLIST.items[2]!);
      };
      const closeToList = async (resume: number) => {
        const before = listed;
        await page.locator('#closeStage').click();
        await pathIs(page, '/playlists');
        await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
        await card.waitFor();
        assert.equal(await page.locator('#stage[open]').count(), 0);
        for (const deadline = Date.now() + 10_000; listed <= before && Date.now() < deadline;) await page.waitForTimeout(50);
        assert.ok(listed > before, '关掉之后列表页没有重读');
        await card.locator(`img[src*="id=${resume}&"]`).first().waitFor();
      };
      await card.waitFor({ timeout: 15_000 });
      await enter(PLAYLIST.items[0]!);
      await closeToList(PLAYLIST.items[2]!);
      await enter(PLAYLIST.items[2]!);
      await page.goBack();
      await pathIs(page, `/assets/${PLAYLIST.items[1]}?playlist=${PLAYLIST.id}`);
      await page.locator(`#stage [data-queue-item="${PLAYLIST.items[1]}"][aria-current="true"]`).waitFor();
      await page.locator(DETAIL_READY).waitFor();
      await closeToList(PLAYLIST.items[1]!);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('舞台在媒体框里挂上 Video.js,氛围光画布与统计角标跟它进同一块媒体区', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#stage [data-item-media="video"] > .video-js video.vjs-tech').waitFor({ state: 'attached' });
      const shape = await page.evaluate(() => {
        const media = document.querySelector('#stage [data-item-media="video"]')!;
        return { id: media.querySelector(':scope > .video-js')!.id, canvas: !!media.querySelector(':scope > [data-ambient-canvas]'),
          stats: !!media.querySelector('#playerStatsBtn'), videos: media.querySelectorAll('video').length };
      });
      assert.deepEqual(shape, { id: 'vid', canvas: true, stats: true, videos: 1 });
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('来源不可读取:说明块代替播放器,刷新状态后显示读取结果', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.offline}`, DESKTOP);
    try {
      const page = opened.page;
      const gate = page.locator('#stage [data-item-gate="offline"]');
      assert.match(await gate.innerText(), /媒体目录无法访问/);
      assert.equal(await page.locator('#stage .vjs-tech').count(), 0, '脱盘的条目挂了播放器');
      await page.locator('#offlineRetry').click();
      await page.locator('#offlineRetry', { hasText: '仍无法读取 · 刷新' }).waitFor();
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of [DESKTOP, MOBILE]) {
    it(`${viewport.mobile ? '手机' : '桌面'}来源状态:权限原因可见,未取得状态不判脱盘`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, `/assets/${ITEM.offline}`, viewport);
      try {
        const page = opened.page;
        let online: boolean | null = false;
        await page.route((url) => url.pathname === '/api/sources', (route) => route.fulfill({ json: {
          sources: [{ location: 'local', online: true }, { location: '115', online,
            state: online === false ? 'permission_denied' : 'timeout',
            message: online === false ? 'CloudDrive · 115:没有权限读取' : 'CloudDrive · 115:探测未返回' }],
        } }));
        await page.reload({ waitUntil: 'load' });
        await page.locator(DETAIL_READY).waitFor();
        assert.match(await page.locator('#stage [data-item-gate="offline"]').innerText(), /没有权限读取/);
        online = null;
        await page.reload({ waitUntil: 'load' });
        await page.locator(DETAIL_READY).waitFor();
        assert.equal(await page.locator('#stage [data-item-gate="offline"]').count(), 0);
        assert.equal(await page.locator('body.offline-source').count(), 0);
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally {
        await opened.close();
      }
    });
  }

  for (const viewport of [DESKTOP, MOBILE]) {
  it(`${viewport.name}:出演超过 8 位先收起,点「还有 N 位」全部展开`, { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.cast}`, viewport);
    try {
      const page = opened.page;
      const shown = () => page.locator('#stage [data-item-identity] [data-entity-kind="performer"]:visible').count();
      assert.equal(await shown(), 8);
      assert.equal((await page.locator('#castMore').innerText()).trim(), `还有 ${NAMES.length - 8} 位`);
      const button = page.getByRole('button', { name: `还有 ${NAMES.length - 8} 位` });
      const appearance = await button.evaluate((node) => {
        const style = getComputedStyle(node);
        return { border: style.borderTopStyle, width: parseFloat(style.borderTopWidth),
          background: style.backgroundColor, icon: !!node.querySelector('svg'), height: node.getBoundingClientRect().height };
      });
      assert.equal(appearance.border, 'solid');
      assert.ok(appearance.width >= 1);
      assert.notEqual(appearance.background, 'rgba(0, 0, 0, 0)');
      assert.equal(appearance.icon, true);
      assert.ok(appearance.height >= 32);
      const centers = await button.evaluate((node) => {
        const own = node.getBoundingClientRect();
        const siblings = [...node.parentElement!.querySelectorAll<HTMLElement>('[data-id-cell]:not([hidden])')]
          .map((cell) => cell.getBoundingClientRect()).filter((cell) => cell.top < own.bottom && cell.bottom > own.top);
        return { margin: getComputedStyle(node).marginTop,
          delta: siblings.map((cell) => own.top + own.height / 2 - cell.top - cell.height / 2) };
      });
      assert.equal(centers.margin, '0px', '展开按钮应在出演名单行内垂直居中');
      assert.ok(centers.delta.every((delta) => Math.abs(delta) < 1), JSON.stringify(centers));
      await button.focus();
      await page.keyboard.press('Enter');
      assert.equal(await shown(), NAMES.length);
      assert.equal(await page.locator('#castMore').count(), 0);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });
  }

  it('标题折成两行,真溢出才给展开键;展开键与标题文字都能展开,再点收回', { timeout: 60_000 }, async () => {
    const short = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      assert.equal(await short.page.locator('#stage [data-title-fold]').isHidden(), true);
    } finally {
      await short.close();
    }
    const opened = await openItemPage(browser, `/assets/${ITEM.long}`, DESKTOP);
    try {
      const page = opened.page;
      const title = page.locator('#stage [data-detail-title]');
      const fold = page.locator('#stage [data-title-fold]');
      const lines = () => title.evaluate((node) => Math.round(node.clientHeight / parseFloat(getComputedStyle(node).lineHeight)));
      assert.equal(await fold.isVisible(), true);
      assert.equal(await lines(), 2);
      await fold.click();
      assert.equal(await fold.getAttribute('aria-label'), '收起标题');
      assert.ok(await lines() > 2, '展开之后整段标题都在');
      await title.click();
      assert.equal(await fold.getAttribute('aria-label'), '展开标题');
      assert.equal(await lines(), 2);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('评分:点当前那一颗是撤销评分;回执的撤销写回原分', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      const value = page.locator('#detailRating [data-rating-value]');
      assert.equal((await value.innerText()).trim(), '3 星');
      await page.locator('#detailRating [data-rate="3"]').click();
      await page.locator('#detailRating [data-rating-value]', { hasText: '未评分' }).waitFor();
      assert.equal(await page.locator('#detailRating [data-on="true"]').count(), 0);
      await undo(page);
      await page.locator('#detailRating [data-rating-value]', { hasText: '3 星' }).waitFor();
      assert.deepEqual(opened.stub.writes.filter((write) => write.url === '/api/feedback').map((write) => write.body), [
        { id: ITEM.plain, kind: 'rate', value: 0 },
        { id: ITEM.plain, kind: 'rate', value: 60 },
      ]);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('标签:删一枚再撤销回来;从选择器新建一枚', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      const tag = (name: string) => page.locator(`#detailTags [data-tag="${name}"]`);
      await page.locator('#detailTags [data-remove-tag="剧情"]').click();
      await tag('剧情').waitFor({ state: 'detached' });
      await undo(page);
      await tag('剧情').waitFor();
      await page.locator('#tagPlus').click();
      await page.locator('#tagPicker:not([hidden])').waitFor();
      await page.locator('#tagPickSearch').fill('新建的标签');
      await page.locator('#tagPicker [data-pick="新建的标签"]', { hasText: '新建“新建的标签”' }).waitFor();
      await page.locator('#tagPickSearch').press('Enter');
      await tag('新建的标签').waitFor();
      assert.equal(await page.locator('#tagPicker').isHidden(), true, '加完标签选择器没有收起');
      assert.deepEqual(opened.stub.writes.filter((write) => write.url === '/api/item-tag').map((write) => write.body), [
        { id: ITEM.plain, operation: 'remove', tag: '剧情' },
        { id: ITEM.plain, operation: 'add', tag: '剧情' },
        { id: ITEM.plain, operation: 'add', tag: '新建的标签' },
      ]);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of [DESKTOP, MOBILE]) {
    it(`${viewport.name}详情动作保持单行,提示显示在浮窗顶层`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, viewport);
      try {
        const page = opened.page;
        const actions = page.locator('#stage [data-item-feedback] button');
        const rects = await actions.evaluateAll(nodes => nodes.map(node => {
          const r = node.getBoundingClientRect(); return { top: r.top, width: r.width };
        }));
        assert.equal(rects.length, 8);
        assert.equal(await page.locator('#stage [data-fb="cloud-download"]').count(), 0);
        assert.ok(Math.max(...rects.map(r => r.top)) - Math.min(...rects.map(r => r.top)) <= 1);
        assert.ok(rects.every(r => r.width >= 30));
        await actions.first().scrollIntoViewIfNeeded();
        await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
        await actions.first().focus();
        const tip = page.locator('#board-control-tooltip');
        await tip.waitFor({state:'visible'});
        assert.equal(await tip.evaluate(node => node.matches(':popover-open')), true);
        assert.equal(await tip.evaluate(node => node.closest('dialog')?.id), 'stage');
        const r = await tip.boundingBox();
        assert.ok(r && r.x >= 0 && r.y >= 0 && r.x + r.width <= viewport.width && r.y + r.height <= viewport.height);
        await actions.first().evaluate(node => (node as HTMLElement).blur());
        assert.equal(await tip.isHidden(), true);
      } finally { await opened.close() }
    });
  }

  it('稍后看与反馈键:写完键态跟着服务端的回话变', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#stageLater').click();
      await page.locator('#stageLater[aria-pressed="true"]').waitFor();
      await page.locator('#stage [data-kind="dislike"]').click();
      await page.locator('#stage [data-kind="dislike"][aria-pressed="true"]').waitFor();
      await page.locator('#stage [data-kind="seen"]').click();
      await page.locator('#stage [data-kind="seen"][aria-pressed="true"]').waitFor();
      assert.equal(await page.locator('#stage [data-kind="dislike"]').getAttribute('aria-pressed'), 'false', '看过与不合口味同时亮着');
      assert.deepEqual(opened.stub.writes.map((write) => [write.url, write.body.kind ?? null]), [
        ['/api/watch-later', null], ['/api/feedback', 'dislike'], ['/api/feedback', 'seen'],
      ]);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of [DESKTOP, MOBILE]) {
    it(`${viewport.name}完整文件与修复分卷共用一张卡,版本队列只续播同版下一卷`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, '/', viewport, { catalogIds: [ITEM.edition], ready: '#grid [data-media-card]' });
      try {
        const page = opened.page;
        const full = await page.evaluate(async id => (await fetch(`/api/item?id=${id}`)).json(), ITEM.edition);
        const repair = await page.evaluate(async () => (await fetch('/api/item?id=32')).json());
        const edition_group = { key: 'PCH-031', seed_id: 31, count: 2, editions: ['1080p', '4K 修复'] };
        const part_group = { key: 'PCH-031:32', seed_id: 32, count: 2 };
        const files = [
          { ...full, edition_group, edition_label: '1080p', version_id: 31 },
          { ...repair, edition_group, part_group, edition_label: '4K 修复', version_id: 32, part_label: '1' },
          { ...repair, id: 33, edition_group, part_group, edition_label: '4K 修复', version_id: 32, part_label: '2' },
        ];
        await page.route(url => url.pathname === '/api/item', route => {
          const file = files.find(row => row.id === Number(new URL(route.request().url()).searchParams.get('id')));
          return file ? route.fulfill({ json: file }) : route.fallback();
        });
        await page.route(url => url.pathname === '/api/editions', route => route.fulfill({ json: { title: 'PCH-031', count: 2, items: files } }));
        await page.route(url => url.pathname === '/api/items', route => route.fulfill({ json: {
          items: [files[2], files[0], files[1]].map(row => ({ ...row, tags: row.tags.map((tag: { k: string }) => tag.k) })),
          total: 3, work_total: 1, has_more: false,
        } }));
        await page.reload({ waitUntil: 'load' });
        const card = page.locator('#grid [data-media-card][data-variant="grid"]');
        await card.first().waitFor();
        assert.equal(await card.count(), 1);
        assert.deepEqual(await card.locator('[data-media-group]').allTextContents(), ['2 个版本']);
        await card.locator('[data-media-title]').click();
        await pathIs(page, '/assets/33?editions=31');
        await page.locator('#stage [data-queue-item="33"][aria-current="true"]').waitFor();
        assert.equal(await page.locator('#stage [data-queue-item]').count(), 3);
        assert.equal(await page.locator('#stage').getByText('2 个版本', { exact: true }).count(), 1);
        await page.locator('#stage [data-queue-item="32"]').click();
        await page.locator('#stage [data-queue-item="32"][aria-current="true"]').waitFor();
        await page.locator('#stage video.vjs-tech').dispatchEvent('ended');
        await pathIs(page, '/assets/33?editions=31');
        await page.locator('#stage [data-queue-item="33"][aria-current="true"]').waitFor();
        await page.locator('#stage video.vjs-tech').dispatchEvent('ended');
        assert.equal(new URL(page.url()).pathname + new URL(page.url()).search, '/assets/33?editions=31');
        const bounds = await layout(page);
        assert.ok(bounds.scrollWidth <= bounds.viewportWidth + 1, JSON.stringify(bounds));
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally {
        await opened.close();
      }
    });
  }

  for (const [kind, path, next, wanted] of [
    ['Mix', `/assets/${ITEM.plain}?mix=${ITEM.plain}`, 14, `/assets/14?mix=${ITEM.plain}`],
    ['分卷', `/assets/22?parts=${ITEM.part}`, 23, `/assets/23?parts=${ITEM.part}`],
    ['版本', `/assets/${ITEM.edition}?editions=${ITEM.edition}`, 32, `/assets/32?editions=${ITEM.edition}`],
    ['播放列表', `/assets/11?playlist=${PLAYLIST.id}`, 15, `/assets/15?playlist=${PLAYLIST.id}`],
  ] as const) {
    it(`${kind}队列:深链停在给的那一条,点另一条原地换过去且不重取队列`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, path, DESKTOP);
      try {
        const page = opened.page;
        assert.equal(await current(page), new URL(path, page.url()).pathname.split('/').at(-1));
        const reads = new Map(opened.stub.reads);
        await watchStage(page);
        await page.locator(`#stage [data-queue-item="${next}"]`).click();
        await pathIs(page, wanted);
        await page.locator(`#stage [data-queue-item="${next}"][aria-current="true"]`).waitFor();
        await page.locator(DETAIL_READY).waitFor();
        assert.deepEqual(await stageKept(page), { same: true, skeleton: false, focused: true }, `${kind}队列换一条重开了浮窗`);
        if (kind === '分卷') assert.match(await page.locator('#stage [data-detail-title]').innerText(), /第 3 卷/);
        for (const url of ['/api/parts', '/api/editions', '/api/related']) {
          assert.equal(opened.stub.reads.get(url) || 0, reads.get(url) || 0, `同一个${kind}队列里换一条又取了 ${url}`);
        }
        if (kind === '播放列表') {
          assert.deepEqual(opened.stub.writes.filter((write) => write.body.action === 'progress').map((write) => write.body.asset_id),
            [11, next], '播放列表没有记下续播位置');
        }
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally {
        await opened.close();
      }
    });
  }

  it('播放列表:拖动换顺序、移出一条,两者都能撤销', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/11?playlist=${PLAYLIST.id}`, DESKTOP);
    try {
      const page = opened.page;
      const order = () => ids(page, '#stage [data-queue-row]', 'data-queue-row');
      const wait = (wanted: number[]) => page.waitForFunction((want) => JSON.stringify([...document
        .querySelectorAll('#stage [data-queue-row]')].map((row) => Number(row.getAttribute('data-queue-row')))) === want,
      JSON.stringify(wanted), { timeout: 10_000 });
      assert.deepEqual(await order(), PLAYLIST.items);
      await page.locator('#stage [data-queue-row="16"]').dragTo(page.locator('#stage [data-queue-row="14"]'),
        { targetPosition: { x: 4, y: 2 } });
      await wait([11, 16, 14, 15]);
      await undo(page);
      await wait(PLAYLIST.items);
      await page.locator('#stage [data-queue-remove="15"]').click();
      await page.locator('[data-modal-confirm]').click();
      await wait([11, 14, 16]);
      await undo(page);
      await wait(PLAYLIST.items);
      assert.deepEqual(opened.stub.writes.filter((write) => write.body.action !== 'progress')
        .map((write) => [write.body.action, write.body.asset_ids ?? write.body.asset_id]), [
        ['reorder', [11, 16, 14, 15]], ['reorder', PLAYLIST.items],
        ['remove', 15], ['add', [15]], ['reorder', PLAYLIST.items],
      ]);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('保存 Mix:确认之后转去新建的那份播放列表', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}?mix=${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#stage [data-save-mix]').click();
      await page.locator('[name="name"]').fill('我的 Mix');
      await page.locator('[data-modal-confirm]').click();
      await page.waitForFunction(() => location.pathname.startsWith('/assets/') && new URLSearchParams(location.search).get('playlist') === '8', undefined, { timeout: 10_000 });
      await page.locator('#stage [data-queue-row]').first().waitFor();
      assert.deepEqual(await ids(page, '#stage [data-queue-row]', 'data-queue-row'), [ITEM.plain, ...RELATED]);
      const created = opened.stub.writes.find((write) => write.body.action === 'create')!;
      assert.deepEqual(created.body, {
        action: 'create', name: '我的 Mix', asset_ids: [ITEM.plain, ...RELATED], source_kind: 'mix',
        source_seed_asset_id: ITEM.plain,
      });
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('接着看:一排卡按相关作品的顺序;点一张原地换到那一条', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#nrow [data-media-card]').first().waitFor();
      assert.deepEqual(await ids(page, '#nrow [data-media-card]', 'data-id'), RELATED);
      const before = await page.locator('#stage [data-detail-title]').innerText();
      await watchStage(page);
      await page.locator(`#nrow [data-media-card][data-id="${RELATED[0]}"] [data-media-title]`).click();
      await pathIs(page, `/assets/${RELATED[0]}`);
      await page.waitForFunction((title) => {
        const now = document.querySelector('#stage [data-detail-title]');
        return !!now && (now as HTMLElement).innerText !== title;
      }, before, { timeout: 10_000 });
      await page.locator(DETAIL_READY).waitFor();
      assert.deepEqual(await stageKept(page), { same: true, skeleton: false, focused: true }, '接着看换一条重开了浮窗');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('点女优进她的实体页,舞台收起', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.route(url => url.pathname === '/api/entity', route => {
        assert.equal(new URL(route.request().url()).searchParams.get('id'), '1');
        return route.fulfill({ json: {
          id: 1, kind: 'performer', canonical_name: NAMES[0], aliases: [], display_aliases: [], user_aliases: [],
          asset_count: 1, tags: [], related_performers: [], links: [], metadata: {},
          has_image: false, has_avatar: false, representative_asset_id: null, entry_links: [],
        } });
      });
      await page.locator(`#stage [data-item-identity] [data-entity-kind="performer"][data-entity-name="${NAMES[0]}"]`).click();
      await pathIs(page, '/performers/1');
      await page.locator('#index [data-entity-title] h2').filter({ hasText: NAMES[0] }).waitFor();
      await page.waitForFunction(() => !document.querySelector('#stage[open]'), undefined, { timeout: 10_000 });
      assert.equal(await page.evaluate(() => document.body.classList.contains('entity-open')), true);
    } finally {
      await opened.close();
    }
  });

  it('取不到这一条:播放器格照 16:9 撑开,重试提示居中,不塌成一条窄条', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.route((url) => url.pathname === '/api/item', (route) => route.fulfill({ status: 500, json: { error: 'database is locked' } }));
      await page.goto(new URL('/assets/987654', page.url()).toString(), { waitUntil: 'load' });
      await page.locator('#stage [data-item-placeholder] [data-media-error]').waitFor({ timeout: 15_000 });
      const box = await page.evaluate(() => {
        const frame = document.querySelector('#stage [data-item-placeholder]')!.getBoundingClientRect();
        const note = document.querySelector('#stage [data-item-placeholder] [data-media-error]')!.getBoundingClientRect();
        return { ratio: frame.width / frame.height, offset: Math.abs((note.top + note.bottom) / 2 - (frame.top + frame.bottom) / 2) };
      });
      assert.ok(Math.abs(box.ratio - 16 / 9) < 0.02, `播放器格不是 16:9:${JSON.stringify(box)}`);
      assert.ok(box.offset < 2, `提示没有竖直居中:${JSON.stringify(box)}`);
    } finally {
      await opened.close();
    }
  });

  it('长标签单行省略,高度和别的标签一样', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, MOBILE);
    try {
      const page = opened.page;
      const heights = await page.evaluate(() => {
        const tag = document.querySelector('#detailTags [data-tag]') as HTMLElement;
        const before = tag.getBoundingClientRect().height;
        tag.textContent = 'customer-feedback-from-enterprise-onboarding-customer-feedback-from-enterprise-onboarding';
        return { before, after: tag.getBoundingClientRect().height, wide: tag.scrollWidth > tag.clientWidth };
      });
      assert.equal(heights.after, heights.before, `长标签折行了:${JSON.stringify(heights)}`);
      assert.equal(heights.wide, true, '长标签应当被省略');
      assert.deepEqual((await layout(page)).offenders, [], '长标签撑出视口');
    } finally {
      await opened.close();
    }
  });

  it('桩片源的连续解码错误不消耗真实诊断预算,其他错误继续下传', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const forwarded: unknown[] = [];
      await opened.page.context().route((url) => url.pathname === '/api/diagnostics/client-error', (route) => {
        forwarded.push(route.request().postDataJSON());
        return route.fulfill({ json: { recorded: false } });
      });
      const replies = await opened.page.evaluate(async () => {
        const send = async (kind: string, source_route: string, message = '夹具错误') => (await fetch('/api/diagnostics/client-error', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ kind, message, playback: { source_route } }),
        })).json();
        const ambient = await send('media', '/stream', '并发播放器错误');
        const media = await Promise.all(Array.from({ length: 21 }, () => send('media', '/stream')));
        return { ambient, media, other: await send('error', '/stream'), external: await send('media', '/external') };
      });
      assert.deepEqual(replies.ambient, { recorded: true });
      assert.deepEqual(replies.media, Array.from({ length: 21 }, () => ({ recorded: true })));
      assert.equal(opened.stub.mediaErrors.filter((sent) => sent.message === '夹具错误').length, 21);
      assert.deepEqual(replies.other, { recorded: false });
      assert.deepEqual(replies.external, { recorded: false });
      assert.deepEqual(forwarded, [
        { kind: 'error', message: '夹具错误', playback: { source_route: '/stream' } },
        { kind: 'media', message: '夹具错误', playback: { source_route: '/external' } },
      ]);
    } finally { await opened.close() }
  });

  it('手机 390:详情不横向溢出,侧栏排在播放区下面', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, MOBILE);
    try {
      const page = opened.page;
      assert.deepEqual((await layout(page)).offenders, [], '详情里有元素越出视口右边');
      const boxes = await page.evaluate(() => ({
        media: document.querySelector('#stage [data-item-media]')!.getBoundingClientRect().bottom,
        side: document.querySelector('#stage [data-item-side]')!.getBoundingClientRect().top,
      }));
      assert.ok(boxes.side >= boxes.media - 1, `侧栏不在播放区下面:${JSON.stringify(boxes)}`);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });
});