Ctrl K
ComunidadGuías…

frontend/e2e/design-people.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 } from 'playwright-core';
import { expectBody, layout, requiredEnv, settle, visit, VIEWPORTS } from './harness.ts';
import {
  DESKTOP, MOBILE, tokenColor, PIXEL, TASTE_FACE, WIDE, openCatalog, openCatalogFixture, openFollowManage,
  openPerformer, openPhotoWall, photoWallFaces, PROFILED, openProfiledPerformer, popmenuShadow, SUGGEST,
  stubSuggest, installDesignBrowser,
} from './design-fixture.ts';

describe('设计决定:人物与厂牌资料页', () => {
  let browser: Browser;
  installDesignBrowser((next) => { browser = next; });

  it('取景前的懒加载头像保留可见性,加载后显示人像', { timeout: 60_000 }, async () => {
    const opened = await openProfiledPerformer(browser, DESKTOP);
    try {
      const page = opened.page;
      let release!: () => void;
      const pending = new Promise<void>(resolve => { release = resolve; });
      await page.route('**/avatar-loading-probe', async route => {
        await pending;
        await route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="green"/></svg>' });
      });
      const state = await page.evaluate(() => {
        const ring = document.createElement('span');
        ring.style.cssText = 'position:fixed;top:20px;left:20px;width:80px;height:80px';
        ring.innerHTML = '<img id="avatar-loading-probe" loading="lazy" width="128" height="128" data-facebox="0.5 0.5 60 128 128" src="/avatar-loading-probe">';
        document.body.append(ring);
        const image = ring.querySelector('img')!;
        return { visibility: getComputedStyle(image).visibility, opacity: getComputedStyle(image).opacity };
      });
      release();
      assert.deepEqual(state, { visibility: 'visible', opacity: '0' });
      await page.waitForFunction(() => {
        const image = document.querySelector<HTMLImageElement>('#avatar-loading-probe')!;
        return image.naturalWidth > 0 && getComputedStyle(image).opacity === '1';
      });
    } finally {
      await opened.close();
    }
  });

  for (const [viewport, split] of [[WIDE, true], [MOBILE, false]] as const) {
    it(`搜索下拉按种类分页签;宽屏分两栏、人名带头像与近作、视频排成封面格,窄屏一栏(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/', viewport);
      try {
        const page = opened.page;
        const kinds = await stubSuggest(page);
        await expectBody(page, '/', [page.locator('#grid [data-media-card][data-id]').first()]);
        await settle(page);
        if (viewport.mobile) await page.locator('#searchBtn').click();
        await page.locator('#q').fill(SUGGEST.q);
        const menu = page.locator('#searchMenu');
        await menu.locator('[data-search-option="person"]').first().waitFor({ state: 'visible', timeout: 5_000 });
        const shown = await page.evaluate(() => {
          const menuBox = document.querySelector('#searchMenu')!.getBoundingClientRect();
          const results = document.querySelector<HTMLElement>('#searchMenu [data-search-results]')!;
          const columns = [...results.querySelectorAll(':scope > [data-search-col]')];
          const person = document.querySelector('#searchMenu [data-search-option="person"]')!;
          /* 命中的别名与作品数是这一行的注脚:字号小一档、字色退一档,作品数靠右。 */
          const footnote = (selector: string) => {
            const note = document.querySelector<HTMLElement>(`#searchMenu ${selector}`)!;
            const row = note.closest('[data-search-option]')!;
            const name = note.previousElementSibling!;
            const [noteStyle, nameStyle] = [getComputedStyle(note), getComputedStyle(name)];
            return {
              smaller: parseFloat(noteStyle.fontSize) < parseFloat(nameStyle.fontSize),
              quieter: noteStyle.color !== nameStyle.color,
              trailing: Math.round(row.getBoundingClientRect().right - note.getBoundingClientRect().right),
            };
          };
          return {
            matched: footnote('[data-search-matched]'),
            count: footnote('[data-search-n]'),
            radius: getComputedStyle(person).borderRadius === getComputedStyle(document.documentElement).getPropertyValue('--control-radius').trim(),
            tabs: [...document.querySelectorAll('#searchMenu [role="tab"]')].map((tab) => tab.textContent!.trim()),
            tracks: getComputedStyle(results).gridTemplateColumns.split(' ').filter((track) => track !== 'none').length,
            divider: getComputedStyle(results, '::before').content,
            dividerHeight: parseFloat(getComputedStyle(results, '::before').height),
            resultsHeight: results.getBoundingClientRect().height,
            right: columns.at(-1)!.querySelector('[data-search-group]')?.getAttribute('data-kind'),
            videoGrid: getComputedStyle(document.querySelector('#searchMenu [data-search-group][data-kind="asset"] [data-search-items]')!).display,
            peeks: getComputedStyle(person.querySelector('[data-search-peeks]')!).display,
            peekCount: person.querySelectorAll('[data-open-work]').length,
            sub: person.querySelector('[data-search-sub]')!.textContent,
            face: person.querySelector('[data-search-face] img')?.getAttribute('src'),
            inView: menuBox.left >= 0 && menuBox.right <= innerWidth,
            edges: [Math.round(menuBox.left), Math.round(innerWidth - menuBox.right)],
          };
        });
        assert.deepEqual(shown.tabs, ['全部', '女优2', '厂牌1', '视频12'], '页签不是「全部」加各类命中数');
        assert.equal(shown.tracks, split ? 2 : 0, split ? '宽屏下拉没有分两栏' : '窄屏下拉不该分栏');
        assert.equal(shown.divider, split ? '""' : 'none', split ? '宽屏两栏之间没有分隔线' : '窄屏一栏不该有分隔线');
        if (split) {
          assert.ok(Math.abs(shown.dividerHeight - shown.resultsHeight) <= 1,
            `分隔线高 ${shown.dividerHeight}px,两栏高 ${shown.resultsHeight}px`);
        }
        assert.equal(shown.right, 'asset', '视频不在最后一栏');
        assert.equal(shown.videoGrid, split ? 'grid' : 'block', split ? '宽屏视频没排成封面格' : '窄屏视频该一行一部');
        assert.equal(shown.peeks, split ? 'flex' : 'none', split ? '宽屏人名一行没摆近作' : '窄屏人名一行不摆近作');
        assert.equal(shown.peekCount, 4);
        assert.equal(shown.sub, '128 个视频 · Capsule Agency');
        assert.equal(shown.face, '/entity-image?kind=performer&id=90301&thumb=1');
        for (const [name, note] of [['别名', shown.matched], ['作品数', shown.count]] as const) {
          assert.ok(note.smaller && note.quieter, `${name}没有退成注脚:${JSON.stringify(note)}`);
        }
        assert.equal(shown.count.trailing, 10, '作品数没有靠右贴着行的内边距');
        assert.ok(shown.radius, '补全行的圆角不是 --control-radius');
        assert.ok(shown.inView, '下拉栏越出了视口');
        // 窄屏下拉栏盖过返回键那一列,和顶栏两侧一样各留 8,不缩进到搜索框底下。
        if (viewport.mobile) assert.deepEqual(shown.edges, [8, 8], '窄屏下拉栏两侧留白不是 8');
        const page_ = await layout(page);
        assert.ok(page_.scrollWidth <= page_.viewportWidth, `下拉把页面撑出了横向滚动:${page_.offenders.join(',')}`);

        await menu.getByRole('tab', { name: /^女优/ }).click();
        await page.waitForFunction(() => document.querySelectorAll('#searchMenu [data-search-group][data-kind]').length === 1);
        assert.equal(await menu.isVisible(), true, '点页签把下拉栏收掉了');
        assert.ok(kinds.includes('performer'), '选了一类没有按这一类去拉满');
        assert.equal(await menu.getByRole('tab', { name: /^女优/ }).getAttribute('aria-selected'), 'true');

        // 人名那一行点开的是资料页,不是按名字再搜一遍。
        await page.keyboard.press('ArrowDown');
        await page.keyboard.press('Enter');
        await page.waitForURL('**/performers/90301');
        assert.deepEqual(opened.problems, []);
      } finally {
        await opened.close();
      }
    });
  }

  it('只看视频时封面格按列均分铺满整栏,每格有宽度上限', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/', WIDE);
    try {
      const page = opened.page;
      await stubSuggest(page);
      await expectBody(page, '/', [page.locator('article[data-media-card][data-id]').first()]);
      await settle(page);
      await page.locator('#q').fill(SUGGEST.q);
      const menu = page.locator('#searchMenu');
      await menu.locator('[data-search-option="person"]').first().waitFor({ state: 'visible', timeout: 5_000 });
      await menu.getByRole('tab', { name: /^视频/ }).click();
      await page.waitForFunction(() => document.querySelectorAll('#searchMenu [data-search-group][data-kind]').length === 1);
      const grid = await page.evaluate(() => {
        const items = document.querySelector('#searchMenu [data-search-group][data-kind="asset"] [data-search-items]')!;
        const box = items.getBoundingClientRect();
        const cards = [...items.querySelectorAll('[data-search-option="work"]')].map((card) => card.getBoundingClientRect());
        return {
          rows: new Set(cards.map((card) => Math.round(card.top))).size,
          widths: cards.map((card) => Math.round(card.width)),
          slack: Math.round(box.right - cards.at(-1)!.right),
        };
      });
      assert.equal(grid.rows, 1, '五部视频没排在同一行');
      assert.ok(grid.slack <= 1, `封面格右侧空出 ${grid.slack}px`);
      assert.ok(grid.widths.every((width) => width >= 96 && width <= 242), `封面宽度越出 96~242:${grid.widths.join('、')}`);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('资料页照片墙:1280 宽小图 4 列、大图 2 列,手机 2 列;固定版式是方格,瀑布流按列填;格子是一块等图的空底', { timeout: 90_000 }, async () => {
    const opened = await openPhotoWall(browser);
    try {
      const { page } = opened;
      // 默认瀑布流、小图。
      assert.deepEqual(await photoWallFaces(page), { display: 'block', columns: 4, gap: '10px', square: true });
      const cell = await page.evaluate(() => {
        const node = document.querySelector<HTMLElement>('[data-local-wall] [data-photo-cell]')!;
        const style = getComputedStyle(node);
        const head = document.querySelector('[data-photo-group]')!;
        return {
          radius: style.borderRadius, cursor: style.cursor, sunk: style.backgroundColor,
          token: getComputedStyle(document.documentElement).getPropertyValue('--surface-radius').trim(),
          alt: getComputedStyle(node.querySelector('img')!).color,
          headTop: getComputedStyle(head).marginTop,
          rows: [head, head.querySelector('b')!].map((node) => getComputedStyle(node).lineHeight),
          code: getComputedStyle(head.querySelector('b')!).fontFamily,
          meta: getComputedStyle(head.querySelector('[data-photo-group-meta]')!).marginLeft !== '0px',
        };
      });
      assert.equal(cell.radius, cell.token, '照片格的圆角是 --surface-radius');
      assert.equal(cell.cursor, 'zoom-in');
      assert.equal(cell.alt, 'rgba(0, 0, 0, 0)', '等图时 alt 文件名不画出来');
      assert.notEqual(cell.sunk, 'rgba(0, 0, 0, 0)', '格子自带一块沉底色');
      assert.equal(cell.headTop, '28px', '每段上方留出段距');
      assert.deepEqual(cell.rows, ['20px', '20px'], '段头一行 20px,番号不按自己的字号撑高');
      assert.match(cell.code, /Cascadia Mono/, '段头番号是等宽字');
      assert.ok(cell.meta, '来源与张数靠右');
      assert.equal(await page.locator('#index [data-entity-more]').evaluate((node) => {
        const style = getComputedStyle(node);
        return `${style.height} ${style.borderRadius} ${style.fontSize} ${style.fontWeight}`;
      }), '36px 10px 14px 500', '载入更多是 36px 高、10px 圆角的 secondary 按钮');
      await page.locator('[data-entity-layout][aria-label="图片布局"] label:has(input[value="fixed"])').click();
      await page.waitForFunction(() => document.querySelector('[data-local-wall]')?.getAttribute('data-layout') === 'fixed');
      assert.deepEqual(await photoWallFaces(page), { display: 'grid', columns: 4, gap: '10px', square: true });
      // 顶栏那枚大小键在照片档里换的是照片的大小档,一次请求都不发。
      await page.locator('#density').click();
      await page.waitForFunction(() => document.querySelector('[data-local-wall]')?.getAttribute('data-size') === 'big');
      assert.deepEqual(await photoWallFaces(page), { display: 'grid', columns: 2, gap: '10px', square: true });
      assert.equal(await page.locator('#density').getAttribute('title'), '当前:大图');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
    const phone = await openPhotoWall(browser, MOBILE);
    try {
      const { page } = phone;
      assert.deepEqual(await photoWallFaces(page), { display: 'block', columns: 2, gap: '10px', square: true });
      await page.locator('[data-entity-layout][aria-label="图片布局"] label:has(input[value="fixed"])').click();
      await page.waitForFunction(() => document.querySelector('[data-local-wall]')?.getAttribute('data-layout') === 'fixed');
      assert.deepEqual(await photoWallFaces(page), { display: 'grid', columns: 2, gap: '8px', square: true });
      assert.deepEqual(phone.problems, []);
    } finally {
      await phone.close();
    }
  });

  it('空搜索的头像与封面分栏,搜索记录排在左栏最下面,分隔线覆盖两栏', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/', WIDE);
    try {
      const page = opened.page;
      const history = Array.from({ length: 10 }, (_, at) => `搜索记录 ${at + 1}`);
      await page.route(/\/api\/search-history\?/, (route) => route.fulfill({ json: { items: history } }));
      await page.reload({ waitUntil: 'load' });
      await expectBody(page, '/', [page.locator('article[data-media-card][data-id]').first()]);
      await settle(page);
      await page.locator('#q').click();
      await page.locator('#searchMenu [data-search-recommendations] [data-search-results][data-split]').waitFor({ state: 'visible', timeout: 5_000 });
      // 推荐是预取好的、打开就在;搜索记录随后才到,排进左栏末尾。
      await page.waitForFunction(() => [...document.querySelectorAll('#searchMenu [data-search-col]:first-child > [data-search-group] > h3')]
        .some((title) => title.textContent === '搜索记录'), undefined, { timeout: 5_000 });
      const split = await page.evaluate(() => {
        const results = document.querySelector('#searchMenu [data-search-results]')!;
        const [left, right] = [...results.querySelectorAll(':scope > [data-search-col]')].map((col) => col.getBoundingClientRect());
        const box = results.getBoundingClientRect();
        const line = getComputedStyle(results, '::before');
        // 搜索记录比推荐晚到,排在左栏末尾,到了也不把上面的往下推。
        const groups = [...results.querySelectorAll(':scope > [data-search-col]:first-child > [data-search-group]')];
        const history = groups.at(-1)!;
        return {
          box: box.height, leftWidth: left.width, rightWidth: right.width, line: parseFloat(line.height),
          historyLabel: history.querySelector('h3')?.textContent,
          historyAfter: history.getBoundingClientRect().top >= groups.at(-2)!.getBoundingClientRect().bottom,
          historyCount: history.querySelectorAll('[data-search-option]').length,
          offset: parseFloat(line.left) + parseFloat(line.width) / 2 - ((left.right + right.left) / 2 - box.left),
        };
      });
      assert.ok(split.rightWidth > split.leftWidth, '封面栏应比头像栏宽');
      assert.equal(split.historyLabel, '搜索记录', '左栏最后一组应是搜索记录');
      assert.equal(split.historyAfter, true, '搜索记录应排在左栏推荐之后');
      assert.equal(split.historyCount, 10);
      assert.ok(Math.abs(split.line - split.box) <= 1, `分隔线高 ${split.line}px,两栏高 ${split.box}px`);
      assert.ok(Math.abs(split.offset) <= 1, `分隔线偏离两栏之间的缝 ${split.offset}px`);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('资料表的标签只列前四个,余下的收进「+N」,浮层进顶层列全部标签', { timeout: 60_000 }, async () => {
    const opened = await openProfiledPerformer(browser, DESKTOP);
    try {
      const page = opened.page;
      const cell = await page.locator('[data-entity-facts] dd[data-fact="tags"]').evaluate((dd) => ({
        shown: [...dd.querySelectorAll(':scope > [data-fact-tag]')].map((tag) => tag.textContent!.trim()),
        more: dd.querySelector(':scope > [data-hero-more="fact"]')?.textContent?.trim() ?? '',
      }));
      assert.deepEqual(cell.shown, PROFILED.profile.tags.slice(0, 4), '标签那一格不是前四个');
      assert.equal(cell.more, '+5', '「+N」数的不是剩下那几个标签');
      const pop = page.locator('#entityTagPop');
      assert.equal(await pop.isVisible(), false);
      await page.locator('[data-hero-more="fact"]').hover();
      await pop.waitFor({ state: 'visible', timeout: 5_000 });
      const shown = await pop.evaluate((element) => {
        const box = element.getBoundingClientRect();
        return {
          topLayer: element.matches(':popover-open'),
          inView: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
          title: element.querySelector('[data-hero-pop-head]')?.textContent?.trim(),
          tags: [...element.querySelectorAll('[data-fact-tag]')].map((tag) => tag.textContent!.trim()),
        };
      });
      assert.equal(shown.topLayer, true, '标签浮层没进顶层,会被资料卡的 overflow:hidden 裁掉');
      assert.ok(shown.inView, '标签浮层越出了视口');
      assert.equal(shown.title, '9 个标签');
      assert.deepEqual(shown.tags, PROFILED.profile.tags);
      await page.mouse.move(1, 1);
      await pop.waitFor({ state: 'hidden', timeout: 5_000 });
      // 点一下钉住:指针离开也不收,Escape 才收。
      await page.locator('[data-hero-more="fact"]').click();
      await page.mouse.move(1, 1);
      await page.waitForTimeout(300);
      assert.equal(await pop.isVisible(), true, '点按钉住的标签浮层指针一走就收了');
      await page.keyboard.press('Escape');
      assert.equal(await pop.isVisible(), false, 'Escape 收不起标签浮层');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('换头像的加号量在圆框上:整行比圆框高时也贴着圆框右下角', { timeout: 60_000 }, async () => {
    const opened = await openProfiledPerformer(browser, { name: 'wide', width: 1440, height: 900, mobile: false });
    try {
      const page = opened.page;
      const button = page.locator('[data-entity-portrait-wrap] [data-avatar-picker] button');
      await button.waitFor({ timeout: 15_000 });
      // 行有多高看她有多少资料、别名那一行折不折;这里直接把身份列撑高,量的是加号跟不跟圆框。
      await page.addStyleTag({ content: '[data-entity-identity]{padding-block:40px}' });
      const box = await page.evaluate(() => {
        const rect = (element: Element) => element.getBoundingClientRect();
        const wrap = rect(document.querySelector('[data-entity-portrait-wrap]')!);
        const circle = rect(document.querySelector('[data-entity-portrait]')!);
        const plus = rect(document.querySelector('[data-entity-portrait-wrap] [data-avatar-picker] button')!);
        return { wrap: wrap.height, circle: { right: circle.right, bottom: circle.bottom, height: circle.height },
          plus: { right: plus.right, bottom: plus.bottom } };
      });
      assert.ok(box.wrap > box.circle.height + 8, `这一行没有比圆框高,用例量不出偏移(行 ${box.wrap},圆框 ${box.circle.height})`);
      // Tailwind 的 `right-1 bottom-1`:按钮离圆框外框右、下各 4px。
      assert.ok(Math.abs(box.circle.bottom - box.plus.bottom - 4) < 1, `加号没有贴着圆框底边:${JSON.stringify(box)}`);
      assert.ok(Math.abs(box.circle.right - box.plus.right - 4) < 1, `加号没有贴着圆框右边:${JSON.stringify(box)}`);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('女优页头深色主题:资料表与别名浮层跟着主题取墨色和浮层底', { timeout: 60_000 }, async () => {
    const opened = await openProfiledPerformer(browser, DESKTOP, 'dark');
    try {
      const page = opened.page;
      const ink = await tokenColor(page, '#main', '--ink');
      const muted = await tokenColor(page, '#main', '--muted');
      const ground = await tokenColor(page, '#main', '--ground');
      const facts = await page.locator('[data-entity-facts]').evaluate((dl) => ({
        dd: getComputedStyle(dl.querySelector('dd')!).color, dt: getComputedStyle(dl.querySelector('dt')!).color,
      }));
      assert.equal(facts.dd, ink, '深色下资料表的值不是墨色');
      assert.equal(facts.dt, muted, '深色下资料表的项名不是次级字色');
      await page.locator('[data-hero-more="alias"]').hover();
      const pop = page.locator('#entityAliasPop');
      await pop.waitFor({ state: 'visible', timeout: 5_000 });
      const { face, shadow } = await pop.evaluate((element) => {
        const style = getComputedStyle(element);
        return { face: style.backgroundColor, shadow: style.boxShadow };
      });
      assert.notEqual(face, 'rgba(0, 0, 0, 0)');
      assert.notEqual(face, 'rgb(255, 255, 255)', '深色下别名浮层还是白底');
      assert.equal(shadow, await popmenuShadow(page), '深色下别名浮层的落影和 .popmenu 不是同一副');
      assert.notEqual(ground, 'rgb(255, 255, 255)', '主题没有切到深色');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('详情出演区每一组的标题与头像共用一条左边缘,名字在头像正下方居中', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/', DESKTOP);
    try {
      const page = opened.page;
      /* 演示库的作品没有女优与厂牌,身份数据按 `/api/item` 的 `entity_refs` 形状换进去;
         名字比头像宽的那种最容易看出错位,所以女优名取四个字。 */
      await page.route(/\/api\/item\?/, async (route) => {
        const payload = await (await route.fetch()).json();
        Object.assign(payload, {
          is_jav: true, performers: ['凉森玲梦'], studio: 'Prestige', creator: '',
          entity_refs: {
            creator: [], series: [],
            performer: [{ id: 90_101, name: '凉森玲梦', has_image: true }],
            studio: [{ id: 90_102, name: 'Prestige', has_image: false, has_logo: true }],
          },
        });
        await route.fulfill({ json: payload });
      });
      const square = { contentType: 'image/svg+xml',
        body: '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#888"/></svg>' };
      await page.route(/\/entity-image\?/, (route) => route.fulfill(square));
      await page.route(/\/logo\?/, (route) => route.fulfill(square));
      await page.goto(new URL(`/assets/${requiredEnv('PEACH_E2E_ITEM')}`, page.url()).href, { waitUntil: 'load' });
      await page.locator('[data-item-identity] [data-id-group="studio"] [data-id-name]').waitFor({ timeout: 15_000 });
      const edges = await page.evaluate(() => Object.fromEntries(
        ['performer', 'studio'].map((kind) => {
          const group = document.querySelector(`[data-item-identity] [data-id-group="${kind}"]`)!;
          const name = document.createRange();
          name.selectNodeContents(group.querySelector('[data-id-name]')!);
          const face = group.querySelector('[data-id-face]')!.getBoundingClientRect();
          const text = name.getBoundingClientRect();
          return [kind, {
            label: group.querySelector('[data-id-label]')!.getBoundingClientRect().left,
            face: face.left, faceCenter: face.left + face.width / 2,
            nameCenter: text.left + text.width / 2,
          }];
        })));
      for (const [kind, edge] of Object.entries(edges)) {
        assert.ok(Math.abs(edge.face - edge.label) < 0.5, `${kind} 组头像左缘 ${edge.face} 不在标题左缘 ${edge.label}`);
        assert.ok(Math.abs(edge.nameCenter - edge.faceCenter) < 0.5,
          `${kind} 组名字中线 ${edge.nameCenter} 不在头像中线 ${edge.faceCenter}`);
      }
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('人物页同台艺人的头像悬停和首页顶栏女优头像同一副:抬整格填充、不描圈', { timeout: 60_000 }, async () => {
    const opened = await openPerformer(browser, DESKTOP);
    try {
      const page = opened.page;
      const person = page.locator('[data-entity-foot] [data-related-performer]').first();
      const face = () => person.evaluate((element) => {
        const style = getComputedStyle(element), ring = getComputedStyle(element.querySelector('[data-hero-ring]')!);
        return { fill: style.backgroundColor, ink: style.color, radius: style.borderRadius, padding: style.padding,
          width: style.width, ring: ring.boxShadow, size: ring.width };
      });
      await page.mouse.move(0, 0);
      const rest = await face();
      await person.hover();
      const hovered = await face();
      // 首页那一格(board.css「首页顶部两排」):76px 宽、6/4px 内边距、12px 圆角,悬停铺
      // primary-hover、字换主文字色,48px 圆头像不另描圈。
      const home = {
        fill: await tokenColor(page, '#main', '--color-background-primary-hover'),
        ink: await tokenColor(page, '#main', '--color-text-primary'),
      };
      assert.equal(rest.fill, 'rgba(0, 0, 0, 0)', '没悬停就垫了底');
      assert.deepEqual({ radius: hovered.radius, padding: hovered.padding, width: hovered.width, size: hovered.size },
        { radius: '12px', padding: '6px 4px', width: '76px', size: '48px' }, '同台艺人那一格和首页头像格不是同一副几何');
      assert.equal(hovered.fill, home.fill, '悬停没有铺首页那一档填充');
      assert.equal(hovered.ink.replace(/\s/g, ''), home.ink.replace(/\s/g, ''), '悬停没有换成主文字色');
      assert.equal(hovered.ring, 'none', '悬停还在给圆头像描圈');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('资料卡大位与同台艺人的头像按人脸框放大:图保持原比例、盖满圆框,不被预检的 max-width 夹住', { timeout: 60_000 }, async () => {
    const name = '七沢みあ';
    const opened = await visit(browser, '/', DESKTOP);
    try {
      const page = opened.page;
      const focus = { box: TASTE_FACE };
      await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
        id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [],
        user_aliases: [], asset_count: 0, tags: [], links: [], metadata: {}, entry_links: [],
        related_performers: [{ id: 90_002, k: '共演者', n: 1, rep: null, has_image: true, has_avatar: false,
          avatar_focus: focus }],
        has_image: true, has_avatar: false, avatar_focus: focus, representative_asset_id: null,
      } }));
      await page.route(/\/entity-image\?/, (route) => route.fulfill({
        contentType: 'image/svg+xml',
        body: `<svg xmlns="http://www.w3.org/2000/svg" width="${TASTE_FACE.imgW}" height="${TASTE_FACE.imgH}">`
          + `<rect width="${TASTE_FACE.imgW}" height="${TASTE_FACE.imgH}" fill="#888"/></svg>`,
      }));
      await page.goto(new URL(`/performers/${encodeURIComponent(name)}`, page.url()).href, { waitUntil: 'load' });
      for (const [where, selector] of [['大位', '[data-entity-portrait] > img[data-facebox]'],
        ['同台艺人', '[data-hero-ring] > img[data-facebox]']] as const) {
        const img = page.locator(selector).first();
        await img.waitFor({ state: 'attached', timeout: 15_000 });
        // 放大是图加载后 `avatarFrame` 写进内联 style 的;等到那一步落地再量。
        await page.waitForFunction((element) => element instanceof HTMLImageElement
          && element.complete && element.naturalWidth > 0 && element.style.width !== '',
        await img.elementHandle(), { timeout: 15_000 });
        const frame = await img.evaluate((element) => {
          const ring = element.parentElement!.getBoundingClientRect();
          const box = element.getBoundingClientRect();
          return {
            maxWidth: getComputedStyle(element).maxWidth, maxHeight: getComputedStyle(element).maxHeight,
            ring: ring.width, width: box.width, aspect: box.width / box.height,
            gaps: [box.left - ring.left, ring.right - box.right, box.top - ring.top, ring.bottom - box.bottom],
            initial: getComputedStyle(element.parentElement!.querySelector('span')!).display,
          };
        });
        assert.deepEqual([frame.maxWidth, frame.maxHeight], ['none', 'none'], `${where}的图被预检的 max-width 夹住`);
        assert.ok(frame.width > frame.ring, `${where}没有按人脸框放大:图宽 ${frame.width}px,圆框 ${frame.ring}px`);
        assert.ok(Math.abs(frame.aspect - TASTE_FACE.imgW / TASTE_FACE.imgH) < .02, `${where}的图宽高比 ${frame.aspect},被压扁了`);
        assert.ok(frame.gaps.every((gap) => gap <= .5), `${where}的图没盖满圆框:${frame.gaps.join('、')}`);
        if (where === '大位') assert.equal(frame.initial, 'none', '大位有图时首字母没有让位');
      }
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of [DESKTOP, MOBILE]) {
    it(`资料卡外链的圆盘不垫底、社媒字形铺满圆盘;手机上那一排不换行、横滑,滚动层顶到卡沿(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openProfiledPerformer(browser, viewport);
      try {
        const row = await opened.page.locator('[data-entity-links]').evaluate((element) => {
          const style = getComputedStyle(element);
          const card = element.closest('[data-entity-card]')!.getBoundingClientRect();
          const box = element.getBoundingClientRect();
          const brand = element.querySelector('[data-link-icon="brand"]')!;
          const glyph = brand.querySelector('svg')!.getBoundingClientRect(), plate = brand.getBoundingClientRect();
          return {
            wrap: style.flexWrap, overflowX: style.overflowX, justify: style.justifyContent,
            edges: [box.left - card.left, card.right - box.right],
            discs: [...element.querySelectorAll('[data-link-icon]')].map((disc) => getComputedStyle(disc).backgroundColor),
            glyph: [Math.round(glyph.width), Math.round(glyph.height)], plate: [Math.round(plate.width), Math.round(plate.height)],
          };
        });
        // 垫一层底会让圆盘比周围暗一档,看着像这条链接被禁用了。
        assert.ok(row.discs.every((fill) => fill === 'rgba(0, 0, 0, 0)'), `外链圆盘垫了底色:${row.discs.join('、')}`);
        assert.deepEqual(row.glyph, row.plate, '社媒字形没有铺满圆盘,场色的角露出底');
        if (viewport.mobile) {
          // 普通 `center` 在溢出时把前半排推到滚动起点之前,那几条滑不到。
          assert.deepEqual([row.wrap, row.overflowX, row.justify], ['nowrap', 'auto', 'safe center'], '窄屏外链不是一行横滑');
          assert.ok(row.edges.every((gap) => Math.abs(gap) < 0.5), `窄屏外链的滚动层没有顶到卡沿:${row.edges.join('、')}`);
        } else {
          assert.equal(row.wrap, 'wrap', '宽屏外链该换行,不横滑');
        }
        assert.deepEqual(opened.problems, []);
      } finally {
        await opened.close();
      }
    });

    it(`名字菜单:当前统称抬一档底色并打勾,其余行的勾位空着;手机上开关画 32px、命中区 44px,菜单行 44px(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openProfiledPerformer(browser, viewport);
      try {
        const page = opened.page;
        const toggle = page.locator('[data-namepick-toggle]');
        const hit = await toggle.evaluate((element) => {
          const box = element.getBoundingClientRect(), after = getComputedStyle(element, '::after');
          return { drawn: [Math.round(box.width), Math.round(box.height)], hit: [after.width, after.height] };
        });
        assert.deepEqual(hit.drawn, [32, 32], '名字旁那枚开关画出来不是 32px');
        if (viewport.mobile) assert.deepEqual(hit.hit, ['44px', '44px'], '手机上开关的命中区不到 44px');
        await toggle.click();
        await page.locator('[data-namepick-menu]').waitFor({ state: 'visible', timeout: 5_000 });
        const rows = await page.locator('[data-namepick-menu] [role="menuitemradio"]').evaluateAll((items) => items.map((item) => ({
          checked: item.getAttribute('aria-checked') === 'true', fill: getComputedStyle(item).backgroundColor,
          tick: getComputedStyle(item.querySelector('svg')!).visibility, height: item.getBoundingClientRect().height,
          menu: getComputedStyle(item.closest('[data-namepick-menu]')!).backgroundColor,
        })));
        assert.deepEqual(rows.map((row) => row.checked), [true, false], '菜单里当前统称不是第一行,或不止一行被选中');
        const [current, other] = rows;
        assert.notEqual(current!.fill, 'rgba(0, 0, 0, 0)', '当前统称那一行没有抬底');
        assert.notEqual(current!.fill, current!.menu, '当前统称那一行的底色和菜单面同色,看不出来');
        assert.deepEqual([current!.tick, other!.tick], ['visible', 'hidden'], '勾没有只给当前统称,或未选中那行不留勾位');
        assert.equal(other!.fill, 'rgba(0, 0, 0, 0)', '未选中那一行也抬了底');
        const least = viewport.mobile ? 44 : 36;
        const alias = await page.locator('[data-namepick-alias]').evaluate((item) => item.getBoundingClientRect().height);
        assert.ok([...rows.map((row) => row.height), alias].every((height) => height >= least - 0.5), `菜单行矮于 ${least}px`);
        assert.deepEqual(opened.problems, []);
      } finally {
        await opened.close();
      }
    });
  }

  /* 尺寸按身份列有哪几行定:外链与看片那一行都在是 160px,缺一行是 120px。取行高百分比的话
     圆框宽、列宽、别名折行、行高绕成一个圈,折行多出的那截压进卡底内边距。
     有资料的女优默认只有外链那一行,两行都在的那位要补上看片标识与订阅开关;七沢みあ只有看片那一行。 */
  for (const viewport of VIEWPORTS) {
    it(`分类图标与视频数量同排,分类位于身份行最左侧(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openProfiledPerformer(browser, viewport, 'light', { identity_labels: ['女优','西方'] });
      try {
        const geometry = await opened.page.locator('[data-entity-alias="meta"]').evaluate((element) => {
          const classification = element.querySelector('[data-identity-classification]')!;
          const count = classification.nextElementSibling!;
          const label = classification.getBoundingClientRect(), video = count.getBoundingClientRect();
          const glyph = classification.querySelector('svg')!.getBoundingClientRect();
          // 行内字形取 `--icon-inline`,是所在文字字号的 1.2 倍。
          const inline = parseFloat(getComputedStyle(classification.querySelector('svg')!).fontSize) * 1.2;
          return { first: element.firstElementChild === classification, top: [label.top, video.top],
            edges: [label.right, video.left], glyph: [glyph.width, glyph.height].map(side => Math.abs(side - inline) < 0.5) };
        });
        assert.equal(geometry.first, true);
        assert.ok(Math.abs(geometry.top[0]! - geometry.top[1]!) < 1, '分类与视频数量另起了行');
        assert.ok(geometry.edges[0]! <= geometry.edges[1]!, '分类没有排在视频数量左侧');
        assert.deepEqual(geometry.glyph, [true, true]);
        const measured = await layout(opened.page);
        assert.ok(measured.scrollWidth <= measured.viewportWidth + 1);
        assert.deepEqual(measured.offenders, []);
        assert.deepEqual(opened.problems, []);
      } finally {
        await opened.close();
      }
    });
  }
  const openBothRows = (browser: Browser, viewport: typeof DESKTOP) => openProfiledPerformer(browser, viewport, 'light', {
    entry_links: [
      { site: 'minnano-av', label: 'みんなのAV', ordinal: '', slot: 'pill', mark: 'brand-minnano',
        url: 'https://www.minnano-av.com/actress12345.html' },
      { site: 'javdb', label: 'JavDB', ordinal: '', slot: 'mark', mark: 'mark-javdb', url: 'https://javdb.com/actors/NPD3' },
    ],
    feed: { following: false },
  });
  for (const [label, open, size] of [
    ['外链与看片那一行都在', openBothRows, 160],
    ['只有看片那一行', openPerformer, 120],
    ['只有外链那一行', openProfiledPerformer, 120],
  ] as const) {
    it(`资料卡大位按身份列的行数定尺寸:${label}是 ${size}px 正圆,身份列不压进卡底内边距`, { timeout: 60_000 }, async () => {
      const opened = await open(browser, DESKTOP);
      try {
        const box = await opened.page.locator('[data-entity-portrait]').evaluate((element) => {
          const portrait = element.getBoundingClientRect();
          const profile = element.closest('[data-entity-profile]')!.getBoundingClientRect();
          const identity = element.closest('[data-entity-profile]')!.querySelector('[data-entity-identity]')!.getBoundingClientRect();
          return { width: portrait.width, height: portrait.height, room: profile.bottom - identity.bottom };
        });
        assert.ok(Math.abs(box.height - size) < 1, `头像 ${box.height}px,应是 ${size}px`);
        assert.ok(Math.abs(box.width - box.height) < 1, '头像不是正圆');
        assert.ok(box.room >= 19.5, `身份列离卡底只剩 ${box.room}px,压进了 20px 的内边距`);
        assert.deepEqual(opened.problems, []);
      } finally {
        await opened.close();
      }
    });
  }

  it('看片那一行:两枚标识隔 32px、按 22px 字高对齐;MISSAV 按它站上的排字,AV 用它的粉;第二枚带序号', { timeout: 60_000 }, async () => {
    const name = '七沢みあ';
    const opened = await visit(browser, `/performers/${encodeURIComponent(name)}`, DESKTOP, async page => {
      await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
        id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [],
        user_aliases: [], asset_count: 0, tags: [], related_performers: [], links: [], metadata: {},
        has_image: false, has_avatar: false, avatar_focus: null, representative_asset_id: null,
        entry_links: [
          { site: 'javdb', label: 'JavDB', ordinal: '', slot: 'mark', mark: 'mark-javdb', url: 'https://javdb.com/actors/NPD3' },
          { site: 'missav', label: 'MISSAV', ordinal: '2', slot: 'mark', mark: '', url: 'https://missav.ws/actresses/x' },
        ],
      } }));
    });
    try {
      const page = opened.page;
      await page.locator('[data-missav-mark]').waitFor({ timeout: 15_000 });
      const marks = await page.locator('[data-entry-marks]').evaluate((row) => {
        const wordmark = row.querySelector('[data-missav-mark]')!;
        const [miss, av] = [...wordmark.children].map((part) => getComputedStyle(part).color);
        return {
          gap: getComputedStyle(row).columnGap,
          javdb: row.querySelector('[data-entry-mark] svg')!.getBoundingClientRect().height,
          family: getComputedStyle(wordmark).fontFamily, weight: getComputedStyle(wordmark).fontWeight,
          miss, av, ink: getComputedStyle(wordmark.closest('a')!).color,
          ordinal: row.querySelector('[data-entry-ordinal]')?.textContent ?? '',
          hrefs: [...row.querySelectorAll('a[data-entry-mark]')].map((a) => a.getAttribute('href')),
        };
      });
      assert.equal(marks.gap, '32px', '两枚标识的间距不是 32px');
      assert.equal(Math.round(marks.javdb), 22, 'JavDB 标识的字高不是 22px');
      assert.match(marks.family, /^Halant/, 'MISSAV 没有用 Halant 排字');
      assert.equal(marks.weight, '500');
      assert.equal(marks.miss, marks.ink, 'MISS 那半没有跟页面墨色');
      assert.equal(marks.av, 'rgb(254, 98, 142)', 'AV 那半不是 MISSAV 的粉');
      assert.equal(marks.ordinal, '2', '第二枚没带服务端编好的序号');
      assert.deepEqual(marks.hrefs, ['https://javdb.com/actors/NPD3', 'https://missav.ws/actresses/x'], '入口地址不是服务端下发的那个');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('厂牌页资料卡:标识铺满方框;指回自家站的官网写「官方网站」,带站点圆标、字距 .02em', { timeout: 60_000 }, async () => {
    const name = 'S1 NO.1 STYLE';
    const opened = await visit(browser, '/', DESKTOP);
    try {
      const page = opened.page;
      await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
        id: 90_601, kind: 'studio', canonical_name: name, aliases: [], display_aliases: [], user_aliases: [],
        asset_count: 3, tags: [], related_performers: [], labels: [], metadata: {}, entry_links: [],
        has_image: false, has_avatar: false, has_logo: true, avatar_focus: null, representative_asset_id: null,
        links: [{ link_id: 90_602, link_kind: 'official', clickable: true, label: 'S1', url: 'https://www.s1s1s1.com/' }],
      } }));
      // 标识比方框大,走铺满那条;比框小的走原尺寸居中,是另一条判据。
      await page.route(/\/logo\?/, (route) => route.fulfill({
        contentType: 'image/svg+xml',
        body: '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600"><rect width="600" height="600" fill="#888"/></svg>',
      }));
      await page.route('**/link-mark**', (route) => route.fulfill({
        status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
      }));
      await page.goto(new URL(`/studios/${encodeURIComponent(name)}`, page.url()).href, { waitUntil: 'load' });
      const logo = page.locator('[data-entity-portrait] > img');
      await logo.waitFor({ state: 'attached', timeout: 15_000 });
      await page.waitForFunction((element) => element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0,
        await logo.elementHandle(), { timeout: 15_000 });
      const face = await logo.evaluate((element) => {
        const frame = element.parentElement!, box = element.getBoundingClientRect(), outer = frame.getBoundingClientRect();
        return { shape: frame.getAttribute('data-entity-portrait'), radius: getComputedStyle(frame).borderTopLeftRadius,
          fit: getComputedStyle(element).objectFit,
          gaps: [box.left - outer.left, outer.right - box.right, box.top - outer.top, outer.bottom - box.bottom] };
      });
      assert.equal(face.shape, 'square');
      assert.notEqual(face.radius, '50%', '厂牌的标识框画成了圆');
      // 标识走「图比框小就别放大」那组:contain 不裁字标,比框大的按框收,所以照样铺满。
      assert.equal(face.fit, 'contain', '厂牌标识被裁成了铺满');
      assert.ok(face.gaps.every((gap) => Math.abs(gap) < 0.5), `标识没盖满方框:${face.gaps.join('、')}`);
      const site = await page.locator('[data-entity-links] a[data-link="url"]').evaluate((a) => ({
        text: a.querySelector('[data-link-label]')?.textContent ?? '',
        spacing: parseFloat(getComputedStyle(a).letterSpacing) / parseFloat(getComputedStyle(a).fontSize),
        mark: a.querySelector('[data-link-icon] img')?.getAttribute('src') ?? '',
        referrer: a.querySelector('[data-link-icon] img')?.getAttribute('referrerpolicy') ?? '',
      }));
      assert.equal(site.text, '官方网站');
      assert.ok(Math.abs(site.spacing - 0.02) < 0.001, `官网那一格的字距是 ${site.spacing}em`);
      assert.deepEqual([site.mark, site.referrer], ['/link-mark?id=90602', 'no-referrer'], '官网没带本机合成的站点圆标');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('人物页首帧保留新作和同台艺人的高度,文字先呈现、封面独立加载', { timeout: 60_000 }, async () => {
    const name = '七沢みあ';
    const opened = await visit(browser, '/', DESKTOP);
    let releaseCover = () => {};
    try {
      await opened.page.route(/\/api\/entity\/shapes/, (route) => route.fulfill({ json: {
        ok: true, entities: [{ id: 90_001, kind: 'performer', names: [name], parts: ['feed', 'costars'] }] } }));
      // 骨架插进页面的那一刻就记下它带着哪几块:之后才补进去的,就是在骨架里跳了一下。
      await opened.page.addInitScript(() => {
        const first = { feed: null as boolean | null, foot: null as boolean | null, sheen: '' };
        (window as unknown as { skeletonFirst: typeof first }).skeletonFirst = first;
        new MutationObserver(() => {
          const skeleton = document.querySelector('[data-skeleton="entity/performer"]');
          if (!skeleton || first.feed !== null) return;
          first.feed = !!skeleton.querySelector('.feednew');
          first.foot = !!skeleton.querySelector('.entityfoot');
          // 骨架卡的微光和骨架同一帧就在:晚一步的话,那一步里露出来的是封面格的黑底。
          const pic = skeleton.querySelector('.feednewskeleton .pic.imgwait');
          first.sheen = pic ? getComputedStyle(pic, '::after').opacity : '';
        }).observe(document, { childList: true, subtree: true });
        // 真实库上启动脚本发出名单请求后还要连续跑四五百毫秒,名单的响应就在这段时间里到、
        // 排在队里。这里在发请求的同一个任务末尾占住主线程 700ms,把那一段复现出来。
        const fetch = window.fetch;
        let blocked = false;
        window.fetch = (...args) => {
          if (!blocked && String(args[0]).includes('/api/entity/shapes')) {
            blocked = true;
            queueMicrotask(() => { const end = performance.now() + 700; while (performance.now() < end); });
          }
          return fetch(...args);
        };
      });
      let release = () => {};
      const held = new Promise<void>((resolve) => { release = resolve; });
      await opened.page.route(/\/api\/entity\?/, async (route) => {
        await held;
        await route.fulfill({ json: {
          id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [],
          user_aliases: [], asset_count: 0, tags: [],
          related_performers: [{ id: 90_002, k: '共演者', n: 1, rep: null, has_image: false,
            has_avatar: false, avatar_focus: null }],
          links: [], metadata: {}, has_image: false, has_avatar: false, avatar_focus: null,
          representative_asset_id: null, entry_links: [], feed: { following: true },
        } });
      });
      await opened.page.route(/\/api\/feeds\/discoveries\?/, (route) => route.fulfill({ json: {
        ok: true, more: false, items: [{
          id: 1, code: 'ABC-001', title: '标题', link: 'https://javdb.com/v/x', cover_url: null,
          has_cover: true, cover_frame: null, poster_box: null, release_date: '2026-09-01',
          studio: '厂牌', performers: name, source_name: '', read: false, ignored: false,
          scrape_error: null }] } }));
      // 封面尚未返回时,身份、标题和操作仍应可见。
      const coverWaiting = new Promise<void>(resolve => { releaseCover = resolve });
      await opened.page.route(/\/cover\?code=ABC-001/, async (route) => {
        await coverWaiting;
        await route.fulfill({ contentType: 'image/png', body: Buffer.from(PIXEL, 'base64') });
      });
      await opened.page.goto(new URL(`/performers/${encodeURIComponent(name)}`,
        opened.page.url()).href, { waitUntil: 'load' });
      const skeleton = opened.page.locator('[data-skeleton="entity/performer"]');
      await skeleton.locator('.feednew .feednewskeleton').first().waitFor({ timeout: 15_000 });
      const before = await skeleton.evaluate((element) => {
        const row = element.querySelector('.feednew')!;
        const foot = element.querySelector('.entityhero > .entityfoot');
        // 同一个 `.pic.imgwait` 放在新作那一行外面,它的微光就是全站等待态那一种。
        const probe = document.createElement('div');
        probe.className = 'pic imgwait';
        document.querySelector('#main')!.append(probe);
        const plain = getComputedStyle(probe, '::after').backgroundImage;
        probe.remove();
        return { height: row.getBoundingClientRect().height,
          footHeight: foot?.getBoundingClientRect().height ?? 0,
          between: !!(row.previousElementSibling?.matches('[data-filter-frame]') || row.previousElementSibling?.querySelector('[data-filter-frame]'))
            && !!row.nextElementSibling?.matches('.entitysection'),
          sheen: getComputedStyle(row.querySelector('.pic.imgwait')!, '::after').backgroundImage, plain,
          first: (window as unknown as { skeletonFirst: { feed: boolean; foot: boolean; sheen: string } }).skeletonFirst };
      });
      assert.ok(before.between, '骨架里的新作那一行不在筛选框和作品之间');
      const { sheen, ...parts } = before.first;
      assert.deepEqual(parts, { feed: true, foot: true }, '骨架先画了一版,新作行或同台艺人是后来才补进去的');
      assert.equal(sheen, '1', '新作骨架卡的微光晚于骨架出现,中间露出封面格的黑底');
      assert.ok(before.footHeight > 0, '骨架的资料卡底没有同台艺人那一条');
      assert.equal(before.sheen, before.plain, '新作骨架的微光另起了一种颜色');
      // 身份就绪后新作可以独立等待;真卡出现后不能再退回骨架。
      await opened.page.evaluate(() => {
        const seen = { ready: false, second: false };
        (window as unknown as { feedSeen: typeof seen }).feedSeen = seen;
        new MutationObserver(() => {
          if (document.querySelector('[data-feed-new] [data-feed-id]')) seen.ready = true;
          if (seen.ready && document.querySelector('[data-feed-new] .feednewskeleton')) seen.second = true;
        }).observe(document.querySelector('#index')!, { childList: true, subtree: true });
      });
      release();
      const row = opened.page.locator('[data-feed-new]');
      await row.locator('[data-feed-id]').waitFor({ timeout: 15_000 });
      const after = await row.evaluate((element) => ({
        height: element.getBoundingClientRect().height, busy: element.getAttribute('aria-busy'),
        waiting: element.querySelectorAll('[data-feed-id] .pic.imgwait').length,
        footHeight: document.querySelector('[data-entity-card] > [data-entity-foot]')?.getBoundingClientRect().height ?? 0,
        second: (window as unknown as { feedSeen: { second: boolean } }).feedSeen.second }));
      assert.equal(after.height, before.height, '占位行和到货的那一行不一样高,下面的作品网格会跳');
      assert.equal(after.footHeight, before.footHeight, '同台艺人那一条占位和真的不一样高,资料卡会伸缩');
      assert.equal(after.busy, null);
      assert.equal(after.second, false, '整页画出来之后新作那一行又单独骨架了一轮');
      assert.equal(after.waiting, 1, '封面独立等待时保留卡片标题和操作');
      releaseCover();
      await opened.page.waitForFunction(() => !document.querySelector('[data-feed-id] .pic.imgwait'));
      assert.equal(await row.locator('[data-feed-id] img').first().evaluate(image =>
        (image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0), true);
      assert.deepEqual(opened.problems, []);
    } finally {
      releaseCover();
      await opened.close();
    }
  });

  it('卡片悬停面不顶到邻卡,三处卡片网格同一副列距', { timeout: 60_000 }, async () => {
    const opened = await openCatalog(browser);
    try {
      const card = opened.page.locator('#grid [data-media-grid] > [data-media-card]').first();
      await card.hover();
      const geometry = await card.evaluate((element) => {
        const box = element.getBoundingClientRect();
        const neighbor = [...element.parentElement!.children].find((other) => other !== element
          && Math.abs(other.getBoundingClientRect().top - box.top) < 1)!;
        const spread = Number(/0px 0px 0px (\d+(?:\.\d+)?)px/.exec(getComputedStyle(element).boxShadow)?.[1]);
        // 壳画的骨架网格和关注页岛的视频列表不在首页这叠卡里:各挂一个同形的空壳读列距。
        const columnGap = (build: (probe: HTMLElement) => HTMLElement) => {
          const probe = document.createElement('div');
          const host = build(probe);
          document.querySelector('#main')!.append(host);
          const gap = parseFloat(getComputedStyle(probe).columnGap);
          host.remove();
          return gap;
        };
        return {
          spread, clearance: neighbor.getBoundingClientRect().left - (box.right + spread),
          home: parseFloat(getComputedStyle(element.parentElement!).columnGap),
          skeleton: columnGap((probe) => { probe.className = 'grid'; return probe }),
          follow: columnGap((probe) => {
            const island = document.createElement('div');
            island.className = 'peach-react';
            probe.setAttribute('data-follow-list', '');
            island.append(probe);
            return island;
          }),
        };
      });
      assert.ok(geometry.spread > 0, '卡片悬停面没有往盒外铺');
      assert.ok(geometry.clearance >= geometry.spread,
        `悬停面离邻卡只剩 ${geometry.clearance}px,比它自己往外铺的 ${geometry.spread}px 还窄`);
      assert.equal(geometry.skeleton, geometry.home, '壳画的骨架网格的列距和首页不一样,真卡换上来时整排挪位');
      assert.equal(geometry.follow, geometry.home, '关注页视频列表的列距和首页不一样');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('相邻两张稍后看卡的底色之间隔着整段列距', { timeout: 60_000 }, async () => {
    const opened = await openCatalogFixture(browser, (payload) => {
      if (payload.items.length < 2) throw new Error('演示目录不足两张卡');
      for (const item of payload.items.slice(0, 2)) item.watch_later = true;
    });
    try {
      await opened.page.mouse.move(0, 0);
      const geometry = await opened.page.evaluate(() => {
        const [first, second] = [...document.querySelectorAll<HTMLElement>('#grid [data-media-grid] > [data-media-card][data-watch-later]')];
        const spread = (element: HTMLElement) => Number(/0px 0px 0px (\d+(?:\.\d+)?)px/.exec(getComputedStyle(element).boxShadow)?.[1] ?? 0);
        return {
          sameRow: Math.abs(first!.getBoundingClientRect().top - second!.getBoundingClientRect().top) < 1,
          clearance: second!.getBoundingClientRect().left - spread(second!) - (first!.getBoundingClientRect().right + spread(first!)),
          columnGap: parseFloat(getComputedStyle(first!.parentElement!).columnGap),
        };
      });
      assert.ok(geometry.sameRow, '前两张稍后看卡不在同一排');
      assert.equal(geometry.clearance, geometry.columnGap, `两张稍后看卡的底色之间只剩 ${geometry.clearance}px`);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('跳过渲染的元信息区不裁掉贴着边的头像悬停描边和焦点环', { timeout: 60_000 }, async () => {
    /* 演示库的作品都未归属,头像是不可聚焦的 `<span>`;首张卡归给一位女优,头像才是按钮。 */
    const opened = await openCatalogFixture(browser, (payload) => {
      const item = payload.items[0];
      if (!item) throw new Error('演示目录没有可替换的卡片');
      item.creator = '';
      item.performers = ['演示演员'];
      item.performer_total = 1;
      item.performer_entities = [{ id: 90_000, name: '演示演员', has_image: false }];
    });
    try {
      /* 视口外跳过渲染连带 paint containment,元信息区里画出 padding box 的像素一律裁掉;
         几何照算,所以这里比的是描边外沿与 padding box,不是与内容盒。头像贴着内容盒的
         左缘和上缘。这条不读 `overflow-clip-margin`:Safari 不认它,裁切边只能靠盒子本身。 */
      const { page } = opened;
      const avatar = page.locator('#grid [data-media-grid] > [data-media-card] [data-media-meta] > button[data-media-avatar]').first();
      const edges = () => avatar.evaluate((element) => {
        const meta = element.closest('[data-media-meta]')!;
        const style = getComputedStyle(element);
        const shadow = Number(/0px 0px 0px (\d+(?:\.\d+)?)px/.exec(style.boxShadow)?.[1] ?? 0);
        const outline = style.outlineStyle === 'none' ? 0
          : parseFloat(style.outlineWidth) + parseFloat(style.outlineOffset);
        const ring = Math.max(shadow, outline);
        const box = element.getBoundingClientRect();
        const clip = meta.getBoundingClientRect();
        const left = clip.left + meta.clientLeft, top = clip.top + meta.clientTop;
        return {
          contained: getComputedStyle(meta).contentVisibility === 'auto',
          focused: element.matches(':focus-visible'), ring,
          // 描边外沿到裁切边还剩多少,四边取最小的那一边。
          room: Math.min(box.left - ring - left, box.top - ring - top,
            left + meta.clientWidth - (box.right + ring), top + meta.clientHeight - (box.bottom + ring)),
        };
      });
      await avatar.hover();
      const hovered = await edges();
      assert.ok(hovered.contained, '首页卡片的元信息区不再跳过渲染:这条用例守的裁切前提变了,改用例');
      assert.ok(hovered.ring > 0, '头像悬停没有描边');
      assert.ok(hovered.room >= -.5, `头像悬停描边越过了元信息区的裁切边 ${-hovered.room}px,那一截会被裁掉`);
      await page.mouse.move(0, 0);
      await page.keyboard.press('Tab');
      await avatar.focus();
      const focused = await edges();
      assert.ok(focused.focused && focused.ring > 0, '键盘聚焦的头像没有焦点环');
      assert.ok(focused.room >= -.5, `头像焦点环越过了元信息区的裁切边 ${-focused.room}px,那一截会被裁掉`);
    } finally {
      await opened.close();
    }
  });

  it('头像圆框里的图按人脸框写进的内联尺寸不被岛里的预检夹回框宽', { timeout: 60_000 }, async () => {
    /* 演示库没有实体图,往首张卡的头像框里塞一张按人脸框放大的图:宽超过框宽时图就该那么宽,
       由圆框的 overflow 裁;被夹回框宽的话脸偏到左边,右侧露出底下的首字母。 */
    const opened = await openCatalogFixture(browser, (payload) => {
      const item = payload.items[0];
      if (!item) throw new Error('演示目录没有可替换的卡片');
      item.creator = '';
      item.performers = ['演示演员'];
      item.performer_total = 1;
      item.performer_entities = [{ id: 90_000, name: '演示演员', has_image: false }];
    });
    try {
      const avatar = opened.page.locator('#grid [data-media-grid] > [data-media-card] [data-media-meta] > button[data-media-avatar]').first();
      const framed = await avatar.evaluate((element) => {
        const img = document.createElement('img');
        img.setAttribute('style', 'position:absolute;inset:-25% auto auto 0;width:150%;height:195%');
        element.appendChild(img);
        const frame = element.getBoundingClientRect();
        const box = img.getBoundingClientRect();
        const style = getComputedStyle(img);
        return { maxWidth: style.maxWidth, maxHeight: style.maxHeight,
          width: box.width / frame.width, height: box.height / frame.height };
      });
      assert.equal(framed.maxWidth, 'none', '头像图还带着预检的 max-width');
      assert.equal(framed.maxHeight, 'none', '头像图还带着 max-height');
      assert.ok(Math.abs(framed.width - 1.5) <= .02, `按人脸框放大到 1.5 倍框宽的图被夹成了 ${framed.width} 倍`);
      assert.ok(Math.abs(framed.height - 1.95) <= .02, `按人脸框放大到 1.95 倍框高的图被夹成了 ${framed.height} 倍`);
    } finally {
      await opened.close();
    }
  });

  it('亮暗按钮保持尺寸与悬停面色,卡片和回执保持接触阴影', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/stats', DESKTOP);
    try {
      // 选中的那张页签按基线收掉接触阴影,读旁边没选中的一张。
      const card = opened.page.locator('#main [class~="shadow-card"]:not([data-selected])').first();
      await card.waitFor({ timeout: 15_000 });
      await settle(opened.page);
      const checkSecondaryButton = async () => {
        await opened.page.mouse.move(0, 0);
        const dimensions = await opened.page.evaluate(() => {
          const holder = document.createElement('div');
          holder.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:10000';
          const button = document.createElement('button');
          button.className = 'geist-button';
          button.dataset.buttonProbe = '';
          button.textContent = '操作';
          holder.append(button);
          document.body.append(holder);
          const style = getComputedStyle(button);
          return [style.boxSizing, style.height, style.paddingTop, style.paddingRight,
            style.borderTopWidth, style.display];
        });
        assert.deepEqual(dimensions, ['border-box', '36px', '8px', '12px', '1px', 'inline-flex']);
        const button = opened.page.locator('[data-button-probe]');
        try {
          const idle = await button.evaluate(element => getComputedStyle(element).backgroundColor);
          assert.equal(idle, await tokenColor(opened.page, 'body', '--color-background-primary-default'));
          const border = await button.evaluate(element => getComputedStyle(element).borderTopColor);
          await button.hover();
          await settle(opened.page);
          const hovered = await button.evaluate(element => getComputedStyle(element).backgroundColor);
          assert.equal(hovered, await tokenColor(opened.page, 'body', '--control-hover'));
          assert.equal(await button.evaluate(element => getComputedStyle(element).borderTopColor), border);
        } finally {
          await button.evaluate(element => element.parentElement!.remove());
        }
      };
      /* Tailwind 把阴影 token 的字面值抄进工具类,`.dark` 里改 `--shadow-*` 够不着它;
         旧样式表里写死的浅色阴影同样不跟主题走。读三类来源各一处的计算值。 */
      const alphas = () => opened.page.evaluate(() => {
        const strongest = (shadow: string) => Math.max(0, ...[...shadow.matchAll(
          /rgba\(0, 0, 0, ([\d.]+)\)|rgb\(0, 0, 0\)/g)].map((match) => (match[1] ? Number(match[1]) : 1)));
        const probe = (html: string, parent: Element = document.body) => {
          const holder = document.createElement('div');
          holder.innerHTML = html;
          const element = holder.firstElementChild!;
          // 挂在 React 岛外面:岛里的重置会把旧样式表的按钮阴影清掉。
          parent.append(element);
          const shadow = getComputedStyle(element).boxShadow;
          element.remove();
          return strongest(shadow);
        };
        return {
          card: strongest(getComputedStyle(document.querySelector('#main [class~="shadow-card"]:not([data-selected])')!).boxShadow),
          button: probe('<button class="geist-button" type="button">键</button>'),
          // Toast 的面只在 #toasts 里成立:阴影写在 Sonner 那一条的属性选择器上。
          toast: probe('<li data-sonner-toast data-styled="true">回执</li>', document.getElementById('toasts')!),
        };
      });
      await opened.page.evaluate(() => {
        document.documentElement.dataset.theme = 'light';
        document.documentElement.classList.remove('dark');
      });
      const light = await alphas();
      await checkSecondaryButton();
      // `web/app.js` 的 `applyTheme('dark')` 就是这两句;这里只借它换一次配色。
      await opened.page.evaluate(() => {
        document.documentElement.dataset.theme = 'dark';
        document.documentElement.classList.add('dark');
      });
      const dark = await alphas();
      await checkSecondaryButton();
      for (const key of Object.keys(light) as Array<keyof typeof light>) {
        assert.ok(light[key] > 0 && light[key] < .2, `浅色下 ${key} 的阴影 ${light[key]} 不在浅色那一档`);
        assert.ok(dark[key] > light[key] && dark[key] <= .2,
          `暗色 ${key} 阴影应保留接触感而不形成重边:${dark[key]}`);
      }
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of [DESKTOP, MOBILE]) {
    it(`JAV 订阅卡片包含分页与贴边底栏(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const page = opened.page;
        await page.route('**/api/feeds', route => route.fulfill({ json: {
          unread: 81, sources: Array.from({ length: 25 }, (_, i) => ({
            id: i + 1, kind: 'javdb-actor', kind_label: 'JAV 订阅', name: `测试订阅 ${i + 1}`,
            url: `https://example.com/actors/${i + 1}`, entity_id: null, entity_name: null,
            has_image: false, enabled: true, interval_minutes: 360, last_fetched_at: null,
            last_error: null, last_new_count: 0, seen: 0,
          })),
        } }));
        await page.reload({ waitUntil: 'load' });
        await page.getByRole('tab', { name: '关注列表', exact: true }).waitFor();
        const card = page.getByRole('region', { name: 'JAV 订阅列表', exact: true });
        await card.getByText('1–20 / 25 个订阅源', { exact: true }).waitFor();
        assert.equal(await card.locator('[role="row"][data-key]').count(), 20);
        await card.getByRole('button', { name: '下一页', exact: true }).click();
        await card.getByText('21–25 / 25 个订阅源', { exact: true }).waitFor();
        assert.equal(await card.locator('[role="row"][data-key]').count(), 5);
        for (const theme of ['light', 'dark']) {
          await page.evaluate(value => {
            document.documentElement.dataset.theme = value;
            document.documentElement.classList.toggle('dark', value === 'dark');
          }, theme);
          const surface = await card.evaluate(element => {
            const footer = element.querySelector('footer')!;
            const box = element.getBoundingClientRect();
            const bottom = footer.getBoundingClientRect();
            return { background: getComputedStyle(element).backgroundColor,
              footerBackground: getComputedStyle(footer).backgroundColor,
              left: bottom.left - box.left, right: box.right - bottom.right,
              bottom: box.bottom - bottom.bottom };
          });
          assert.notEqual(surface.background, 'rgba(0, 0, 0, 0)');
          assert.notEqual(surface.background, surface.footerBackground);
          for (const edge of ['left', 'right', 'bottom'] as const) assert.ok(Math.abs(surface[edge]) <= 1);
          const dimensions = await layout(page);
          assert.ok(dimensions.scrollWidth <= dimensions.viewportWidth + 1, JSON.stringify(dimensions));
        }
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });
    it(`关注分类使用 Pills,添加关注结果带描边(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const page = opened.page;
        const tabs = page.getByRole('tablist', { name: '关注管理区域' });
        for (const theme of ['light', 'dark']) {
          await page.evaluate((value) => {
            document.documentElement.dataset.theme = value;
            document.documentElement.classList.toggle('dark', value === 'dark');
          }, theme);
          await tabs.getByRole('tab', { name: '添加关注', exact: true }).click();
          await page.waitForFunction(() => {
            const host = document.querySelector('[role="tablist"][aria-label="关注管理区域"]')!;
            const selected = host.querySelector('[aria-selected=true]')!.getBoundingClientRect();
            const surface = host.querySelector('[data-pill-thumb]')?.getBoundingClientRect();
            return surface && Math.abs(selected.left - surface.left) <= 1
              && Math.abs(selected.width - surface.width) <= 1;
          });
          const face = await tabs.evaluate((element) => {
            const selected = element.querySelector('[data-pill-thumb]')!;
            return { track: getComputedStyle(element).backgroundColor,
              selected: getComputedStyle(selected).backgroundColor,
              shadow: getComputedStyle(selected).boxShadow };
          });
          assert.equal(face.track, 'rgba(0, 0, 0, 0)', 'Pills 容器应透明');
          assert.notEqual(face.selected, face.track, '当前分类应有独立底色');
          assert.equal(face.shadow, 'none', 'Pills 不使用浮起滑块');
          await tabs.getByRole('tab', { name: '添加关注', exact: true }).focus();
          await page.keyboard.press('Home');
          await page.keyboard.press('Enter');
          assert.equal(await tabs.getByRole('tab', { name: '关注列表', exact: true }).getAttribute('aria-selected'), 'true');
          const dimensions = await layout(page);
          assert.ok(dimensions.scrollWidth <= dimensions.viewportWidth + 1, JSON.stringify(dimensions));
        }
        await page.route('**/api/follow/resolve', (route) => route.fulfill({
          status: 200, contentType: 'application/json', body: JSON.stringify({
            status: 'done', results: [{ line: '主题测试来源', candidates: [{
              provider: 'kemono', provider_label: 'Kemono', label: '主题测试候选', url: 'https://example.com/creator',
            }] }],
          }),
        }));
        await tabs.getByRole('tab', { name: '添加关注', exact: true }).click();
        await page.getByRole('textbox', { name: '来源链接、名字或 id' }).fill('主题测试来源');
        await page.getByRole('region', { name: '添加创作者关注', exact: true }).getByRole('button', { name: '查找', exact: true }).click();
        const result = page.getByText('主题测试来源', { exact: true }).locator('..');
        await result.waitFor();
        for (const theme of ['light', 'dark']) {
          await page.evaluate((value) => {
            document.documentElement.dataset.theme = value;
            document.documentElement.classList.toggle('dark', value === 'dark');
          }, theme);
          const surface = await result.evaluate((element) => {
            const s = getComputedStyle(element);
            return { shadow: s.boxShadow, width: s.borderTopWidth, border: s.borderTopColor, bg: s.backgroundColor };
          });
          assert.equal(surface.shadow, 'none');
          assert.equal(surface.width, '1px');
          assert.notEqual(surface.border, surface.bg, '结果分组必须有可辨认边界');
        }
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });
    it(`Peach 配置的来源凭据按要求分组并带描边(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const page = opened.page;
        await page.getByRole('link', { name: /^来源与订阅设置/ }).click();
        await page.getByRole('tab', { name: '来源与订阅', exact: true, selected: true }).waitFor();
        await page.getByRole('region', { name: '可配置凭据', exact: true }).waitFor();
        await settle(page);
        for (const theme of ['light', 'dark']) {
          await page.evaluate(value => {
            document.documentElement.dataset.theme = value;
            document.documentElement.classList.toggle('dark', value === 'dark');
          }, theme);
          for (const name of ['可配置凭据', '不需要凭据', '暂不支持']) {
            const group = page.getByRole('region', { name, exact: true });
            const rows = await group.evaluate(element => {
              const style = getComputedStyle(element.lastElementChild!);
              return { shadow: style.boxShadow, width: style.borderTopWidth,
                border: style.borderTopColor, background: style.backgroundColor };
            });
            assert.equal(rows.shadow, 'none');
            assert.equal(rows.width, '1px');
            assert.notEqual(rows.border, rows.background);
          }
          assert.match(await page.getByRole('region', { name: '不需要凭据', exact: true }).innerText(), /Kemono/);
          assert.match(await page.getByRole('region', { name: '暂不支持', exact: true }).innerText(), /OnlyFans/);
          const dimensions = await layout(page);
          assert.ok(dimensions.scrollWidth <= dimensions.viewportWidth + 1, JSON.stringify(dimensions));
        }
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });
  }
});