Ctrl K
ComunidadGuías…

frontend/e2e/design-detail.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 设计决定:关注详情、作品详情、播放器与侧栏。读 `getComputedStyle` 断言用户定过的外观;共用的桩与浏览器生命周期在 `design-fixture.ts`。 */
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { Browser, Page } from 'playwright-core';
import { DETAIL, openFollowFeed } from './follow-fixture.ts';
import { settle, visit, VIEWPORTS, type Viewport, type Visit } from './harness.ts';
import { muteVideos, openImmerse, pickClips, pinQueue, settledClip } from './immerse-fixture.ts';
import { ITEM, openItemPage } from './item-fixture.ts';
import { DESKTOP, MOBILE, tokenColor, installDesignBrowser } from './design-fixture.ts';

describe('设计决定:关注详情、作品详情、播放器与侧栏', () => {
  let browser: Browser;
  installDesignBrowser((next) => { browser = next; });

  it('关注详情翻页圆钮固定两侧 11px;桌面 48px、手机 44px,导航条位于画面下方', { timeout: 90_000 }, async () => {
    const read = (page: Page) => page.evaluate(() => {
      const frame = document.querySelector<HTMLElement>('#stage [data-follow-detail-media="image"]')!;
      const next = frame.querySelector('[data-follow-image-arrow="next"]')!, style = getComputedStyle(next);
      const outer = frame.getBoundingClientRect(), box = next.getBoundingClientRect();
      const indicator = frame.querySelector('[data-media-indicator]')!.getBoundingClientRect();
      return {
        size: [box.width, box.height], background: style.backgroundColor, blur: style.backdropFilter,
        inset: outer.right - box.right, indicatorBottom: outer.bottom - indicator.bottom,
        framed: frame.hasAttribute('data-framed'),
        ratio: getComputedStyle(frame.querySelector('[data-follow-detail-poster]')!).aspectRatio,
      };
    });
    const settlePoster = async (page: Page) => {
      await page.waitForFunction(() => {
        const image = document.querySelector<HTMLImageElement>('#stage [data-follow-detail-poster]');
        return image?.complete && image.naturalWidth > 0;
      });
      // 图像解码与布局就绪后读取几何。
      await page.evaluate(() => new Promise<void>(resolve => {
        requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
      }));
    };
    const wide = await openFollowFeed(browser, `/posts/${DETAIL.gallery}`, DESKTOP,
      { ready: '#stage [data-follow-image-arrow="next"]' });
    try {
      await settlePoster(wide.page);
      const shown = await read(wide.page);
      assert.deepEqual(shown.size, [48, 48]);
      assert.equal(shown.background, 'rgba(0, 0, 0, 0.6)');
      assert.equal(shown.blur, 'blur(16px)');
      assert.ok(Math.abs(shown.inset - 11) <= 1, `箭头内距:${JSON.stringify(shown)}`);
      assert.ok(Math.abs(shown.indicatorBottom - 8) <= .5, `导航条底部留白 ${shown.indicatorBottom}px`);
      // 画框比例跟整组图走:换图时详情不忽高忽低。
      assert.equal(shown.framed, true);
      assert.match(shown.ratio, /^0\.75\b/);
      assert.deepEqual(wide.problems, []);
    } finally {
      await wide.close();
    }
    const narrow = await openFollowFeed(browser, `/posts/${DETAIL.gallery}`, MOBILE,
      { ready: '#stage [data-follow-image-arrow="next"]' });
    try {
      await settlePoster(narrow.page);
      const shown = await read(narrow.page);
      assert.deepEqual(shown.size, [44, 44]);
      assert.ok(Math.abs(shown.inset - 11) <= 1, `窄屏箭头内距:${JSON.stringify(shown)}`);
      assert.ok(Math.abs(shown.indicatorBottom - 8) <= .5, `窄屏导航条底部留白 ${shown.indicatorBottom}px`);
      assert.deepEqual(narrow.problems, []);
    } finally {
      await narrow.close();
    }
  });

  it('关注详情:无预览媒体占 16:9;标签保留来源分类并复用详情标签外观', { timeout: 60_000 }, async () => {
    const opened = await openFollowFeed(browser, `/posts/${DETAIL.bare}`, DESKTOP,
      { ready: '#stage [data-follow-detail-tags]' });
    try {
      const page = opened.page;
      const shown = await page.evaluate(() => ({
        placeholder: getComputedStyle(document.querySelector('#stage [data-follow-detail-placeholder]')!).aspectRatio,
        tags: Object.fromEntries([...document.querySelectorAll<HTMLElement>('#stage [data-follow-detail-tags] [data-follow-tag]')].map((tag) =>
          [tag.dataset.followTag, getComputedStyle(tag).getPropertyValue('--tag-color').trim()])),
      }));
      assert.equal(shown.placeholder, '16 / 9');
      const chip = await page.locator('#stage [data-detail-tag]').first().evaluate(node => {
        const style = getComputedStyle(node), probe = document.createElement('span');
        probe.style.backgroundColor = 'color-mix(in srgb,var(--tag-color) 10%,var(--surface))'; node.append(probe);
        const expected = getComputedStyle(probe).backgroundColor; probe.remove();
        return { fill: style.backgroundColor, expected, height: node.getBoundingClientRect().height };
      });
      assert.equal(chip.fill, chip.expected);
      assert.ok(chip.height >= 20 && chip.height <= 32);
      const muted = await tokenColor(page, '#stage [data-follow-detail]', '--muted');
      const unknown = await page.evaluate(() => {
        const tag = document.querySelector<HTMLElement>('#stage [data-follow-detail-tags] [data-follow-tag="odd"]')!;
        const probe = document.createElement('div');
        probe.style.backgroundColor = 'var(--tag-color)';
        tag.append(probe);
        const value = getComputedStyle(probe).backgroundColor;
        probe.remove();
        return value;
      });
      assert.deepEqual({ ...shown.tags, odd: unknown }, {
        ow: '#d675d6', tracer: '#68c76f', kou: '#e36c6c', solo: '#55a7ff', animated: '#f5a24a', odd: muted,
      });
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('关注详情标题:来源外链标与标题第一行同心,标题折行时仍贴第一行', { timeout: 60_000 }, async () => {
    const opened = await openFollowFeed(browser, `/posts/${DETAIL.gallery}`, DESKTOP,
      { ready: '#stage [data-follow-origin]' });
    try {
      const page = opened.page;
      const measure = () => page.evaluate(() => {
        const name = document.querySelector('#stage [data-follow-detail-name]')!;
        const mark = document.querySelector('#stage [data-follow-origin] svg')!.getBoundingClientRect();
        return { line: name.getBoundingClientRect().top + parseFloat(getComputedStyle(name).lineHeight) / 2,
          mark: (mark.top + mark.bottom) / 2 };
      });
      const single = await measure();
      assert.ok(Math.abs(single.mark - single.line) <= 0.5, `外链标与标题第一行不同心:${JSON.stringify(single)}`);
      await page.evaluate(() => { document.querySelector('#stage [data-follow-detail-name]')!.textContent = '很长的标题'.repeat(12) });
      const wrapped = await measure();
      assert.ok(Math.abs(wrapped.mark - wrapped.line) <= 0.5, `标题折行后外链标离开了第一行:${JSON.stringify(wrapped)}`);
    } finally {
      await opened.close();
    }
  });

  /* 作品详情(`item-detail` 岛)。桩数据见 `item-fixture.ts`:普通那一条评了 3 星、两位出演、
     一个厂牌一个系列,都带实体 id。桩里的片源没有正片,播放器那一条 VIDEOJS 错误不算。 */
  const withoutPlayer = (problems: string[]) => problems.filter((line) => !line.includes('VIDEOJS'));

  it('作品详情标题与评分:来源徽标站在标题第一行开头;整排星一种琥珀、评没评只看填不填,悬停预演到指针那一颗', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      const title = await page.evaluate(() => {
        const text = document.querySelector('#stage [data-detail-title]')!;
        const badge = text.querySelector('[class~="srcbig"]')!;
        const style = getComputedStyle(badge);
        const glyph = badge.querySelector('svg, img')!.getBoundingClientRect();
        const line = text.getBoundingClientRect().top + parseFloat(getComputedStyle(text).lineHeight) / 2;
        return {
          clamp: getComputedStyle(text).webkitLineClamp,
          first: text.firstElementChild === badge,
          badge: { display: style.display, width: style.width, height: style.height, gap: style.marginRight },
          centered: Math.abs((glyph.top + glyph.bottom) / 2 - line) <= 0.5,
          tools: document.querySelector('#stage [data-title-tools]')!.getBoundingClientRect().top
            >= text.getBoundingClientRect().bottom - 0.5,
        };
      });
      assert.deepEqual(title, {
        clamp: '2', first: true, badge: { display: 'inline-grid', width: '17px', height: '28px', gap: '8px' },
        centered: true, tools: true,
      }, '徽标是行内块、与第一行同心、随文字一起被两行折叠裁住;那排键自成一行排在标题下面');

      const stars = page.locator('#stage [data-rate]');
      const read = () => stars.evaluateAll((nodes) => nodes.map((node) => ({
        color: getComputedStyle(node).color, filled: getComputedStyle(node.querySelector('svg')!).fill !== 'none',
      })));
      const amber = await tokenColor(page, '#stage [data-item-detail]', '--rating');
      await page.mouse.move(0, 0);
      const rest = await read();
      assert.deepEqual(rest.map((star) => star.color), Array(5).fill(amber), '换色那一档在浅色主题下凑不出两级都成立的灰');
      assert.deepEqual(rest.map((star) => star.filled), [true, true, true, false, false]);
      await stars.nth(1).hover();
      const preview = await read();
      assert.deepEqual(preview.map((star) => star.color), Array(5).fill(amber));
      assert.deepEqual(preview.map((star) => star.filled), [true, true, false, false, false]);
      const edges = await page.evaluate(() => {
        const box = (selector: string) => document.querySelector(`#stage ${selector}`)!.getBoundingClientRect();
        return { stars: box('[data-rating-stars]').left, title: box('[data-detail-title]').left };
      });
      assert.equal(edges.stars, edges.title - 4, '26px 命中区里的星形靠右约 4px 起笔,这一排往左让回来');
      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 shown = await page.evaluate(() => {
        const stage = document.querySelector('#stage')!;
        const primary = stage.querySelector('[data-identity-primary]')!;
        const width = primary.getBoundingClientRect().width;
        const name = getComputedStyle(stage.querySelector('[data-id-group="performer"] [data-id-name]')!);
        const series = stage.querySelector('[data-series-link]')!;
        const link = getComputedStyle(series);
        return {
          primary: [getComputedStyle(primary).display, getComputedStyle(primary).flexWrap],
          narrower: [...primary.querySelectorAll('[data-id-group]')].every((group) => group.getBoundingClientRect().width < width),
          name: { ratio: parseFloat(name.lineHeight) / parseFloat(name.fontSize), overflow: name.textOverflow, align: name.textAlign },
          cursor: getComputedStyle(stage.querySelector('[data-id-group="performer"] [data-id-cell]')!).cursor,
          face: getComputedStyle(stage.querySelector('[data-id-group="performer"] [data-id-face]')!).backgroundColor,
          series: {
            display: link.display, wrap: link.overflowWrap, border: link.borderTopWidth,
            full: Math.abs(series.getBoundingClientRect().width - series.parentElement!.getBoundingClientRect().width) < 0.5,
          },
        };
      });
      assert.deepEqual(shown.primary, ['flex', 'wrap']);
      assert.equal(shown.narrower, true, '组按内容宽,共演作品不会一组占满一行');
      assert.ok(shown.name.ratio >= 1.5, `名字行高 ${shown.name.ratio} 倍字号,拉丁字母的下伸部会被省略号那层 overflow 裁掉`);
      assert.deepEqual([shown.name.overflow, shown.name.align], ['ellipsis', 'center']);
      assert.equal(shown.cursor, 'pointer');
      assert.notEqual(shown.face, 'rgba(0, 0, 0, 0)', '没图的头像画首字盘');
      assert.deepEqual(shown.series, { display: 'flex', wrap: 'anywhere', border: '0px', full: true });
      await page.locator('#stage [data-series-link]').hover();
      const hovered = await page.locator('#stage [data-series-link]').evaluate((node) =>
        [getComputedStyle(node).color, getComputedStyle(node).textDecorationLine]);
      assert.deepEqual(hovered, [await tokenColor(page, '#stage [data-item-detail]', '--tungsten'), 'none']);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of VIEWPORTS) {
    it(`作品详情头像按有尺寸的圆框取景并显示(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, viewport);
      try {
        const page = opened.page;
        const base = await page.evaluate(() => fetch('/api/item?id=14').then((response) => response.json()));
        const payload = { ...base, performers: ['取景艺人', '无图艺人'],
          entity_refs: { ...base.entity_refs, performer: [
            { id: 3, name: '取景艺人', has_image: true, image_version: 'face-version',
              avatar_focus: { box: { cx: 0.58, cy: 0.49, faceW: 344, imgW: 1000, imgH: 1000 } } },
            { id: 4, name: '无图艺人', has_image: false },
          ] } };
        await page.route((url) => url.pathname === '/api/item', (route) => route.fulfill({ json: payload }));
        await page.route('**/entity-image**', (route) => route.fulfill({ contentType: 'image/svg+xml',
          body: '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000"><rect width="1000" height="1000" fill="#ddd"/></svg>' }));
        await page.goto(new URL('/assets/14', page.url()).href, { waitUntil: 'load' });
        const face = page.locator('#stage [data-entity-name="取景艺人"] [data-id-face]');
        await face.scrollIntoViewIfNeeded();
        await page.waitForFunction(() => {
          const image = document.querySelector<HTMLImageElement>('#stage [data-id-cell="performer"] img');
          return image?.complete && image.naturalWidth > 0;
        });
        const geometry = await face.evaluate((ring) => {
          const image = ring.querySelector('img')!, frame = ring.getBoundingClientRect(), pixels = image.getBoundingClientRect();
          return { visible: getComputedStyle(image).visibility, framed: image.hasAttribute('data-face-framed'),
            direct: image.parentElement === ring, zoomed: pixels.width > frame.width,
            covers: pixels.left <= frame.left + 0.5 && pixels.right >= frame.right - 0.5
              && pixels.top <= frame.top + 0.5 && pixels.bottom >= frame.bottom - 0.5 };
        });
        assert.deepEqual(geometry, { visible: 'visible', framed: true, direct: true, zoomed: true, covers: true });
        assert.equal(await page.locator('#stage [data-entity-name="无图艺人"] img').count(), 0);
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally { await opened.close(); }
    });
  }

  it('作品详情身份区:没实体 id 的格子不给手形,厂牌标识铺满方框', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      // 先经桩取到原样的那一条;`route.fetch()` 会绕过桩直接打到服务器。
      const base = await page.evaluate(() => fetch('/api/item?id=14').then((response) => response.json()));
      const payload = {
        ...base, performers: ['无名氏'], performer_total: 1, performer_entities: [{ id: 0, has_image: false }], has_studio_logo: true,
        entity_refs: { ...base.entity_refs,
          performer: [{ id: 0, name: '无名氏', has_image: false, avatar_focus: null }],
          studio: [{ id: 50, name: 'Peach Studio', has_image: false, has_logo: true }] },
      };
      await page.route((url) => url.pathname === '/api/item', (route) => route.fulfill({ json: payload }));
      await page.goto(new URL('/assets/14', page.url()).href, { waitUntil: 'load' });
      await page.locator('#stage [data-id-cell="studio"] img').waitFor({ timeout: 15_000 });
      await settle(page);
      const shown = await page.evaluate(() => {
        const cell = (kind: string) => document.querySelector(`#stage [data-id-cell="${kind}"]`)!;
        const face = cell('studio').querySelector('[data-id-face]')!.getBoundingClientRect();
        const img = cell('studio').querySelector('img')!;
        const box = img.getBoundingClientRect();
        return {
          performer: [cell('performer').tagName, getComputedStyle(cell('performer')).cursor],
          studio: getComputedStyle(cell('studio')).cursor,
          fit: getComputedStyle(img).objectFit,
          filled: [box.left - face.left, box.top - face.top, box.width - face.width, box.height - face.height]
            .every((delta) => Math.abs(delta) < 0.5),
        };
      });
      assert.deepEqual(shown, { performer: ['SPAN', 'default'], studio: 'pointer', fit: 'cover', filled: true },
        '标识文件自带边距,页面再补 inset 或换成 contain 就多围出一圈框');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('作品详情标签多时先露 20 个,展开键报出剩下几个,展开后能收回', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      const base = await page.evaluate(() => fetch('/api/item?id=14').then((response) => response.json()));
      const tags = Array.from({ length: 30 }, (_, index) => ({ k: `折叠标签${String(index).padStart(2, '0')}`, cat: 'scene' }));
      await page.route((url) => url.pathname === '/api/item', (route) => route.fulfill({ json: { ...base, tags } }));
      await page.goto(new URL('/assets/14', page.url()).href, { waitUntil: 'load' });
      const more = page.locator('#stage [data-tags-more]');
      await more.waitFor({ timeout: 15_000 });
      const chips = page.locator('#stage [data-detail-tags] [data-detail-tag]');
      assert.deepEqual([await chips.count(), (await more.innerText()).trim()], [20, '还有 10 个']);
      await more.click();
      assert.deepEqual([await chips.count(), (await more.innerText()).trim()], [30, '收起']);
      await more.click();
      assert.equal(await chips.count(), 20);
      assert.equal(await page.locator('#stage [data-detail-tags] [data-tag-plus]').isVisible(), 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;
      const face = await page.evaluate(() => {
        const tag = document.querySelector('#stage [data-detail-tag]')!;
        const probe = document.createElement('div');
        probe.style.cssText = 'border:1px solid var(--line);border-radius:var(--tag-radius);background:var(--tag-fill);color:var(--ink-2)';
        tag.parentElement!.append(probe);
        const want = getComputedStyle(probe);
        const expected = { radius: want.borderTopLeftRadius, line: want.borderTopColor, fill: want.backgroundColor, ink: want.color };
        probe.remove();
        const style = getComputedStyle(tag);
        return { expected, actual: { radius: style.borderTopLeftRadius, line: style.borderTopColor,
          fill: style.backgroundColor, ink: getComputedStyle(tag.querySelector('[data-tag]')!).color } };
      });
      assert.deepEqual(face.actual, face.expected, '圆角、线、填充与字色都走卡片那颗 `.tg` 的同一组 token');
      const filter = page.locator('#stage [data-detail-tag] [data-tag]').first();
      await filter.hover();
      assert.equal(await filter.evaluate((node) => getComputedStyle(node).backgroundColor),
        await tokenColor(page, '#stage [data-item-detail]', '--hover'));

      const buttons = ['[data-fb="like"]', '[data-fb="reason"]', '[data-stage-action="dislike"]', '[data-stage-action="seen"]', '[data-stage-action="later"]', '[data-fb="playlist"]',
        '[data-fb="quality"]', '[data-fb="dispose"]'];
      const unchanged: string[] = [];
      for (const selector of buttons) {
        const button = page.locator(`#stage [data-stage-actions] ${selector}`);
        const color = () => button.evaluate((node) => {
          node.getAnimations({ subtree: true }).forEach((animation) => animation.finish());
          return getComputedStyle(node).color;
        });
        await page.mouse.move(0, 0);
        const rest = await color();
        await button.hover();
        if (await color() === rest) unchanged.push(selector);
      }
      assert.deepEqual(unchanged, [], '漏写配色的那一枚全程停在 --muted,看着像不能点');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('作品详情喜爱理由框与「接着看」:输入框聚焦画 BoardUI 那枚 2px 内环;接着看和侧栏同底、顶上一条细线', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#stage [data-media-card]').first().waitFor({ timeout: 15_000 });
      await page.locator('#preferenceToggle').click();
      const field = page.locator('#stage [data-item-preference] textarea');
      await field.focus();
      const ring = await field.evaluate((node) => {
        node.getAnimations().forEach((animation) => animation.finish());
        return getComputedStyle(node).boxShadow;
      });
      const active = await tokenColor(page, '#stage [data-item-detail]', '--color-border-button-active');
      assert.equal(ring, `${active} 0px 0px 0px 2px inset`);

      const related = await page.evaluate(() => {
        const block = document.querySelector('#stage [data-item-related]')!;
        const style = getComputedStyle(block);
        const row = getComputedStyle(block.querySelector('[data-related-row]')!);
        return {
          fill: style.backgroundColor, side: getComputedStyle(document.querySelector('#stage [data-item-side]')!).backgroundColor,
          line: [style.borderTopWidth, style.borderTopStyle, style.borderTopColor],
          heading: getComputedStyle(block.querySelector('h3')!).fontWeight,
          row: [row.display, row.overflowX, row.scrollbarWidth],
        };
      });
      assert.equal(related.fill, related.side, '同一格详情的两块,底色同源,不然整幅宽度上留一道色差');
      assert.deepEqual(related.line, ['1px', 'solid', await tokenColor(page, '#stage', '--line-soft')]);
      assert.equal(related.heading, '600');
      assert.deepEqual(related.row, ['flex', 'auto', 'none'], '那一排横滚,不露系统滚动条');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('作品详情队列与说明块:版次徽章和标题同一行、不斜体;脱盘说明块铺满播放器格', { timeout: 60_000 }, async () => {
    const editions = await openItemPage(browser, `/assets/${ITEM.edition}?editions=${ITEM.edition}`, DESKTOP,
      { ready: '#stage [data-queue-edition]' });
    try {
      const shown = await editions.page.evaluate(() => {
        const badge = document.querySelector('#stage [data-queue-edition]')!;
        const head = badge.parentElement!;
        const name = head.querySelector('b')!.getBoundingClientRect();
        const box = badge.getBoundingClientRect();
        return {
          head: [getComputedStyle(head).display, getComputedStyle(head).flexDirection],
          badge: [getComputedStyle(badge).fontStyle, getComputedStyle(badge).flexShrink, getComputedStyle(badge).maxWidth],
          sameRow: box.top < name.bottom && box.bottom > name.top,
          heading: getComputedStyle(document.querySelector('#stage [data-mix-queue-head] h2')!).fontWeight,
        };
      });
      assert.deepEqual(shown, { head: ['flex', 'row'], badge: ['normal', '1', '50%'], sameRow: true, heading: '600' },
        '`<i>` 默认斜体,徽章不是强调语气;长版次字样最多占一半宽,番号不被挤没');
      /* 队列头的按钮是关闭一类的操作,走控件圆角,不是圆形标签。 */
      const head = await editions.page.evaluate(() => {
        const button = document.querySelector('#stage [data-mix-queue-head] button')!;
        return [getComputedStyle(button).borderTopLeftRadius, getComputedStyle(button).getPropertyValue('--control-radius').trim()];
      });
      assert.equal(head[0], head[1]);
      assert.deepEqual(withoutPlayer(editions.problems), []);
    } finally {
      await editions.close();
    }
    const offline = await openItemPage(browser, `/assets/${ITEM.offline}`, DESKTOP, { ready: '#stage #offlineGate' });
    try {
      const fit = await offline.page.evaluate(() => {
        const gate = document.querySelector('#stage #offlineGate')!;
        const cell = gate.parentElement!;
        const a = gate.getBoundingClientRect(), b = cell.getBoundingClientRect();
        return {
          filled: [a.left - b.left, a.top - b.top, a.width - b.width, a.height - b.height].every((delta) => Math.abs(delta) < 0.5),
          radius: [getComputedStyle(gate).borderTopLeftRadius, getComputedStyle(cell).borderTopLeftRadius],
        };
      });
      assert.equal(fit.filled, true, '说明块铺满播放器格,不上下留黑');
      assert.equal(fit.radius[0], fit.radius[1], '圆角跟着格走');
      assert.notEqual(fit.radius[0], '0px');
      assert.deepEqual(offline.problems, []);
    } finally {
      await offline.close();
    }
  });

  it('作品详情标签选择器用全站下拉面板那一对开合动效,减少动态效果时当场开合', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      const picker = page.locator('#stage [data-tag-picker]');
      /* 关闭动效只有 150ms,点完再读计算样式会在负载高时读到已收起;改记选择器上真正开播过的动效。 */
      await page.evaluate(() => {
        const played: string[] = [];
        (window as unknown as { pickerMotion: string[] }).pickerMotion = played;
        document.addEventListener('animationstart', (event) => {
          if ((event.target as Element).matches('[data-tag-picker]')) played.push(event.animationName);
        }, true);
      });
      const played = () => page.evaluate(() => (window as unknown as { pickerMotion: string[] }).pickerMotion.splice(0));
      const cycle = async () => {
        await page.locator('#tagPlus').click();
        await page.locator('#tagPickSearch').waitFor();
        if (await page.evaluate(() => !matchMedia('(prefers-reduced-motion: reduce)').matches)) {
          await page.waitForFunction(() => (window as unknown as { pickerMotion: string[] }).pickerMotion.includes('board-menu-in'));
        }
        await page.locator('#stage [data-rating-value]').click();
        await picker.waitFor({ state: 'hidden' });
        return played();
      };
      assert.deepEqual(await cycle(), [], '减少动态效果时当场开合,不播动效');
      await page.emulateMedia({ reducedMotion: 'no-preference' });
      assert.deepEqual(await cycle(), ['board-menu-in', 'board-menu-out']);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  /* 舞台与播放器的外观:数值照 YouTube 桌面版 delhi-modern 的播放器与 ytd-miniplayer 实测定下。
     桩里的片源放不出来,Video.js 停在错误态,控件照样挂着,读的是计算值。 */
  const PLAYER_BAR = '#stage .video-js .vjs-control-bar';
  const PLAYER_BLACK = 'rgba(0, 0, 0, 0.6)';
  const styleOf = (page: Page, selector: string, properties: string[], pseudo = '') => page.evaluate(
    ([target, names, element]) => {
      const node = document.querySelector(target);
      if (!node) return null;
      const style = getComputedStyle(node, element || null);
      return Object.fromEntries(names.map((name) => [name, style.getPropertyValue(name)]));
    }, [selector, properties, pseudo] as const);
  const tokenOf = (page: Page, selector: string, token: string) => page.evaluate(([target, name]) =>
    getComputedStyle(document.querySelector(target)!).getPropertyValue(name).trim(), [selector, token] as const);

  it('详情浮窗:遮罩同一档 --scrim、不带模糊,进出场同设置弹层;关闭键是压在画面上的 40px 黑圆;媒体格只圆左上角,剧场模式圆上面两角、排成单列', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP, { settings: { theme: 'light' } });
    try {
      const page = opened.page;
      const scrim = await tokenColor(page, 'body', '--scrim');
      assert.equal(scrim, 'rgba(0, 0, 0, 0.7)');
      await page.emulateMedia({ reducedMotion: 'no-preference' });
      assert.deepEqual(await styleOf(page, '#stage', ['background-color', 'backdrop-filter', 'animation-name'], '::backdrop'),
        { 'background-color': scrim, 'backdrop-filter': 'none', 'animation-name': 'settings-backdrop-in' });
      assert.equal((await styleOf(page, '#stage', ['animation-name']))!['animation-name'], 'board-dialog-in');
      /* 亮色主题下白底白晕看不出悬停,用户以 YouTube 为参照:播放器上的键在亮色下也是黑底。 */
      assert.deepEqual(await styleOf(page, '#closeStage', ['width', 'height', 'border-top-left-radius', 'background-color', 'color']),
        { width: '40px', height: '40px', 'border-top-left-radius': '50%', 'background-color': PLAYER_BLACK, color: 'rgb(255, 255, 255)' });

      const corners = () => styleOf(page, '#stage [data-stage-media]',
        ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius']);
      const wide = (await corners())!;
      assert.notEqual(wide['border-top-left-radius'], '0px', '媒体格左上角没圆');
      assert.deepEqual([wide['border-top-right-radius'], wide['border-bottom-left-radius']], ['0px', '0px'],
        '媒体格右边贴着侧栏、下面接着「接着看」,只圆左上角');
      /* `overflow-y:auto` 会把 overflow-x 算成 auto,内容宽出 1px 就冒横向滚动条;侧栏撑满所在那一行,不露半截底色。 */
      assert.deepEqual(await styleOf(page, '#stage [data-stage-side-content]', ['overflow-x', 'overflow-y']),
        { 'overflow-x': 'hidden', 'overflow-y': 'auto' });
      assert.equal((await styleOf(page, '#stage [data-stage-side]', ['align-self']))!['align-self'], 'stretch');
      await page.locator('#stage [data-player-theater]').dispatchEvent('click');
      await page.locator('#stage[data-theater]').waitFor();
      const theater = (await corners())!;
      assert.deepEqual([theater['border-top-right-radius'], theater['border-bottom-left-radius']],
        [wide['border-top-left-radius'], '0px'], '剧场模式下媒体格圆上面两角');
      const columns = (await styleOf(page, '#stage [data-stage-grid]', ['grid-template-columns']))!['grid-template-columns']!;
      assert.equal(columns.split(' ').length, 1, `剧场模式没排成单列:${columns}`);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('手机上详情浮窗离屏幕边 8px,滚的是里面那一层,视频格吸在它顶上', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, MOBILE);
    try {
      const page = opened.page;
      await page.locator(PLAYER_BAR).waitFor({ state: 'attached' });
      const box = (await page.locator('#stage').boundingBox())!;
      assert.deepEqual([box.x, box.y, box.width], [8, 8, MOBILE.width - 16]);
      assert.equal((await styleOf(page, '#stage > [data-stage-scroll]', ['overflow-y']))!['overflow-y'], 'auto');
      assert.deepEqual(await styleOf(page, '#stage [data-stage-media]', ['position', 'top']), { position: 'sticky', top: '0px' });
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('手机上界面按钮与标签长按不选字、点按不等双击判定;标题、番号这类馆藏数据照常能选中复制', { timeout: 90_000 }, async () => {
    /* 每个样本取 [touch-action, user-select];没找到记成 null。 */
    const touchOf = (page: Page, selectors: string[]) => page.evaluate((list) => list.map((selector) => {
      const node = document.querySelector(selector);
      return node ? [getComputedStyle(node).touchAction, getComputedStyle(node).userSelect] : null;
    }), selectors);
    const controls = ['#closeStage', '#likeBtn', '#stage [data-stage-action]', '#stage [data-title-fold]', '#tagPlus',
      '#stage [data-detail-tag] [data-tag]'];
    const stage = await openItemPage(browser, `/assets/${ITEM.plain}`, MOBILE);
    try {
      await stage.page.locator('#stage [data-detail-tag] [data-tag]').first().waitFor({ state: 'attached' });
      assert.deepEqual(await touchOf(stage.page, controls), controls.map(() => ['manipulation', 'none']),
        `控件顺序:${controls.join('、')}`);
      const [title] = await touchOf(stage.page, ['#stage [data-detail-title]']);
      assert.notEqual(title?.[1], 'none', '详情标题是内容,不能跟着控件一起关掉选字');
      assert.deepEqual(withoutPlayer(stage.problems), []);
    } finally {
      await stage.close();
    }
    const home = await openItemPage(browser, '/', MOBILE, { ready: '#grid [data-media-card] [data-media-title]' });
    try {
      const [card] = await touchOf(home.page, ['#grid [data-media-card] [data-media-title]']);
      assert.equal(card?.[0], 'manipulation', '作品卡标题是按钮,点按不该等双击判定');
      assert.notEqual(card?.[1], 'none', '作品卡标题装着番号,长按要能选中复制');
      assert.deepEqual(home.problems, []);
    } finally {
      await home.close();
    }
  });

  it('播放器控件:40px 黑圆播放键、同一档黑的右侧胶囊与提示、钨丝色进度、页面字体;统计键与加载速度角标压在左上;报错是一张盖在统计上面的卡', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator(PLAYER_BAR).waitFor({ state: 'attached' });
      assert.deepEqual(await styleOf(page, `${PLAYER_BAR} > .vjs-play-control`, ['width', 'height', 'border-top-left-radius', 'background-color']),
        { width: '40px', height: '40px', 'border-top-left-radius': '50%', 'background-color': PLAYER_BLACK });
      assert.equal((await styleOf(page, '#stage .vjs-peach-right-controls', ['background-color']))!['background-color'], PLAYER_BLACK);
      /* 错误态下控件条收起、量不到盒子,读它离播放器四边的计算值。 */
      assert.deepEqual(await styleOf(page, PLAYER_BAR, ['left', 'right', 'bottom', 'height']),
        { left: '12px', right: '12px', bottom: '8px', height: '59px' });
      /* 彩条 6px,抓取区 18px,多出来的 12px 全在条上方:往下扩会盖住按钮那一排的顶边。 */
      assert.deepEqual(await styleOf(page, '#stage .vjs-progress-control', ['height', 'top']), { height: '18px', top: '-12px' });
      assert.equal((await styleOf(page, '#stage .vjs-progress-holder > media-slider-track', ['height']))!.height, '6px', '彩条本身不变粗');
      assert.equal((await styleOf(page, '#stage .vjs-play-progress', ['background-color']))!['background-color'],
        await tokenColor(page, '#stage', '--tungsten'));
      assert.equal((await styleOf(page, '#stage .video-js', ['font-family']))!['font-family'],
        (await styleOf(page, 'body', ['font-family']))!['font-family'], '播放器里的字用页面字体,不是 Video.js 的 Arial');
      assert.deepEqual(await styleOf(page, '#stage [data-player-theater] > .vjs-peach-tooltip',
        ['background-color', 'color', 'padding-top', 'padding-left', 'backdrop-filter', 'white-space']),
      { 'background-color': PLAYER_BLACK, color: 'rgb(255, 255, 255)', 'padding-top': '5px', 'padding-left': '9px',
        'backdrop-filter': 'blur(16px)', 'white-space': 'nowrap' });
      assert.deepEqual(await styleOf(page, '#stage #playerStatsBtn', ['width', 'height', 'border-top-left-radius', 'background-color']),
        { width: '40px', height: '40px', 'border-top-left-radius': '50%', 'background-color': PLAYER_BLACK });
      assert.deepEqual(await styleOf(page, '#stage [data-player-toolbar]', ['left', 'top', 'flex-wrap']),
        { left: '11px', top: '11px', 'flex-wrap': 'wrap' });
      /* 音量胶囊和右边那枚同一排,毛玻璃同一档:只有一边磨砂,展开后它就比邻居更透。 */
      assert.equal((await styleOf(page, `${PLAYER_BAR} > .vjs-volume-panel`, ['backdrop-filter']))!['backdrop-filter'], 'blur(16px)');
      assert.deepEqual(await styleOf(page, '#stage #playerNet', ['height', 'white-space']),
        { height: '40px', 'white-space': 'nowrap' }, '速率断成两行会顶破 40px 的胶囊');
      /* sprite 里的仪表盘是描边图形,容器不声明就按 SVG 默认填成黑块,压在黑底上等于没有图标。
         演示库的片子放不出来、角标没有速率可写,量的是临时塞进去的一枚 svg。 */
      const gauge = await page.evaluate(() => {
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        document.querySelector('#stage #playerNet')!.append(svg);
        const style = getComputedStyle(svg);
        const shown = { fill: style.fill, stroke: style.stroke };
        svg.remove();
        return shown;
      });
      assert.deepEqual(gauge, { fill: 'none', stroke: 'rgb(255, 255, 255)' });
      await page.locator('#stage .video-js.vjs-error').waitFor({ timeout: 10_000 });
      const error = (await styleOf(page, '#stage .vjs-error-display .vjs-modal-dialog-content', ['background-color', 'z-index']))!;
      const stats = (await styleOf(page, '#stage #playerStats', ['z-index']))!;
      assert.equal(error['background-color'], 'rgba(2, 4, 8, 0.86)');
      assert.ok(Number(error['z-index']) > Number(stats['z-index']), `报错卡压在统计面板底下:${error['z-index']} / ${stats['z-index']}`);
      const card = (await page.locator('#stage .vjs-error-display .vjs-modal-dialog-content').boundingBox())!;
      const frame = (await page.locator('#stage .video-js').boundingBox())!;
      assert.ok(Math.abs(card.x + card.width / 2 - (frame.x + frame.width / 2)) < 1
        && Math.abs(card.y + card.height / 2 - (frame.y + frame.height / 2)) < 1, '报错卡居中在画面里,躲开左上角的速率角标');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('播放器的设置面板与右键菜单:同一档黑、浮层圆角、48px 的行;右键菜单悬停抬一层白', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#stage #playerStatsBtn').waitFor();
      const menu = '#stage .vjs-peach-settings-menu';
      /* 关闭态不能是 display:none:它没有可过渡的中间态,面板只会瞬间消失。 */
      assert.deepEqual(await styleOf(page, menu, ['display', 'opacity', 'visibility']),
        { display: 'block', opacity: '0', visibility: 'hidden' });
      await page.locator('#stage .vjs-peach-settings-toggle').dispatchEvent('click');
      const floating = await tokenOf(page, menu, '--floating-radius');
      const settings = (await styleOf(page, menu, ['background-color', 'border-top-left-radius', 'width', 'backdrop-filter']))!;
      assert.equal(settings['background-color'], PLAYER_BLACK);
      assert.equal(settings['backdrop-filter'], 'blur(16px)', '设置面板的毛玻璃没生效');
      assert.equal(settings['border-top-left-radius'], floating);
      assert.ok(parseFloat(settings.width!) <= 274, `设置面板宽 ${settings.width}`);
      assert.equal((await styleOf(page, `${menu} .vjs-peach-menu-row`, ['min-height']))!['min-height'], '48px');
      await page.locator('#stage .vjs-peach-settings-toggle').dispatchEvent('click');

      await page.locator('#stage .video-js').click({ button: 'right' });
      await page.locator('#playerMenu:popover-open').waitFor();
      assert.deepEqual(await styleOf(page, '#playerMenu', ['padding-top', 'background-color', 'border-top-left-radius', 'backdrop-filter']),
        { 'padding-top': '8px', 'background-color': PLAYER_BLACK, 'border-top-left-radius': await tokenOf(page, '#playerMenu', '--floating-radius'),
          'backdrop-filter': 'blur(16px)' });
      const row = (await styleOf(page, '#playerMenu [data-player-menu]', ['min-height', 'grid-template-columns']))!;
      assert.equal(row['min-height'], '48px');
      const tracks = row['grid-template-columns']!.split(' ');
      assert.deepEqual([tracks[0], tracks.at(-1)], ['56px', '32px'], `右键菜单行的列:${row['grid-template-columns']}`);
      await page.locator('#playerMenu [data-player-menu]').first().hover();
      assert.equal((await styleOf(page, '#playerMenu [data-player-menu]', ['background-color']))!['background-color'],
        'rgba(255, 255, 255, 0.1)');
      await page.keyboard.press('Escape');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('统计图 20px 高,缓冲那条按健康度换色;小窗照 ytd-miniplayer:固定在右下角 16px、宽 400px、层级在弹层之下,快退快进键 36px', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP);
    try {
      const page = opened.page;
      await page.locator('#stage #playerStatsBtn').click();
      const plot = '#stage [data-player-stats-plot="buffer"]';
      assert.equal((await styleOf(page, plot, ['height']))!.height, '20px');
      const colors = await page.evaluate((selector) => ['low', 'mid'].map((state) => {
        const bar = document.createElement('i');
        bar.dataset.bar = state;
        document.querySelector(selector)!.append(bar);
        const color = getComputedStyle(bar).backgroundColor;
        bar.remove();
        return color;
      }), plot);
      assert.deepEqual(colors, ['rgb(225, 105, 98)', 'rgb(239, 181, 95)']);

      await page.locator('#closeStage').focus();
      await page.keyboard.press('i');
      await page.locator('#miniplayer:not([hidden]) .video-js').waitFor({ timeout: 10_000 });
      assert.deepEqual(await styleOf(page, '#miniplayer', ['position', 'z-index']), { position: 'fixed', 'z-index': '900' });
      const box = (await page.locator('#miniplayer').boundingBox())!;
      assert.deepEqual([box.width, box.x + box.width, box.y + box.height], [400, DESKTOP.width - 16, DESKTOP.height - 16]);
      assert.deepEqual(await styleOf(page, '#miniplayerBack', ['width', 'height']), { width: '36px', height: '36px' });
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  /** 一组元素的外框,取到小数点后一位。 */
  const rectsOf = (page: Page, selectors: Record<string, string>) => page.evaluate((entries) => Object.fromEntries(
    Object.entries(entries).map(([name, selector]) => {
      const box = document.querySelector(selector)!.getBoundingClientRect();
      const round = (value: number) => Math.round(value * 10) / 10;
      return [name, { left: round(box.left), top: round(box.top), right: round(box.right), bottom: round(box.bottom), width: round(box.width), height: round(box.height) }];
    })), selectors);
  const IMMERSE = {
    root: '[data-immerse]', stage: '[data-immerse-stage]', actions: '[data-immerse-actions]', ui: '[data-immerse-ui]',
    bar: '[data-immerse-bar]', close: '[data-immerse-close]',
  };

  it('沉浸模式桌面照 Shorts 三段:9:16 舞台居中、动作列贴舞台右侧 12px、作者标题在左下;横片换成 16:9,动作列收进框里;浅色主题下仍是深色层', { timeout: 90_000 }, async () => {
    const opened = await visit(browser, '/', DESKTOP);
    try {
      const { page } = opened;
      const { wide, tall } = await pickClips(page);
      await pinQueue(page, [tall, wide]);
      await muteVideos(page);
      await openImmerse(page, tall.id);
      const portrait = await rectsOf(page, IMMERSE);
      assert.deepEqual(portrait.stage, { left: 415, top: 0, right: 865, bottom: 800, width: 450, height: 800 });
      assert.deepEqual([portrait.actions.left, portrait.actions.width, portrait.actions.bottom], [877, 72, 792]);
      assert.deepEqual([portrait.ui.left, portrait.ui.bottom], [20, 780]);
      assert.deepEqual(portrait.bar, { left: 0, top: 780, right: 1280, bottom: 800, width: 1280, height: 20 });
      assert.deepEqual([portrait.close.top, portrait.close.right, portrait.close.width], [15, 1265, 42]);
      const faces = await page.evaluate(() => {
        const read = (selector: string, names: string[]) => {
          const style = getComputedStyle(document.querySelector(selector)!);
          return Object.fromEntries(names.map((name) => [name, style.getPropertyValue(name)]));
        };
        return {
          root: read('[data-immerse]', ['background-color']),
          track: read('[data-immerse-track]', ['border-radius', 'background-color']),
          floating: (() => {
            const probe = document.createElement('div');
            probe.style.borderRadius = 'var(--floating-radius)';
            document.querySelector('[data-immerse]')!.append(probe);
            const value = getComputedStyle(probe).borderRadius;
            probe.remove();
            return value;
          })(),
          circle: read('[data-immerse-circle]', ['width', 'height', 'border-radius', 'background-color', 'color']),
          glyph: read('[data-immerse-circle] svg', ['width', 'height', 'fill', 'stroke']),
          author: read('[data-immerse-author]>a', ['color', 'font-weight']),
          title: read('[data-immerse-title]', ['font-size', 'font-weight', 'text-overflow', 'white-space', 'pointer-events']),
          caption: read('[data-immerse-ui]', ['pointer-events']),
          progress: read('[data-immerse-bar] i', ['height']),
        };
      });
      const { floating, ...face } = faces;
      assert.notEqual(floating, '0px');
      assert.deepEqual(face, {
        root: { 'background-color': 'rgb(15, 15, 15)' },
        track: { 'border-radius': floating, 'background-color': 'rgb(0, 0, 0)' },
        circle: { width: '48px', height: '48px', 'border-radius': '50%', 'background-color': 'rgba(255, 255, 255, 0.1)', color: 'rgb(241, 241, 241)' },
        glyph: { width: '24px', height: '24px', fill: 'none', stroke: 'rgb(241, 241, 241)' },
        author: { color: 'rgb(241, 241, 241)', 'font-weight': '600' },
        title: { 'font-size': '20px', 'font-weight': '600', 'text-overflow': 'ellipsis', 'white-space': 'nowrap', 'pointer-events': 'auto' },
        caption: { 'pointer-events': 'none' },
        progress: { height: '4px' },
      });

      // 进度:4px 的线贴着 20px 热区的底,走钨丝蓝;指针靠近才变粗。
      assert.equal(await page.evaluate(() => getComputedStyle(document.querySelector('[data-immerse-bar] i')!).backgroundColor),
        await tokenColor(page, '[data-immerse]', '--tungsten'));
      await page.hover('[data-immerse-bar]');
      await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-immerse-bar] i')!).height === '9px');

      // 动作键以底色反馈,悬停与按住保持尺寸;选中使用浅底深字。
      await page.route('**/api/feedback', (route) => route.fulfill({ json: { feedback: 'seen', o_count: 0 } }));
      const seen = '[data-immerse-actions] button[aria-label="标为看过"]';
      const resting = await page.locator(seen).boundingBox();
      const background = await page.locator(seen).evaluate(node => getComputedStyle(node).backgroundColor);
      await page.hover(seen);
      await page.waitForFunction(([selector, color]) => getComputedStyle(document.querySelector(selector!)!).backgroundColor !== color, [seen, background]);
      assert.deepEqual(await page.locator(seen).boundingBox(), resting);
      assert.equal(await page.locator(seen).evaluate(node => getComputedStyle(node).scale), 'none');
      await page.mouse.down();
      assert.deepEqual(await page.locator(seen).boundingBox(), resting);
      assert.equal(await page.locator(seen).evaluate(node => getComputedStyle(node).scale), 'none');
      await page.mouse.up();
      await page.locator(`${seen}[aria-pressed="true"]`).waitFor({ timeout: 5_000 });
      await page.mouse.move(0, 0);
      assert.deepEqual(await page.evaluate((selector) => {
        const style = getComputedStyle(document.querySelector(selector)!);
        return [style.backgroundColor, style.color];
      }, seen), ['rgb(241, 241, 241)', 'rgb(15, 15, 15)']);

      await page.keyboard.press('ArrowDown');
      await page.waitForFunction((id) => location.search === `?id=${id}`, wide.id);
      await settledClip(page);
      const landscape = await rectsOf(page, IMMERSE);
      assert.deepEqual(landscape.stage, { left: 230.4, top: 169.6, right: 1049.6, bottom: 630.4, width: 819.2, height: 460.8 });
      assert.deepEqual([landscape.actions.right, landscape.actions.bottom], [1037.6, 612.4]);
      assert.equal(landscape.ui.width, 404.8);

      await page.evaluate(() => { document.documentElement.dataset.theme = 'light' });
      assert.deepEqual(await page.evaluate(() => [
        getComputedStyle(document.querySelector('[data-immerse]')!).backgroundColor,
        getComputedStyle(document.querySelector('[data-immerse-author]>a')!).color,
      ]), ['rgb(15, 15, 15)', 'rgb(241, 241, 241)']);
      assert.deepEqual(opened.problems.filter((line) => !line.includes('VIDEOJS')), []);
    } finally {
      await opened.close();
    }
  });

  it('沉浸模式手机铺满视口:舞台不留圆角,动作列贴右、作者标题贴左下,作者那行的时长与序号收起', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/', MOBILE);
    try {
      const { page } = opened;
      const { wide, tall } = await pickClips(page);
      await pinQueue(page, [tall, wide]);
      await muteVideos(page);
      await openImmerse(page, tall.id);
      const rects = await rectsOf(page, { ...IMMERSE, avatar: '[data-immerse-avatar]' });
      assert.deepEqual(rects.stage, { left: 0, top: 0, right: 390, bottom: 844, width: 390, height: 844 });
      assert.deepEqual([rects.actions.right, rects.actions.bottom, rects.actions.width], [382, 752, 56]);
      assert.deepEqual([rects.ui.left, rects.ui.right, rects.ui.bottom], [14, 308, 816]);
      assert.deepEqual([rects.avatar.width, rects.avatar.height], [36, 36]);
      assert.deepEqual([rects.close.top, rects.close.right], [10, 380]);
      assert.deepEqual(await page.evaluate(() => ({
        radius: getComputedStyle(document.querySelector('[data-immerse-track]')!).borderRadius,
        meta: getComputedStyle(document.querySelector('[data-immerse-author]>span')!).display,
        title: getComputedStyle(document.querySelector('[data-immerse-title]')!).fontSize,
      })), { radius: '0px', meta: 'none', title: '14px' });
      assert.deepEqual(opened.problems.filter((line) => !line.includes('VIDEOJS')), []);
    } finally {
      await opened.close();
    }
  });

  /* ── 侧栏岛(`frontend/src/react/sidebar/sidebar.css`) ── */

  /** 打开首页等侧栏的分组画出来。聚合照演示库真取,只保证时长那一组在(演示库的短片可能凑不出总时长)。 */
  async function openSidebar(viewport: Viewport): Promise<Visit> {
    const opened = await visit(browser, '/', viewport);
    await opened.page.route((url) => url.pathname === '/api/facets', async (route) => {
      const real = await (await route.fetch()).json() as { stats?: Record<string, unknown> };
      await route.fulfill({ json: { ...real, stats: { ...real.stats, duration: real.stats?.duration || 540 } } });
    });
    await opened.page.reload({ waitUntil: 'load' });
    await opened.page.locator('#drawer [data-sidebar-group="时长"]').waitFor({ state: 'attached', timeout: 15_000 });
    await settle(opened.page);
    return opened;
  }

  it('侧栏当前项自己不铺底,由挂在抽屉上的那块玻璃标出;悬停时玻璃跟过去,离开这一列回到当前项', { timeout: 60_000 }, async () => {
    const opened = await openSidebar(DESKTOP);
    try {
      const { page } = opened;
      const read = (key: string) => page.evaluate((target) => {
        const glide = document.querySelector<HTMLElement>('[data-sidebar-glide]')!;
        const button = document.querySelector<HTMLElement>(`#drawer [data-sidebar-nav] [data-nav="${target}"]`)!;
        const box = (node: Element) => {
          const rect = node.getBoundingClientRect();
          return [rect.left, rect.top, rect.width, rect.height].map(Math.round);
        };
        const style = getComputedStyle(button);
        return {
          glide: box(glide), button: box(button), parent: glide.parentElement?.id,
          layer: getComputedStyle(glide).zIndex, radius: getComputedStyle(glide).borderRadius,
          fill: [style.backgroundColor, style.backgroundImage, style.boxShadow], color: style.color,
        };
      }, key);
      const current = await read('');
      assert.equal(current.parent, 'drawer', '玻璃没有挂在抽屉上,纵滚时会被滚动层切掉');
      assert.deepEqual([current.layer, current.radius], ['-1', '10px'], '玻璃要压在字底下、圆角照按钮的 10px');
      assert.deepEqual(current.glide, current.button, '玻璃没有垫在当前项下面');
      assert.deepEqual(current.fill, ['rgba(0, 0, 0, 0)', 'none', 'none'], '当前项自己铺了底,静止时就是两层底叠着');
      assert.equal(current.color, await tokenColor(page, '#drawer', '--glass-text'), '当前项的字色不是玻璃上的字色');
      const idle = await read('studios');
      assert.equal(idle.color, await tokenColor(page, '#drawer', '--color-text-secondary'), '未选中项的字色不是次要文字色');

      await page.locator('#drawer [data-sidebar-nav] [data-nav="studios"]').hover();
      const hovered = await read('studios');
      assert.deepEqual(hovered.glide, hovered.button, '悬停时玻璃没有跟到指着的那一项');
      assert.deepEqual(hovered.fill, ['rgba(0, 0, 0, 0)', 'none', 'none'], '悬停的那一项自己铺了底');
      await page.mouse.move(900, 420);
      const back = await read('');
      assert.deepEqual(back.glide, back.button, '指针离开这一列之后玻璃没有回到当前项');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('侧栏标题行:切换器 32px 圆标识、收起键 20px 高,与导航首项相隔 12px;分组箭头展开时转 90°;收起后只剩 60px 宽的图标列', { timeout: 60_000 }, async () => {
    const opened = await openSidebar(DESKTOP);
    try {
      const { page } = opened;
      const head = await page.evaluate(() => {
        const drawer = document.querySelector('#drawer')!;
        const rect = (selector: string) => drawer.querySelector(selector)!.getBoundingClientRect();
        const mark = drawer.querySelector('#brandHome .mark')!;
        const rotation = (group: Element) => {
          const matrix = new DOMMatrix(getComputedStyle(group.querySelector(':scope > [data-sidebar-toggle] svg')!).transform);
          return Math.round(Math.atan2(matrix.b, matrix.a) * 180 / Math.PI);
        };
        const groups = [...drawer.querySelectorAll<HTMLDetailsElement>('[data-sidebar-group]')];
        return {
          gap: Math.round(rect('[data-sidebar-nav]').top - rect('[data-sidebar-head]').bottom),
          mark: [Math.round(rect('#brandHome .mark').width), Math.round(rect('#brandHome .mark').height), getComputedStyle(mark).borderRadius],
          toggle: Math.round(rect('#filterBtn').height),
          open: groups.filter((group) => group.open).map(rotation),
          closed: groups.filter((group) => !group.open).map(rotation),
        };
      });
      assert.equal(head.gap, 12, '切换器与导航首项挨得太近,两块底色读起来像压在一起');
      assert.deepEqual(head.mark, [32, 32, '50%'], '切换器的标识不是 32px 的圆');
      assert.equal(head.toggle, 20, '收起键不是 20px 高');
      assert.ok(head.open.length && head.closed.length, `首页的分组没有一开一合可比:${JSON.stringify(head)}`);
      assert.ok(head.open.every((angle) => angle === 90), `展开的分组箭头没有转到 90°:${head.open}`);
      assert.ok(head.closed.every((angle) => angle === 0), `收着的分组箭头没有回到 0°:${head.closed}`);

      await page.locator('#filterBtn').click();
      await page.waitForFunction(() => Math.round(document.querySelector('#drawer')!.getBoundingClientRect().width) === 60,
        undefined, { timeout: 5_000 });
      const rail = await page.evaluate(() => {
        const drawer = document.querySelector('#drawer')!;
        const toggle = drawer.querySelector('#filterBtn')!.getBoundingClientRect();
        return {
          toggle: [Math.round(toggle.width), Math.round(toggle.height)],
          groups: [...new Set([...drawer.querySelectorAll('[data-sidebar-group]')].map((group) => getComputedStyle(group).display))],
          labels: [...new Set([...drawer.querySelectorAll('[data-sidebar-nav] button span')].map((span) => getComputedStyle(span).maxWidth))],
        };
      });
      assert.deepEqual(rail, { toggle: [36, 20], groups: ['none'], labels: ['0px'] }, '收起的窄栏不是一列图标');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('时长拉条:已选段是 accent 渐变,两端读数常显、推到端点也不越出轨道,刚动过的那枚压在上面', { timeout: 60_000 }, async () => {
    const opened = await openSidebar(DESKTOP);
    try {
      const { page } = opened;
      await page.locator('#drawer [data-sidebar-group="时长"] > [data-sidebar-toggle]').click();
      await page.locator('#drawer [data-sidebar-group="时长"] [data-sidebar-collapse]:not([inert])').waitFor({ timeout: 5_000 });
      const read = () => page.evaluate(() => {
        const range = document.querySelector('#durationRange')!;
        const bounds = range.getBoundingClientRect();
        return {
          fill: getComputedStyle(range.querySelector('[data-sidebar-range-fill]')!).backgroundImage,
          tips: [...range.querySelectorAll<HTMLElement>('[data-sidebar-range-tip]')].map((tip) => {
            const box = tip.getBoundingClientRect();
            return {
              end: tip.dataset.rangeEnd, text: tip.textContent, layer: getComputedStyle(tip).zIndex,
              visible: getComputedStyle(tip).visibility === 'visible' && box.width > 0,
              inside: box.left >= bounds.left - 1 && box.right <= bounds.right + 1,
            };
          }),
        };
      });
      const resting = await read();
      const accent = await tokenColor(page, '#drawer', '--color-accent-400');
      assert.ok(resting.fill.startsWith('linear-gradient(90deg') && resting.fill.includes(accent),
        `已选段不是从 accent-400 起的横向渐变:${resting.fill}`);
      assert.deepEqual(resting.tips, [
        { end: 'min', text: '0 分钟', layer: '1', visible: true, inside: true },
        { end: 'max', text: '不限', layer: '2', visible: true, inside: true },
      ], '两端读数要常显、贴着轨道两端不越界,没动过时右端那枚在上');

      await page.locator('#durMin').focus();
      await page.keyboard.press('ArrowRight');
      await page.waitForFunction(() => document.querySelector('[data-sidebar-range-tip][data-range-end="min"]')?.hasAttribute('data-range-active'),
        undefined, { timeout: 5_000 });
      const moved = await read();
      assert.deepEqual(moved.tips.map((tip) => [tip.end, tip.layer, tip.inside]), [['min', '2', true], ['max', '1', true]],
        '刚动过的那一端没有压到上面');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('手机侧栏:抽屉开着时遮罩外壳隐形、暗色画在 ::before 上,层级低于抽屉;收起时遮罩退出渲染树', { timeout: 60_000 }, async () => {
    const opened = await openSidebar(MOBILE);
    try {
      const { page } = opened;
      const scrim = () => page.evaluate(() => {
        const node = document.querySelector('#scrim')!, drawer = document.querySelector('#drawer')!;
        const before = getComputedStyle(node, '::before');
        return {
          display: getComputedStyle(node).display, visibility: getComputedStyle(node).visibility,
          dim: [before.visibility, before.backgroundColor],
          layers: [Number(getComputedStyle(node).zIndex), Number(getComputedStyle(drawer).zIndex)],
          fill: getComputedStyle(drawer).getPropertyValue('--glass-fill'),
        };
      });
      assert.equal((await scrim()).display, 'none', '抽屉收着时遮罩还在渲染树里,iOS 的 Safari 会拿它给状态栏取色');
      await page.locator('#filterBtn').tap();
      await page.waitForFunction(() => document.querySelector('#drawer')?.classList.contains('open'));
      const shown = await scrim();
      assert.deepEqual([shown.display, shown.visibility], ['block', 'hidden'], '遮罩外壳没有隐形,iOS 的 Safari 会拿它给状态栏取色');
      assert.equal(shown.dim[0], 'visible', '遮罩的暗色没有画在 ::before 上');
      assert.notEqual(shown.dim[1], 'rgba(0, 0, 0, 0)', '遮罩的 ::before 是透明的,压不暗身后的页面');
      assert.ok(shown.layers[0] < shown.layers[1], `遮罩压在抽屉上面,抽屉里就点不动了:${shown.layers}`);
      assert.ok(shown.fill.replace(/\s/g, '').includes('86%'), `窄屏抽屉的填充没有加厚到 86%:${shown.fill}`);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });
});