Ctrl K
ComunidadGuías…

frontend/e2e/design-controls.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, settle } from './harness.ts';
import { ITEM, openItemPage } from './item-fixture.ts';
import {
  DESKTOP, MOBILE, tokenColor, openAccess, settledRun, openActivity, qualityGoal, openQualityGoals,
  openScraping, openProcessing, openCatalog, openCatalogBars, openCatalogFixture, RUNNING_JOB, FAILED_JOB,
  installDesignBrowser, openReview,
} from './design-fixture.ts';

describe('设计决定:控件、状态与首页顶部', () => {
  let browser: Browser;
  installDesignBrowser((next) => { browser = next; });

  it('复核状态沿用首页玻璃控件尺寸,空分类保留选中玻璃与状态入口', { timeout: 60_000 }, async () => {
    for (const viewport of [DESKTOP, MOBILE]) {
      const home = await openCatalogBars(browser, viewport);
      let reference;
      try {
        reference = await home.page.locator('[data-catalog-view]').first().evaluate(element => {
          const style = getComputedStyle(element);
          return { height: element.getBoundingClientRect().height, fontSize: style.fontSize,
            padding: style.padding, background: style.backgroundColor };
        });
      } finally {
        await home.close();
      }
      const opened = await openReview(browser, undefined, viewport);
      try {
        const filter = opened.page.locator('[data-review-filter]');
        const state = filter.locator('[data-entity-state]').first();
        const style = await state.evaluate(element => {
          const computed = getComputedStyle(element);
          return { height: element.getBoundingClientRect().height, fontSize: computed.fontSize,
            padding: computed.padding, background: computed.backgroundColor };
        });
        assert.deepEqual(style, reference);
        const sameRow = await filter.evaluate(element => {
          const top = element.querySelector('[data-filter-row="top"]')!.getBoundingClientRect();
          const bottom = element.querySelector('[data-filter-row="bottom"]')!.getBoundingClientRect();
          return Math.abs(top.top - bottom.top) < 1;
        });
        assert.equal(sameRow, !viewport.mobile, '足宽同排、窄屏换排');
        const divider = await filter.locator('[data-entity-sep]').evaluate(element => getComputedStyle(element).visibility);
        assert.equal(divider, viewport.mobile ? 'hidden' : 'visible');
        await filter.getByRole('button', { name: /^已忽略/ }).click();
        await settle(opened.page);
        const geometry = await filter.evaluate(element => {
          const button = element.querySelector('[data-entity-state][aria-pressed="true"]')!;
          const pane = element.querySelector<HTMLElement>('[data-view-glide]')!;
          const active = button.getBoundingClientRect(), glass = pane.getBoundingClientRect();
          return { visible: !pane.hidden, delta: Math.abs(active.left - glass.left),
            widthDelta: Math.abs(active.width - glass.width),
            overflow: document.documentElement.scrollWidth > innerWidth };
        });
        assert.equal(geometry.visible, true);
        assert.ok(geometry.delta < 1 && geometry.widthDelta < 1, JSON.stringify(geometry));
        assert.equal(geometry.overflow, false);
        assert.equal(await filter.getByRole('button', { name: /^待处理/ }).count(), 1);
      } finally {
        await opened.close();
      }
    }
  });

  it('聚焦 React 输入框只画 BoardUI 外框,旧样式表的焦点环不进来', { timeout: 60_000 }, async () => {
    const opened = await openAccess(browser);
    try {
      const input = opened.form.locator('#access-password');
      await input.focus();
      const style = await input.evaluate((element) => ({
        outline: getComputedStyle(element).outlineStyle,
        // TextField 经 GroupContext 把外框的 role 设成 presentation。
        ring: getComputedStyle(element.closest('[role="presentation"]')!).boxShadow,
      }));
      assert.equal(style.outline, 'none', '输入框自己画了 outline:旧的全局 :focus-visible 进了 React 子树');
      assert.notEqual(style.ring, 'none', '外框没有聚焦描边');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('详情舞台的焦点:骨架期间留在浮窗本身、不画焦点环,内容到了交给关闭键', { timeout: 60_000 }, async () => {
    const opened = await openItemPage(browser, '/', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` });
    try {
      const page = opened.page;
      let release = () => {};
      const held = new Promise<void>((resolve) => { release = resolve });
      await page.route((url) => url.pathname === '/api/item', async (route) => { await held; await route.fallback() });
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().press('Enter');
      await page.locator('#stage[open] [data-skeleton="detail"]').waitFor();
      /* 键盘打开也一样:骨架里没有可操作的东西,焦点环画在浮窗外沿上只是一圈噪声。 */
      const waiting = await page.evaluate(() => {
        const focused = document.activeElement as HTMLElement;
        return { id: focused?.id, outline: getComputedStyle(focused).outlineStyle, visible: focused.matches(':focus-visible') };
      });
      assert.equal(waiting.id, 'stage', '骨架期间焦点不在浮窗上');
      assert.equal(waiting.outline, 'none', `骨架期间浮窗画了焦点环(:focus-visible=${waiting.visible})`);
      release();
      await page.locator('#stage[open] [data-item-side]').waitFor();
      await page.waitForFunction(() => document.activeElement?.id === 'closeStage', null, { timeout: 5_000 });
      assert.deepEqual(opened.problems.filter((line) => !line.includes('VIDEOJS')), []);
    } finally {
      await opened.close();
    }
  });

  it('岛外与 React 子树的语义色是同一份:board.css 根上的每个 --color-* 两边画出同一个像素', { timeout: 60_000 }, async () => {
    const opened = await openAccess(browser);
    try {
      const shell = await opened.form.locator('#access-password').evaluate(
        (element) => getComputedStyle(element.closest('[role="presentation"]')!).backgroundColor);
      // BoardUI theme.css 把浅色 neutral-200 改成了 #ebebeb,不是 Tailwind 默认的 #e5e5e5。
      assert.equal(shell, 'rgb(235, 235, 235)');
      // 岛外的骨架读根上的值,接管后的组件读岛内的值;同一块面两帧要同色。岛内写 oklch、
      // board.css 写十六进制或引用,字面不同,画成像素再比。
      const drift = await opened.form.evaluate((island) => {
        const sheet = [...document.styleSheets].find((candidate) => candidate.href?.includes('/board.css'))!;
        const names = new Set<string>();
        const walk = (rules: CSSRuleList) => {
          for (const rule of rules) {
            if ('cssRules' in rule) walk((rule as CSSGroupingRule).cssRules);
            if (!(rule instanceof CSSStyleRule) || !rule.selectorText.includes(':root')) continue;
            for (const name of rule.style) if (name.startsWith('--color-') && !name.startsWith('--color-accent-')) names.add(name);
          }
        };
        walk(sheet.cssRules);
        const paint = document.createElement('canvas').getContext('2d', { willReadFrequently: true })!;
        const pixel = (host: Element, name: string) => {
          const probe = host.appendChild(document.createElement('i'));
          probe.style.color = `var(${name})`;
          paint.clearRect(0, 0, 1, 1);
          paint.fillStyle = getComputedStyle(probe).color;
          paint.fillRect(0, 0, 1, 1);
          probe.remove();
          return [...paint.getImageData(0, 0, 1, 1).data].join(',');
        };
        const root = document.documentElement;
        const before = { theme: root.dataset.theme, dark: root.classList.contains('dark') };
        const drift: string[] = [];
        for (const theme of ['light', 'dark']) {
          root.dataset.theme = theme;
          root.classList.toggle('dark', theme === 'dark');
          for (const name of names) {
            const page = pixel(document.body, name), inside = pixel(island, name);
            if (page !== inside) drift.push(`${theme} ${name}:岛外 ${page},岛内 ${inside}`);
          }
        }
        if (before.theme === undefined) delete root.dataset.theme;
        else root.dataset.theme = before.theme;
        root.classList.toggle('dark', before.dark);
        return { count: names.size, drift };
      });
      assert.ok(drift.count >= 15, `只从 board.css 根上读到 ${drift.count} 个语义色`);
      assert.deepEqual(drift.drift, []);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('持久警示是状态色块,不和字段说明共用灰色小字', { timeout: 60_000 }, async () => {
    const opened = await openAccess(browser);
    try {
      await opened.form.getByRole('checkbox', { name: '关闭访问密码', exact: true }).locator('xpath=ancestor::label[1]').click({ timeout: 5_000 });
      const note = opened.form.locator('[role="note"]');
      await note.waitFor({ timeout: 5_000 });
      const surface = await note.evaluate((element) => getComputedStyle(element).backgroundColor);
      assert.notEqual(surface, 'rgba(0, 0, 0, 0)', '警示没有底色');
      assert.equal(surface, await tokenColor(opened.page, '.peach-react', '--color-status-yellow-background'));
      const ink = await note.evaluate((element) => getComputedStyle(element).color);
      assert.equal(await opened.form.locator('input[type="password"]').count(), 0);
      const hint = await tokenColor(opened.page, '.peach-react', '--color-text-secondary');
      assert.notEqual(ink, hint, '警示文字和字段说明同色');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('跑失败的那一轮整张卡框线换成 danger 色,结束原因还是正文色', { timeout: 60_000 }, async () => {
    const opened = await openActivity(browser, [
      settledRun(1, 'failed', '扫描与采集'), settledRun(2, 'succeeded', '追更检查'),
    ]);
    try {
      const danger = await tokenColor(opened.page, '.peach-react', '--color-border-error-default');
      // 框线画成不占盒子的 outline:只比颜色会放过「有色无宽、根本没画出来」的线,宽度一起比。
      const frame = (status: string) => opened.page.locator(`li[data-status="${status}"]`)
        .evaluate((element) => {
          const style = getComputedStyle(element);
          return style.outlineStyle === 'none' ? '' : `${style.outlineWidth} ${style.outlineColor}`;
        });
      assert.equal(await frame('failed'), `1px ${danger}`, '失败卡的框线不是 danger 色');
      assert.notEqual(await frame('succeeded'), `1px ${danger}`, '没失败的卡也用了 danger 框线');
      // 一屏十几行里逐行读红字比看一眼哪张卡的框是红的慢:原因那行留正文色。
      const reason = await opened.page.locator('li[data-status="failed"] [data-task-error]')
        .evaluate((element) => getComputedStyle(element).color);
      assert.notEqual(reason, danger, '结束原因那行字被涂成了 danger 色');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('任务结果成功绿、失败和取消红、中断黄,排队中性', { timeout: 60_000 }, async () => {
    const opened = await openActivity(browser, [
      settledRun(1, 'succeeded', '追更检查'), settledRun(2, 'failed', '扫描与采集'),
      settledRun(3, 'cancelled', '批量操作'), settledRun(4, 'pending', '命令行批处理'),
      settledRun(5, 'interrupted', '资料采集'),
    ]);
    try {
      const badge = (status: string) => opened.page.locator(`li[data-status="${status}"] span`).first()
        .evaluate((element) => getComputedStyle(element).backgroundColor);
      const token = (name: string) => tokenColor(opened.page, '.peach-react', name);
      assert.equal(await badge('succeeded'), await token('--color-status-lime-background'));
      assert.equal(await badge('failed'), await token('--color-status-rose-background'));
      assert.equal(await badge('interrupted'), await token('--color-status-yellow-background'));
      assert.equal(await badge('cancelled'), await token('--color-status-rose-background'));
      assert.equal(await badge('pending'), await token('--color-background-tertiary-default'));
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('高清版卡片的封面是 150px 宽的 16/10 方块,长标题从头显示、最多两行', { timeout: 60_000 }, async () => {
    const long = '这是一个长到必须省略才放得下的文件名,用来盯住标题从头显示而番号不被截掉.mp4';
    const opened = await openQualityGoals(browser, [
      qualityGoal(1, long), qualityGoal(2, 'short.mp4'),
    ]);
    try {
      const cover = opened.page.locator('li[data-goal-id="1"] button').first();
      const box = await cover.evaluate((element) => ({
        width: getComputedStyle(element).width,
        ratio: getComputedStyle(element).aspectRatio,
      }));
      assert.equal(box.width, '150px');
      assert.equal(box.ratio.replaceAll(' ', ''), '16/10');
      const title = opened.page.locator('li[data-goal-id="1"] h3 button');
      await title.waitFor({ timeout: 5_000 });
      const shown = await title.evaluate((element) => ({
        clamp: getComputedStyle(element).webkitLineClamp,
        middle: element.classList.contains('middle-truncated') || element.hasAttribute('data-middle-truncate'),
        text: element.textContent,
        hint: element.getAttribute('title'),
      }));
      assert.equal(shown.clamp, '2');
      assert.equal(shown.middle, false, '标题仍被中间截断');
      assert.equal(shown.text, long);
      assert.equal(shown.hint, long);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('一张来源卡底下只有写入那一颗是主按钮,其余次级', { timeout: 60_000 }, async () => {
    const opened = await openScraping(browser);
    try {
      const card = opened.page.locator('form[aria-label="演示来源甲"]');
      const fill = (name: string) => card.getByRole('button', { name, exact: true })
        .evaluate((element) => getComputedStyle(element).backgroundColor);
      // 收 Cookie 且已存了一份的来源,底下三颗:撤销、检查、保存;只有保存是写入。
      assert.equal(await fill('保存'), await tokenColor(opened.page, '.peach-react', '--color-button-primary'));
      const secondary = await tokenColor(opened.page, '.peach-react', '--color-background-primary-default');
      assert.equal(await fill('检查连接'), secondary, '检查连接被画成了主按钮');
      assert.equal(await fill('撤销 Cookie'), secondary, '撤销 Cookie 被画成了主按钮');
      // 没存过 Cookie 的来源没有可撤的对象,那一颗不画。
      const plain = opened.page.locator('form[aria-label="演示来源乙"]');
      assert.equal(await plain.getByRole('button', { name: '撤销 Cookie', exact: true }).count(), 0);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('常驻任务面在运行时使用中性色,失败时使用状态红并跨页保留', { timeout: 60_000 }, async () => {
    const running = await openProcessing(browser, '/', RUNNING_JOB);
    try {
      const banner = running.page.locator('#toasts [data-processing-dock][role="status"]');
      await banner.waitFor({ state: 'visible', timeout: 15_000 });
      assert.match(await banner.innerText(), /采集缺失资料 · 38 \/ 100/);
      // 一圈长度钉成 100,画出来的那一段就是百分比本身。
      assert.equal(await banner.locator('[role="progressbar"]').getAttribute('aria-valuenow'), '38');
      // 判据是「不是语气色」,不钉某一个具体的底:在跑那条走中性底,黄与红留给出事的时候。
      const tint = await banner.evaluate((element) => getComputedStyle(element).backgroundColor);
      for (const tone of ['--color-background-tertiary-error', '--color-status-yellow-background']) {
        assert.notEqual(tint, await tokenColor(running.page, '.peach-react', tone),
          '任务在跑是正在发生的事,配上状态底色就和「出事了」一个分量');
      }
    } finally {
      await running.close();
    }

    const failed = await openProcessing(browser, '/', { status: 'failed', error: '来源离线' });
    try {
      const banner = failed.page.locator('#toasts [data-processing-dock][role="alert"]');
      await banner.waitFor({ state: 'visible', timeout: 15_000 });
      /* 计算值现在是 `oklab()`/`oklch()`,两种记法之间没法直接比字符串,所以统一在
         canvas 上取回 RGBA 再比。 */
      const readSurface = () => banner.evaluate((element) => {
        const style = getComputedStyle(element);
        const context = document.createElement('canvas').getContext('2d')!;
        const paint = (color: string) => {
          context.clearRect(0, 0, 1, 1);
          context.fillStyle = color;
          context.fillRect(0, 0, 1, 1);
          return [...context.getImageData(0, 0, 1, 1).data];
        };
        return { base: paint(style.backgroundColor), tint: style.backgroundImage,
          line: paint(style.borderTopColor), ink: paint(style.color),
          button: getComputedStyle(element.querySelector('button[aria-expanded]')!).backgroundColor,
          link: getComputedStyle(element.querySelector('a')!).color };
      });
      for (const theme of ['light', 'dark']) {
        await failed.page.evaluate(value => {
          document.documentElement.dataset.theme = value;
          document.documentElement.classList.toggle('dark', value === 'dark');
        }, theme);
        const surface = await readSurface();
        const errorTint = await tokenColor(failed.page, '.peach-react', '--color-background-tertiary-error');
        assert.equal(surface.base[3], 255, '任务卡的底层不透明');
        assert.equal(surface.tint, `linear-gradient(${errorTint}, ${errorTint})`);
        assert.equal(surface.button, await tokenColor(failed.page, '.peach-react', '--color-background-primary-default'));
        assert.equal(surface.link, await tokenColor(failed.page, '.peach-react', '--color-text-secondary'));
        for (const at of [0, 1, 2]) {
          assert.ok(Math.abs(surface.line[at]! - surface.ink[at]!) <= 2,
            `横幅的线没跟着语气走:线 ${surface.line} 与字 ${surface.ink} 不是同一个色`);
        }
        assert.ok(surface.line[3]! < surface.ink[3]!, '状态分隔线比正文更淡');
      }
      // 数据管理子页继续保留同一趟尚未处理的失败提示。
      await failed.page.goto(new URL('/junk-files', failed.page.url()).href, { waitUntil: 'load' });
      await failed.page.locator('[data-junk-filters]').waitFor({ state: 'visible', timeout: 15_000 });
      const retainedFailure = failed.page.locator('#toasts [data-processing-dock][role="alert"]');
      await retainedFailure.waitFor({ state: 'visible', timeout: 15_000 });
      assert.equal(await retainedFailure.count(), 1,
        '未处理的失败在换页后仍可找到');
    } finally {
      await failed.close();
    }
  });

  it('扫描卡的进度条走焦点环色,底槽是三级底,任务在跑时同步键标为不可用但仍可聚焦', { timeout: 60_000 }, async () => {
    const opened = await openProcessing(browser, '/data-cleanup', RUNNING_JOB);
    try {
      const card = opened.page.locator('section[aria-label="扫描与采集"]');
      await card.waitFor({ state: 'visible', timeout: 15_000 });
      const bar = card.locator('[role="progressbar"]');
      assert.equal(await bar.getAttribute('aria-valuenow'), '38');
      assert.equal(await bar.getAttribute('aria-valuemax'), '100');
      const fill = (at: number) =>
        bar.locator('rect').nth(at).evaluate((element) => getComputedStyle(element).fill);
      assert.equal(await fill(0),
        await tokenColor(opened.page, '.peach-react', '--color-background-tertiary-default'));
      assert.equal(await fill(1),
        await tokenColor(opened.page, '.peach-react', '--color-border-focus-ring'));
      // 已有任务在跑只标 `aria-disabled`、不改 `disabled`:控件仍可聚焦,点了由卡片说明为什么没开始。
      const scan = card.getByRole('button', { name: '同步新增文件', exact: true });
      assert.equal(await scan.getAttribute('aria-disabled'), 'true');
      assert.equal(await scan.getAttribute('aria-busy'), null);
      assert.equal(await scan.evaluate((element) => (element as HTMLButtonElement).disabled), false,
        '用原生 disabled 挡的话按钮连焦点都拿不到');
      const split = card.locator('[data-split-button]');
      const parts = split.locator(':scope > button');
      assert.equal(await parts.count(), 2, '拆分按钮没有保持主操作与菜单两区');
      const boxes = await parts.evaluateAll((buttons) => buttons.map((button) => {
        const rect = button.getBoundingClientRect();
        return { left: rect.left, right: rect.right, height: rect.height };
      }));
      assert.ok(Math.abs(boxes[0]!.right - boxes[1]!.left) < 0.5,
        '拆分按钮两区之间留了空隙');
      assert.equal(boxes[0]!.height, boxes[1]!.height, '拆分按钮两区高度不一致');
      const divider = await parts.nth(1).evaluate((element) => {
        const style = getComputedStyle(element, '::after');
        return { width: style.width, color: style.backgroundColor };
      });
      assert.equal(divider.width, '1px', '拆分按钮分隔线宽度不对');
      assert.notEqual(divider.color, 'rgba(0, 0, 0, 0)', '拆分按钮分隔线没有颜色');
    } finally {
      await opened.close();
    }
  });

  it('拆分按钮两半各自抬填充:悬停哪半只有哪半变,中缝那条线不动', { timeout: 60_000 }, async () => {
    // Geist 实测(`vercel-geist-split-button.md`「悬停」):两半各是一颗有自己底色的按钮,
    // 悬停只抬指针下那一颗;分隔线是触发档 `::before`,`left:-1px` 盖在主动作最后一列上,
    // 高度顶满、颜色不随悬停变。
    const opened = await openProcessing(browser, '/data-cleanup', { status: 'idle' });
    try {
      // 骨架那张卡同名同结构,等到 React 接管、两半不再带骨架标记才量。
      const split = opened.page.locator('section[aria-label="扫描与采集"] [data-split-button]:not(:has(> [data-skeleton-action]))');
      await split.waitFor({ state: 'visible', timeout: 15_000 });
      await settle(opened.page);
      const parts = split.locator(':scope > button');
      const faces = () => parts.evaluateAll((buttons) => buttons.map((button) => {
        const style = getComputedStyle(button), lift = getComputedStyle(button, '::before');
        return { fill: style.backgroundImage, lift: lift.opacity, ink: style.color };
      }));
      const seam = () => parts.nth(1).evaluate((element) => {
        const line = getComputedStyle(element, '::after');
        return { color: line.backgroundColor, width: line.width, left: line.left,
          height: line.height, full: `${element.getBoundingClientRect().height}px`,
          clip: getComputedStyle(element).overflowX };
      });
      assert.equal(await split.evaluate((element) => getComputedStyle(element).backgroundImage), 'none',
        '底色画在整组上,两半就没法各自抬填充');
      await opened.page.mouse.move(0, 0);
      const rest = await faces();
      assert.match(rest[0]!.fill, /gradient/, '主动作那半没有自己的蓝色填充');
      assert.equal(rest[1]!.fill, rest[0]!.fill, '两半静止时不是同一档填充');
      assert.deepEqual(rest.map((face) => face.lift), ['0', '0'], '没悬停就抬了填充');
      const line = await seam();
      assert.equal(line.width, '1px');
      assert.equal(line.left, '-1px', '分隔线该盖在主动作最后一列上,不占触发档的宽度');
      assert.equal(line.height, line.full, '分隔线没有上下顶满');
      assert.equal(line.clip, 'visible', '触发档把伸到左邻上的分隔线裁掉了,屏幕上看不见');
      assert.notEqual(line.color, 'rgba(0, 0, 0, 0)');
      for (const [at, other] of [[0, 1], [1, 0]] as const) {
        await parts.nth(at).hover();
        // 悬停层按 150ms 淡入淡出,直接读会落在半路;走到终点再比。
        await opened.page.evaluate(() => document.getAnimations().forEach((animation) => animation.finish()));
        const hovered = await faces();
        assert.equal(hovered[at]!.lift, '1', `悬停第 ${at + 1} 半没有抬填充`);
        assert.equal(hovered[other]!.lift, '0', `悬停第 ${at + 1} 半时另一半也跟着变了`);
        assert.deepEqual(hovered.map((face) => face.ink), rest.map((face) => face.ink), '悬停改了字色');
        assert.deepEqual(await seam(), line, '悬停时分隔线变了');
      }
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('失败提示里重试键靠右,问题清单铺满提示并走覆盖式滚动条', { timeout: 60_000 }, async () => {
    const opened = await openProcessing(browser, '/data-cleanup', FAILED_JOB);
    try {
      const alert = opened.page.locator('#libraryProcessing [role="alert"]');
      await expectBody(opened.page, '/data-cleanup 处理失败提示', [alert], opened.problems);
      await alert.locator('summary').click();
      const list = alert.locator('ul');
      await list.waitFor({ state: 'visible', timeout: 5_000 });
      const geometry = await alert.evaluate((element) => {
        const note = element.getBoundingClientRect();
        const button = element.querySelector('[data-note-action]')!.getBoundingClientRect();
        const items = element.querySelector('ul')!;
        return {
          padding: parseFloat(getComputedStyle(element).paddingRight),
          noteRight: note.right, buttonRight: button.right,
          listRight: items.getBoundingClientRect().right,
          client: items.clientWidth, offset: items.offsetWidth,
          track: !!element.querySelector('.ovtrack'),
        };
      });
      assert.ok(geometry.noteRight - geometry.buttonRight - geometry.padding < 1,
        '重试与归档没有贴着提示的右内边,读起来就不是这条提示的动作');
      assert.ok(geometry.noteRight - geometry.listRight - geometry.padding < 1,
        '问题清单没有铺满提示的宽度');
      assert.equal(geometry.client, geometry.offset, '原生滚动条还占着清单右边一列');
      assert.ok(geometry.track, '清单没有挂上全站那条覆盖式滚动条');
      /* 同一块红底上叠着三段:结论、清单、完整记录。条与条之间要看得见界,完整记录
         要退回灰字——它不是这条提示在说的事,是出事之后自己去翻的东西。 */
      const layering = await alert.evaluate((element) => {
        const context = document.createElement('canvas').getContext('2d')!;
        const paint = (color: string) => {
          context.clearRect(0, 0, 1, 1);
          context.fillStyle = color;
          context.fillRect(0, 0, 1, 1);
          return [...context.getImageData(0, 0, 1, 1).data];
        };
        // `divide-y` 把线画在每条的下缘(末条除外),所以量的是第一条的下边。
        const first = element.querySelectorAll('li')[0]!;
        const log = element.querySelector('[aria-label="在资源管理器中显示"]')!.closest('.text-text-secondary')!.querySelector('p')!;
        return {
          divider: parseFloat(getComputedStyle(first).borderBottomWidth),
          dividerInk: paint(getComputedStyle(first).borderBottomColor),
          logInk: paint(getComputedStyle(log).color),
          bodyInk: paint(getComputedStyle(element).color),
        };
      });
      assert.ok(layering.divider > 0, '两条明细之间没有界,几十条连成一片');
      assert.ok(layering.dividerInk[3]! < layering.bodyInk[3]!, '条间的线和正文一样实');
      assert.ok([0, 1, 2].some((at) => Math.abs(layering.logInk[at]! - layering.bodyInk[at]!) > 8),
        '完整记录还跟着提示是红的,读起来像又出了一件事');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('一趟的问题收在同一块提示里,「7 天内不再问」、完整记录与导出都跟着它', { timeout: 60_000 }, async () => {
    const notes = { fetching_cover: 84, querying_metadata: 18 };
    const button = '[aria-label="在资源管理器中显示"]';
    const reveal = `#libraryProcessing ${button}`;
    const failed = await openProcessing(browser, '/data-cleanup', { ...FAILED_JOB, notes });
    try {
      const alert = failed.page.locator('#libraryProcessing [role="alert"]');
      await expectBody(failed.page, '/data-cleanup 失败提示与不再问提示', [alert], failed.problems);
      assert.ok((await alert.textContent())!.includes('7 天内不再问'), '「7 天内不再问」没有并进失败提示');
      assert.equal(await failed.page.locator('#libraryProcessing [role="note"]').count(), 0,
        '失败提示旁边还另挂着一块同类提示');
      assert.equal(await failed.page.locator(reveal).count(), 1, '完整记录印了不止一处');
      assert.equal(await alert.locator(button).count(), 1, '完整记录该跟着问题提示');
      assert.equal(await alert.getByRole('button', { name: '导出完整问题清单' }).count(), 1, '导出没有跟着完整记录');
      assert.deepEqual(failed.problems, []);
    } finally {
      await failed.close();
    }
    /* 跑完的一趟没有失败提示,这条提示就是日志唯一的入口;分隔线上下的留白与内边距相同。 */
    const complete = await openProcessing(browser, '/data-cleanup', {
      status: 'complete', job_id: 'two', issue_count: 3, issues_log: FAILED_JOB.issues_log, notes,
    });
    try {
      const note = complete.page.locator('#libraryProcessing [role="note"]', { hasText: '7 天内不再问' });
      await expectBody(complete.page, '/data-cleanup 不再问提示', [note], complete.problems);
      assert.equal(await complete.page.locator(reveal).count(), 1, '跑完的一趟丢了完整记录');
      const rhythm = await note.evaluate((element, selector) => {
        const bar = element.querySelector(selector)!.closest('.border-t')!;
        const note = getComputedStyle(element);
        const style = getComputedStyle(bar);
        return { padding: note.paddingTop, gap: note.rowGap, above: style.marginTop, below: style.paddingTop };
      }, button);
      assert.equal(rhythm.above, rhythm.padding, '分隔线上方的留白和提示内边距不一样');
      assert.equal(rhythm.below, rhythm.padding, '分隔线下方的留白和提示内边距不一样');
      assert.ok(rhythm.gap === 'normal' || rhythm.gap === '0px', '正文与分隔线之间还多垫了一段间距');
      assert.deepEqual(complete.problems, []);
    } finally {
      await complete.close();
    }
  });

  it('新窗口打开的来源地址两项 rel 都写:不带走会话,也不带走来处', { timeout: 60_000 }, async () => {
    const opened = await openScraping(browser);
    try {
      const link = opened.page.locator('form[aria-label="演示来源甲"] a[target="_blank"]');
      assert.equal(await link.getAttribute('href'), 'https://demoa.example/login');
      assert.equal((await link.getAttribute('rel'))!.split(/\s+/).sort().join(' '), 'noopener noreferrer');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('顶栏版式键保留两枚字形并原地换态', { timeout: 60_000 }, async () => {
    const opened = await openCatalog(browser);
    try {
      await opened.page.emulateMedia({ reducedMotion: 'no-preference' });
      const button = opened.page.locator('#density');
      const swap = button.locator('[data-icon-swap]');
      await swap.waitFor({ timeout: 5_000 });
      assert.equal(await swap.locator('[data-icon]').count(), 2);
      const before = await swap.getAttribute('data-icon-state');
      await swap.evaluate((element) => { element.setAttribute('data-test-identity', 'density-swap'); });
      await button.click();
      assert.notEqual(await swap.getAttribute('data-icon-state'), before,
        '按下后仍停在同一枚字形');
      assert.equal(await swap.locator('[data-icon]').count(), 2,
        '换态不应销毁其中一枚字形');
      await button.click();
      assert.equal(await swap.getAttribute('data-icon-state'), before, '第二次按下没有回到原字形');
      assert.equal(await button.locator('[data-icon-swap][data-test-identity="density-swap"]').count(), 1,
        '两次换态之间重建了字形容器');
    } finally {
      await opened.close();
    }
  });

  it('数字首写静态、变值弹入;筛选读数原位淡入淡出并保留控件', { timeout: 60_000 }, async () => {
    const opened = await openCatalogFixture(browser, (payload, url) => {
      payload.total = url.searchParams.has('q') ? 34 : 12;
    });
    try {
      await opened.page.emulateMedia({ reducedMotion: 'no-preference' });
      const result = await opened.page.evaluate(async () => {
        const { popCount } = await import('/dist/peach-app.js');
        const host = document.createElement('span');
        document.body.append(host);
        popCount(host, '12');
        const firstWasStatic = !host.firstElementChild?.classList.contains('popping');
        popCount(host, '34');
        const digit = host.querySelector<HTMLElement>('.digits.popping > span');
        const style = digit && getComputedStyle(digit);
        const answer = {
          firstWasStatic,
          animationName: style?.animationName || '',
          duration: style?.animationDuration || '',
        };
        host.remove();
        return answer;
      });
      assert.equal(result.firstWasStatic, true, '首次写入不应弹动');
      assert.equal(result.animationName, 'digit-pop-in');
      assert.equal(result.duration, '0.25s');

      const page = opened.page;
      const input = page.locator('#q');
      const readout = page.locator('[data-catalog-readout] [data-async-text]');
      const before = await readout.boundingBox();
      let release = () => {};
      const held = new Promise<void>(resolve => { release = resolve });
      await page.route('**/api/items?**', async route => { await held; await route.fallback() });
      await page.evaluate(() => { Object.assign(window, { countFrame: document.querySelector('[data-catalog-frame]') }) });
      try {
        await input.fill('读数变值');
        await input.press('Enter');
        await page.waitForFunction(() => document.querySelector('[data-catalog-readout] [data-async-text]')?.getAttribute('aria-busy') === 'true');
        const fading = await readout.evaluate(node => {
          const value = node.querySelector('[data-async-text-value]')!;
          const skeleton = node.querySelector('[data-async-text-skeleton]')!;
          return { hidden: value.getAttribute('aria-hidden'), transition: getComputedStyle(value).transitionProperty,
            duration: parseFloat(getComputedStyle(value).transitionDuration), width: skeleton.getBoundingClientRect().width,
            same: (window as unknown as { countFrame: Element }).countFrame === document.querySelector('[data-catalog-frame]') };
        });
        assert.equal(fading.hidden, 'true');
        assert.ok(fading.transition.includes('opacity') && fading.duration > 0, '读数缺少淡入淡出');
        assert.ok(before && Math.abs(fading.width - before.width) <= 1, '等待骨架没有保留读数宽度');
        assert.equal(fading.same, true, '加载读数替换了筛选控件');
      } finally { release() }
      await page.waitForFunction(() => document.querySelector('[data-catalog-readout]')?.textContent?.includes('34 个符合'));
      assert.equal(await readout.getAttribute('aria-busy'), null);
      assert.equal(await readout.locator('[data-async-text-value]').getAttribute('aria-hidden'), null);
    } finally {
      await opened.close();
    }
  });

  it('五枚叠放头像浮于文字上展开,左缘与标题列不动', { timeout: 60_000 }, async () => {
    const names = Array.from({ length: 7 }, (_, index) => `演示演员 ${index + 1}`);
    const opened = await openCatalogFixture(browser, (payload) => {
      const item = payload.items[0];
      if (!item) throw new Error('演示目录没有可替换的卡片');
      item.creator = '';
      item.performers = names;
      item.performer_total = names.length;
      item.performer_entities = names.map((name, index) => ({ id: 90_000 + index, name, has_image: false }));
    });
    try {
      await opened.page.emulateMedia({ reducedMotion: 'no-preference' });
      const stack = opened.page.locator('[data-media-card][data-id] [data-media-avatars]').first();
      await stack.waitFor({ timeout: 5_000 });
      const avatars = stack.locator('[data-media-avatar]');
      assert.equal(await avatars.count(), 5, 'API 给七位表演者时卡片没有收在五枚以内');
      const before = await avatars.evaluateAll((items) => items.map((item) => parseFloat(getComputedStyle(item).left)));
      const left = (await stack.boundingBox())!.x;
      const textBefore = await stack.locator('xpath=ancestor::article[@data-media-card]').first().locator('[data-media-text]').boundingBox();
      const waitForAvatarMotion = () => stack.evaluate(async (element) => {
        const animations = element.getAnimations({ subtree: true });
        await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
      });

      await avatars.first().hover();
      await waitForAvatarMotion();
      const firstSpread = await avatars.evaluateAll((items) => items.map((item) => parseFloat(getComputedStyle(item).left)));
      for (let index = 1; index < firstSpread.length; index += 1) {
        assert.ok(firstSpread[index] > before[index], `首枚悬停时第 ${index + 1} 枚没有向标题方向展开`);
      }

      await avatars.nth(2).hover();
      await waitForAvatarMotion();
      const after = await avatars.evaluateAll((items) => items.map((item) => parseFloat(getComputedStyle(item).left)));
      const card = stack.locator('xpath=ancestor::article[@data-media-card]').first();
      const bounds = await card.evaluate((cardElement) => {
        const stackRect = cardElement.querySelector('[data-media-avatars]')!.getBoundingClientRect();
        const lastRect = cardElement.querySelector('[data-media-avatar]:last-child')!.getBoundingClientRect();
        const firstRect = cardElement.querySelector('[data-media-avatar]')!.getBoundingClientRect();
        const cardRect = cardElement.getBoundingClientRect();
        return {
          stackLeft: stackRect.left,
          stackRight: stackRect.right,
          textLeft: cardElement.querySelector('[data-media-text]')!.getBoundingClientRect().left,
          firstRingLeft: firstRect.left - 2,
          lastRingRight: lastRect.right + 2,
          cardLeft: cardRect.left,
          cardRight: cardRect.right,
        };
      });
      assert.equal(bounds.stackLeft, left, '展开时左缘发生位移');
      // Chrome 合成层结束 transition 时会留下远小于一个物理像素的浮点残差;这里守的是
      // 头像没有发生可见位移,不把 303 与 302.9933 这种同一像素内的取整差判成布局回退。
      assert.ok(Math.abs(after[0] - before[0]) <= .5, '指向第三枚时第一枚被往左推');
      assert.ok(after.slice(1).every((value,index)=>Math.abs(value-firstSpread[index+1]!)<=.5), '在头像间移动时展开位置不稳定');
      assert.ok(after[3] > before[3] && after[4] > before[4], '头像没有朝标题方向展开');
      assert.ok(bounds.firstRingLeft >= bounds.cardLeft - .5, '首枚放大加描边后被卡片左缘裁切');
      assert.ok(bounds.lastRingRight <= bounds.cardRight + .5, '头像展开越出卡片右缘');
      assert.ok(bounds.lastRingRight > bounds.textLeft && bounds.stackRight <= bounds.textLeft, '展开头像没有浮于文字上方');
      assert.ok(Math.abs(bounds.textLeft-textBefore!.x)<=.5, '展开头像改变了标题列位置');

      await opened.page.mouse.move(0, 0);
      await avatars.nth(2).focus();
      await waitForAvatarMotion();
      const layers = await avatars.evaluateAll((items) => items.map((item) => Number(getComputedStyle(item).zIndex)));
      assert.equal(layers[2], Math.max(...layers), '键盘焦点所在头像没有升到最高层');

      await opened.page.setViewportSize({ width: 390, height: 844 });
      const drawer = opened.page.locator('#drawer');
      if (await drawer.evaluate((element) => element.classList.contains('open'))) {
        await opened.page.locator('#scrim').evaluate((element) => (element as HTMLElement).click());
        await opened.page.waitForFunction(() => !document.querySelector('#drawer')?.classList.contains('open'));
      }
      await avatars.first().hover();
      await waitForAvatarMotion();
      const narrow = await card.evaluate((cardElement) => {
        const cardRect = cardElement.getBoundingClientRect();
        const firstRect = cardElement.querySelector('[data-media-avatar]')!.getBoundingClientRect();
        const lastRect = cardElement.querySelector('[data-media-avatar]:last-child')!.getBoundingClientRect();
        let clip: Element | null = cardElement.parentElement;
        while (clip && clip !== document.documentElement) {
          const style = getComputedStyle(clip);
          if (style.overflowX !== 'visible' || style.overflowY !== 'visible') break;
          clip = clip.parentElement;
        }
        const clipRect = (clip || document.documentElement).getBoundingClientRect();
        return {
          firstRingLeft: firstRect.left - 2,
          lastRingRight: lastRect.right + 2,
          cardLeft: cardRect.left,
          cardRight: cardRect.right,
          clipLeft: clipRect.left,
          clipRight: clipRect.right,
          viewport: document.documentElement.clientWidth,
        };
      });
      assert.ok(narrow.firstRingLeft >= Math.max(narrow.cardLeft, narrow.clipLeft) - .5,
        '390px 视口下首枚头像或描边被最近的裁切祖先截掉');
      assert.ok(narrow.lastRingRight <= Math.min(narrow.cardRight, narrow.clipRight, narrow.viewport) + .5,
        '390px 视口下展开头像越出卡片、裁切祖先或视口');
    } finally {
      await opened.close();
    }
  });

  it('搜索框从非空变空时立即清值并溶解原内容', { timeout: 60_000 }, async () => {
    const opened = await openCatalog(browser);
    try {
      await opened.page.emulateMedia({ reducedMotion: 'no-preference' });
      const input = opened.page.locator('#q');
      const text = '一段长到会在搜索框里横向滚动的内容 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ '.repeat(4).trim();
      await input.fill(text);
      await input.evaluate((element) => {
        const field = element as HTMLInputElement;
        field.setSelectionRange(field.value.length, field.value.length);
        field.scrollLeft = field.scrollWidth;
        field.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }));
      });
      assert.ok(await input.evaluate((element) => (element as HTMLInputElement).scrollLeft) > 0,
        '测试内容没有真正让搜索框横向滚动');
      /* 残影最多活一秒(动画结束或兜底计时器就摘掉),负载重时测试的几次往返之间它就没了。
         清空前在页面里挂好观察者:残影一挂上就记下文字与位移,再在同一个页面循环里逐帧等
         placeholder 淡到完全透明;残影先被摘掉而 placeholder 还没透明,才算真失败。 */
      type DissolveSeen = { text: string | null; transform: string; hidden: boolean };
      type DissolveWindow = Window & { dissolveSeen?: Promise<DissolveSeen> };
      await input.evaluate((element) => {
        const host = element.parentElement!;
        (window as DissolveWindow).dissolveSeen = new Promise<DissolveSeen>((resolve) => {
          const seen: DissolveSeen = { text: null, transform: '', hidden: false };
          const finish = () => { observer.disconnect(); resolve(seen) };
          const poll = () => {
            if (!host.querySelector(':scope > .cleardissolve')) { finish(); return }
            if (getComputedStyle(element, '::placeholder').opacity === '0') { seen.hidden = true; finish(); return }
            requestAnimationFrame(poll);
          };
          const observer = new MutationObserver(() => {
            const value = host.querySelector<HTMLElement>(':scope > .cleardissolve [data-dissolve-value]');
            if (!value || seen.text !== null) return;
            seen.text = value.textContent;
            seen.transform = value.style.transform;
            poll();
          });
          observer.observe(host, { childList: true });
        });
      });
      await input.fill('');
      assert.equal(await input.inputValue(), '', '动画阻塞了真实输入框清空');
      const seen = await opened.page.evaluate(() => (window as DissolveWindow).dissolveSeen!);
      assert.equal(seen.text, text, '清空时没有挂上带原内容的残影');
      assert.match(seen.transform, /translateX\(-\d+px\)/);
      assert.ok(seen.hidden, '残影在场时 placeholder 没有完全透明');
      const ghost = opened.page.locator('.search > .cleardissolve');
      await ghost.waitFor({ state: 'detached', timeout: 2_000 });
      assert.equal(await input.evaluate((element) => element.classList.contains('dissolving')), false,
        '动画结束后仍压着输入框的溶解状态');

      await input.fill(text);
      await input.fill('');
      await opened.page.locator('.search > .cleardissolve').waitFor({ timeout: 1_000 });
      await input.type('新输入');
      assert.equal(await opened.page.locator('.search > .cleardissolve').count(), 0,
        '清空后继续输入仍被旧残影覆盖');
      assert.equal(await input.inputValue(), '新输入');

      await input.fill('输入法候选');
      await input.fill('');
      await opened.page.locator('.search > .cleardissolve').waitFor({ timeout: 1_000 });
      await input.evaluate((element) => element.dispatchEvent(new CompositionEvent('compositionstart', {
        bubbles: true,
        data: '候',
      })));
      assert.equal(await opened.page.locator('.search > .cleardissolve').count(), 0,
        '输入法开始组字后旧残影仍覆盖候选字');
    } finally {
      await opened.close();
    }
  });

  it('搜索框空着且没聚焦时占位推荐词隔几秒换一句,聚焦、打字或组字就停', { timeout: 60_000 }, async () => {
    const opened = await openCatalog(browser);
    try {
      await opened.page.emulateMedia({ reducedMotion: 'no-preference' });
      const input = opened.page.locator('#q');
      const ghost = opened.page.locator('.search > .placeholderroll');
      // 推荐词池在页面空闲时取,取到就把第一句换上去;之后每隔几秒换一句。
      await opened.page.waitForFunction(() => document.querySelector<HTMLInputElement>('#q')?.dataset.suggestion,
        null, { timeout: 15_000 });
      // 第一句换上去时也走一次换句动画,等它走完再记「换之前」的那一句。
      await ghost.waitFor({ state: 'detached', timeout: 2_000 });
      const before = await input.getAttribute('placeholder');
      await ghost.waitFor({ timeout: 8_000 });
      const after = await input.getAttribute('placeholder');
      assert.notEqual(after, before, '换句时 placeholder 没有改成新的一句');
      assert.equal(after, await input.getAttribute('data-suggestion'), '占位字与空回车要搜的词对不上');
      assert.equal(await input.evaluate((element) => getComputedStyle(element, '::placeholder').opacity), '0',
        '换句的那一层在场时框里自己的占位字没有收起');
      await ghost.waitFor({ state: 'detached', timeout: 2_000 });
      assert.equal(await input.evaluate((element) => element.classList.contains('placeholderrolling')), false,
        '换完之后仍压着输入框的占位字');

      await ghost.waitFor({ timeout: 8_000 });
      await input.type('新');
      assert.equal(await ghost.count(), 0, '开始打字后换句的那一层仍盖在输入框上');
      // 聚焦时不换句:人正对着这一句,换掉了回车搜的就不是刚看见的词。
      await input.fill('');
      await opened.page.waitForTimeout(6_000);
      assert.equal(await ghost.count(), 0, '聚焦在搜索框上时占位字仍在换句');
      await input.blur();
      await ghost.waitFor({ timeout: 8_000 });
      await input.evaluate((element) => element.dispatchEvent(new CompositionEvent('compositionstart', {
        bubbles: true, data: '候',
      })));
      assert.equal(await ghost.count(), 0, '输入法开始组字后换句的那一层仍盖着候选字');
    } finally {
      await opened.close();
    }
  });

  /* 搜索框住在粘性顶栏里,永远在视口上沿;可 `html` 的 `scroll-padding-top` 把那一条
     划成「被顶栏盖住」的区域,浏览器每敲一个字就把光标往下滚一次,页面一路往回退。
     点击走鼠标坐标:定位器的 click 会先替元素滚进视口,那一下不是页面自己的行为。 */
  it('往下翻过之后在搜索框里打字,页面停在原处', { timeout: 60_000 }, async () => {
    const opened = await openCatalog(browser);
    try {
      const { page } = opened;
      await page.setViewportSize({ width: DESKTOP.width, height: 480 });
      await page.mouse.move(DESKTOP.width / 2, 300);
      await page.mouse.wheel(0, 600);
      await page.waitForFunction(() => scrollY > 300, undefined, { timeout: 5_000 });
      const before = await page.evaluate(() => scrollY);
      const box = await page.locator('#q').boundingBox();
      if (!box) throw new Error('搜索框不可见');
      await page.mouse.click(box.x + 40, box.y + box.height / 2);
      await page.keyboard.type('演示abc', { delay: 60 });
      await page.waitForTimeout(300);
      assert.equal(await page.evaluate(() => scrollY), before, '打字时页面跟着滚动了');
    } finally {
      await opened.close();
    }
  });

  it('首页版式默认小图,大图统一作品画面框,选择单独记住', { timeout: 60_000 }, async () => {
    /* 首张卡使用番号封面,第二张使用普通预览图。 */
    const opened = await openCatalogFixture(browser, (payload) => {
      Object.assign(payload.items[0], { is_jav: true, code: 'ABC-123', display_code: 'ABC-123', has_cover: true });
    });
    const { page } = opened;
    const ratio = (index: number) => page.locator('#grid [data-media-grid] > [data-media-card][data-id]').nth(index).locator('[data-media-pic]')
      .evaluate((element) => element.getBoundingClientRect().width / element.getBoundingClientRect().height);
    const choice = (value: string) => page.locator(`[data-catalog-filter] input[name="home-layout"][value="${value}"]`);
    try {
      assert.equal(await choice('small').isChecked(), true, '首页版式默认不是小图');
      assert.ok(Math.abs(await ratio(0) - 16 / 9) < 0.05, '小图下番号卡不是 16:9');
      await choice('big').check({ force: true });
      assert.ok(Math.abs(await ratio(0) - 0.75) < 0.05, '大图下番号卡没有拉成正封比例');
      assert.ok(Math.abs(await ratio(1) - 0.75) < 0.05, '大图下普通作品画面框不是 3:4');
      const box = (index: number) => page.locator('#grid [data-media-grid] > [data-media-card][data-id]').nth(index).locator('[data-media-pic]')
        .evaluate((element) => ({ top: element.getBoundingClientRect().top, height: element.getBoundingClientRect().height }));
      const [jav, plain] = [await box(0), await box(1)];
      assert.ok(Math.abs(jav.top - plain.top) < 1, '前两张卡不在同一行,量不到撑高');
      assert.ok(Math.abs(plain.height - jav.height) < 1, '同行作品画面框不等高');
      // `visit()` 的初始化脚本每次导航都重写设置,刷新验不了;直接读存下来的那份。
      const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('peach.settings.v1') || '{}'));
      assert.equal(saved.homeLayout, 'big', '首页版式没有存下来');
    } finally {
      await opened.close();
    }
  });

  it('首页标签:没加上去是 --field-ring-hover 的虚线,加上去是玻璃上那圈实线,视图不画虚线;触屏上排 48、视图与标签取紧凑档', { timeout: 60_000 }, async () => {
    /* 虚线只说「这条筛选可加、此刻没加」,取 Vercel 筛选令牌未生效那一档;四枚视图恒有一枚生效,不画。
       圆角同卡片、详情与交集条那一颗(`--tag-radius`)。触屏控件共用 30px 高度。 */
    const opened = await openCatalogBars(browser);
    try {
      const { page } = opened;
      const scope = '[data-catalog-filter]';
      const ring = await tokenColor(page, scope, '--field-ring-hover');
      const low = await tokenColor(page, scope, '--glass-low');
      const edge = (selector: string) => page.locator(`${scope} ${selector}`).first().evaluate((element) => {
        const style = getComputedStyle(element);
        const probe = document.createElement('div');
        probe.style.borderRadius = 'var(--tag-radius)';
        element.append(probe);
        const radius = getComputedStyle(probe).borderTopLeftRadius;
        probe.remove();
        return { style: style.borderTopStyle, color: style.borderTopColor, radius: style.borderTopLeftRadius === radius };
      });
      assert.deepEqual(await edge('[data-catalog-tag][aria-pressed="false"]'), { style: 'dashed', color: ring, radius: true },
        '没加上去的标签不是 --field-ring-hover 的虚线,或圆角不是 --tag-radius');
      assert.notEqual((await edge('[data-catalog-view]')).style, 'dashed', '视图恒有一枚生效,不画虚线');
      await page.locator(`${scope} [data-catalog-tag][aria-pressed="false"]`).first().click();
      await page.locator(`${scope} [data-catalog-tag][aria-pressed="true"]`).waitFor({ timeout: 5_000 });
      assert.deepEqual(await edge('[data-catalog-tag][aria-pressed="true"]'), { style: 'solid', color: low, radius: true },
        '加上去的标签那圈线不是玻璃上的 --glass-low');
    } finally {
      await opened.close();
    }
    const phone = await openCatalogBars(browser, MOBILE);
    try {
      const { page } = phone;
      const heights = await page.evaluate(() => {
        const height = (selector: string) => {
          const element = document.querySelector(`[data-catalog-filter] ${selector}`);
          return element ? Math.round(element.getBoundingClientRect().height) : null;
        };
        return { coarse: matchMedia('(pointer: coarse)').matches, row: height('[data-filter-row="top"]'),
          view: height('[data-catalog-view]'), tag: height('[data-catalog-tag]'),
          compact: parseFloat(getComputedStyle(document.querySelector('[data-catalog-filter]')!).getPropertyValue('--control-h-compact')) };
      });
      assert.equal(heights.compact, 32);
      assert.deepEqual(heights, { coarse: true, row: 48, view: heights.compact, tag: heights.compact, compact: heights.compact }, '触屏筛选按钮高度不一致');
    } finally {
      await phone.close();
    }
  });

  it('首页换一批:头像、厂牌与标签原地藏起来只露一层微光,名字条收成 52px,四枚视图不盖', { timeout: 60_000 }, async () => {
    /* 框就是它们自己的框,零位移;`visibility:hidden` 的控件也不可聚焦。视图由 state 决定,这一趟不改它们。 */
    const opened = await openCatalogBars(browser);
    let release = () => {};
    try {
      const { page } = opened;
      const held = new Promise<void>((resolve) => { release = resolve; });
      // 后注册的先拦:拖住这一趟,放行后交回夹具那一份。
      await page.route((url) => url.pathname === '/api/tops', async (route) => { await held; await route.fallback(); });
      await page.locator('[data-catalog-filter] [data-entity-batch]').click();
      await page.locator('[data-catalog-filter] [data-catalog-root][data-refreshing]').waitFor({ timeout: 5_000 });
      const shown = await page.evaluate(() => {
        const read = (selector: string) => {
          const element = document.querySelector(`[data-catalog-filter] ${selector}`);
          if (!element) return null;
          return { hidden: getComputedStyle(element).visibility, sheen: getComputedStyle(element, '::after').visibility,
            width: Math.round(element.getBoundingClientRect().width) };
        };
        return { tag: read('[data-catalog-tag]'), ring: read('[data-tier-ring]'), name: read('[data-tier-name]'),
          studio: read('[data-tier-studio]'), view: read('[data-catalog-view]') };
      });
      for (const key of ['tag', 'ring', 'name', 'studio'] as const) {
        assert.equal(shown[key]?.hidden, 'hidden', `换一批时${key}没有藏起来:${JSON.stringify(shown[key])}`);
        assert.equal(shown[key]?.sheen, 'visible', `换一批时${key}上没有那层微光:${JSON.stringify(shown[key])}`);
      }
      assert.equal(shown.name?.width, 52, '名字条没有收成首屏骨架那一宽');
      assert.equal(shown.view?.hidden, 'visible', '视图不随这一趟变,不该盖');
    } finally {
      release();
      await opened.close();
    }
  });
});