Ctrl K
ComunidadGuías…

frontend/e2e/catalog-filter.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 首页上方那一整块:两排头像、玻璃浮层的两排(视图与标签、读数与排序)和网格上面那条交集条
 * (`catalog-filter` 岛,ADR-0031)。
 *
 * 作品、详情与图片沿用 `item-fixture.ts` 的桩;两排头像(`/api/tops`)、标签(`/api/facets` 的 `tags`)
 * 与目录列表在这里另给一份,好数请求、好让「按了什么」和「重取了几次」对得上。验的是行为:
 * 点下去发什么请求、按下态与地址怎么变、详情往返时这几排是不是原来那批节点。外观对照另有截图。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

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

import { launch, layout, settle, VIEWPORTS, withPageDiagnostics } from './harness.ts';
import { CATALOG, ITEM, openItemPage, type ItemVisit } from './item-fixture.ts';

const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;
const PHONE = VIEWPORTS.find((viewport) => viewport.mobile)!;
const DETAIL_READY = '#stage[open] [data-item-side]';
const FILTER = '[data-catalog-filter]';
const READOUT = `${FILTER} [data-count-readout]`;

/** 第一页四十位女优、十个厂牌;女优那排还有一页二十位,再往后就到底了。 */
const PERFORMERS = Array.from({ length: 60 }, (_, at) => ({
  id: 95_000 + at, k: `演示女优${String(at + 1).padStart(2, '0')}`, n: 60 - at,
  has_image: true, has_avatar: false, avatar_focus: null, rep: null,
}));
const STUDIOS = Array.from({ length: 10 }, (_, at) => ({ k: `演示厂牌${at + 1}`, n: 20 - at, has_logo: false }));
const TAGS = Array.from({ length: 40 }, (_, at) => ({ k: `演示标签${String(at + 1).padStart(2, '0')}`, n: 90 - at }));

interface Counts {
  /** 目录分页带 offset;竖屏、搜索推荐与库存在性探测各走各的。窄屏首批可以只有一张。 */
  items: URL[];
  summaries: URL[];
  facets: URL[];
  tops: URL[];
}

const pathIs = (page: Page, path: string) => page.waitForFunction(
  (want) => decodeURIComponent(location.pathname) === want, path, { timeout: 10_000 });
const withoutPlayer = (problems: string[]) => problems.filter((line) => !line.includes('VIDEOJS'));

/** 等 Node 这一侧记下的请求数到位:桩在 Node 里计数,页面里的 `waitForFunction` 读不到。 */
async function until(done: () => boolean, message: string | (() => string), timeout = 10_000) {
  const end = Date.now() + timeout;
  while (!done()) {
    if (Date.now() > end) assert.fail(typeof message === 'function' ? message() : message);
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
}

/** 打开首页:桩装好之后重新载入,等到读数出现、等待态结束。 */
async function openHome(browser: Browser, path = '/', viewport = DESKTOP): Promise<ItemVisit & { counts: Counts }> {
  const opened = await openItemPage(browser, '/', viewport, { ready: '#grid [data-media-card]' });
  const { page } = opened;
  const counts: Counts = { items: [], summaries: [], facets: [], tops: [] };
  await page.route((url) => url.pathname === '/api/tops', (route) => {
    const url = new URL(route.request().url());
    counts.tops.push(url);
    const page = Number(url.searchParams.get('page') || 0);
    const performers = page === 0 ? PERFORMERS.slice(0, 40) : page === 1 ? PERFORMERS.slice(40) : [];
    return route.fulfill({ json: { performers, studios: page ? [] : STUDIOS } });
  });
  await page.route((url) => url.pathname === '/api/facets', async (route) => {
    counts.facets.push(new URL(route.request().url()));
    const response = await route.fetch();
    await route.fulfill({ json: { ...await response.json(), tags: TAGS } });
  });
  /* 加一条标签筛选,列表就只剩一半:读数跟着换,才看得出这一趟真按新口径取了。 */
  await page.route((url) => url.pathname === '/api/items', (route) => {
    const url = new URL(route.request().url());
    if (url.searchParams.get('summary_only') === '1') counts.summaries.push(url);
    else if (!url.searchParams.has('orient') && url.searchParams.has('offset')) counts.items.push(url);
    const ids = url.searchParams.get('tag') || url.searchParams.get('owner') === 'none' ? CATALOG.slice(0, 3) : CATALOG;
    return route.fulfill({ json: {
      items: ids.map((id) => ({ id, name: `sample_${id}.mp4`, title: `演示作品 ${id}`, is_jav: false, medium: 'video',
        location: 'local', has_thumb: true, duration: 3600, studio: 'Peach Studio', performers: [], tags: [] })),
      total: ids.length, has_more: false,
    } });
  });
  await page.goto(new URL(path, page.url()).toString(), { waitUntil: 'load' });
  await page.locator(READOUT, { hasText: '个符合' }).waitFor({ timeout: 15_000 });
  await page.locator(`${FILTER} [data-tier-performer]`).first().waitFor({ timeout: 15_000 });
  await settle(page);
  return { ...opened, counts };
}

const tag = (page: Page, key: string) => page.locator(`${FILTER} [data-catalog-tag="${key}"]`);
const chips = (page: Page) => page.locator('#combo [data-combo-chip]');

describe('首页筛选条', () => {
  let browser: Browser;
  before(async () => { browser = await launch(); });
  after(async () => { await browser?.close(); });

  it('JAV 读数尚未返回时保持两行骨架,内容就绪后再决定合行', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser, '/?jav=1');
    const { page } = opened;
    let release!: () => void;
    const waiting = new Promise<void>(resolve => { release = resolve });
    try {
      await page.route((url) => url.pathname === '/api/facets', async route => {
        const response = await route.fetch();
        await route.fulfill({ json: { ...await response.json(), tags: TAGS.slice(0, 1) } });
      });
      await page.route((url) => url.pathname === '/api/items', async route => { await waiting; await route.fallback() });
      await page.setViewportSize({ width: 1920, height: 900 });
      await page.reload();
      const frame = page.locator(`${FILTER} [data-catalog-frame]`);
      await frame.locator('[data-catalog-tag]').first().waitFor();
      assert.equal(await frame.getAttribute('data-single-row'), null);
      assert.equal(await frame.evaluate(node => node.getBoundingClientRect().height), 96);
      release();
      await page.locator(READOUT, { hasText: '个符合' }).waitFor();
      await page.locator(`${FILTER} [data-catalog-frame][data-single-row]`).waitFor();
      assert.deepEqual(opened.problems, []);
    } finally { release(); await opened.close() }
  });

  it('少量筛选项与右侧操作按容器宽度同行或分行', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    const { page } = opened;
    try {
      await page.route((url) => url.pathname === '/api/facets', async route => {
        const response = await route.fetch();
        await route.fulfill({ json: { ...await response.json(), tags: TAGS.slice(0, 1) } });
      });
      await page.setViewportSize({ width: 1920, height: 900 });
      await page.reload(); await settle(page);
      const frame = page.locator(`${FILTER} [data-catalog-frame]`);
      await frame.locator('[data-catalog-tag]').first().waitFor();
      await page.locator(`${FILTER} [data-catalog-frame][data-single-row]`).waitFor();
      const wide = await frame.evaluate(element => {
        const top = element.querySelector('[data-filter-row="top"]')!.getBoundingClientRect();
        const bottom = element.querySelector('[data-filter-row="bottom"]')!.getBoundingClientRect();
        return { same: Math.abs(top.y - bottom.y) < 2, after: bottom.x >= top.right - 2 };
      });
      assert.deepEqual(wide, { same: true, after: true });
      await page.setViewportSize({ width: 390, height: 844 });
      await page.locator(`${FILTER} [data-catalog-frame]:not([data-single-row])`).waitFor();
      assert.ok((await layout(page)).scrollWidth <= 390);
    } finally { await opened.close(); }
  });

  for (const viewport of [DESKTOP, PHONE]) {
    it(`${viewport.name} 筛选条件与作品卡片悬停面保持间距`, { timeout: 60_000 }, async () => {
      const opened = await openHome(browser, '/?studio=Peach%20Studio', viewport);
      try {
        await opened.page.mouse.move(0, 0);
        const controls = await opened.page.locator(`${FILTER} [data-filter-glass]`).evaluate(frame => {
          const candidates = [...frame.querySelectorAll('button,a[aria-pressed],[data-glass-segment]')]
            .filter(node => node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0);
          const actions = ['[data-shortcuts-trigger]', '[data-entity-batch]'].map(selector => {
            const node = frame.querySelector(selector)!;
            const style = getComputedStyle(node), rect = node.getBoundingClientRect();
            return { width: rect.width, height: rect.height, background: style.backgroundImage,
              color: style.color, border: style.border, shadow: style.boxShadow, radius: style.borderRadius };
          });
          return { heights: candidates.map(node => ({ label: node.getAttribute('aria-label') || node.textContent,
            height: node.getBoundingClientRect().height })), actions,
            compact: parseFloat(getComputedStyle(frame).getPropertyValue('--control-h-compact')) };
        });
        // 筛选条整条取紧凑档:页签、药丸、分段与两枚动作键同高。
        assert.equal(controls.compact, 32);
        for (const control of controls.heights) assert.equal(control.height, controls.compact, JSON.stringify(control));
        assert.deepEqual(controls.actions[0], controls.actions[1]);
        assert.equal(controls.actions[0].width, controls.compact);
        assert.equal(controls.actions[0].color, 'rgb(255, 255, 255)');
        assert.match(controls.actions[0].background, /^linear-gradient\(/);
        const gap = await opened.page.evaluate(() => document.querySelector('#grid [data-media-card]')!.getBoundingClientRect().top
          - document.querySelector('#combo [data-entity-combo]')!.getBoundingClientRect().bottom - 8);
        assert.ok(gap >= 16, `筛选条件距卡片悬停面只有 ${gap}px`);
        assert.deepEqual(withoutPlayer(opened.problems), []);
      } finally { await opened.close(); }
    });
  }

  it('点头像条一格打开那一位的资料页;地址里的厂牌筛选出一颗交集条,撤掉它重取一次列表', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser, '/?studio=Peach%20Studio');
    try {
      const { page, counts } = opened;
      await chips(page).filter({ hasText: '厂牌 Peach Studio' }).waitFor();
      const before = counts.items.length;
      await chips(page).filter({ hasText: 'Peach Studio' }).getByRole('button', { name: '撤掉 厂牌 Peach Studio' }).click();
      await page.waitForFunction(() => !new URLSearchParams(location.search).has('studio'));
      await page.locator(READOUT, { hasText: `${CATALOG.length} 个符合` }).waitFor();
      assert.equal(await chips(page).count(), 0, '撤掉之后交集条上还有东西');
      const after = counts.items.slice(before);
      assert.ok(after.length >= 1 && after.every((url) => !url.searchParams.has('studio')), '撤掉之后没有按新口径重取');
      await page.locator(`${FILTER} [data-tier-performer]`).first().click();
      await pathIs(page, `/performers/${PERFORMERS[0]!.id}`);
    } finally {
      await opened.close();
    }
  });

  it('标签条点选:按下态当场变,出一颗交集条,读数和侧栏计数按新口径各取一次;交集条上撤掉它回到原样', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page, counts } = opened;
      /* 首屏那批标签按种子从四十枚里抽,点屏幕上的第一枚,不点清单里的第一枚。 */
      const key = (await page.locator(`${FILTER} [data-catalog-tag]`).first().getAttribute('data-catalog-tag'))!;
      await page.evaluate((filter) => {
        const first = document.querySelector(`${filter} [data-tier-performer]`) as HTMLElement & { __kept?: boolean };
        first.__kept = true;
      }, FILTER);
      const [items, facets, summaries] = [counts.items.length, counts.facets.length, counts.summaries.length];
      const order = () => page.locator(`${FILTER} [data-catalog-tag]`).evaluateAll((pills) =>
        pills.map((pill) => pill.getAttribute('data-catalog-tag')));
      const members = await order();
      /* 计数是标签的附注:隔开一段、字号降一档、字色退一档,不和标签名读成一个词。 */
      const footnote = await tag(page, key).evaluate((pill) => {
        const badge = pill.querySelector('[data-count-badge]')!;
        const [p, b] = [getComputedStyle(pill), getComputedStyle(badge)];
        return { gap: parseFloat(b.marginLeft), smaller: parseFloat(b.fontSize) < parseFloat(p.fontSize),
          dimmer: b.color !== p.color, spaced: !/\s$/.test(badge.previousSibling?.textContent ?? '') };
      });
      assert.ok(footnote.gap >= 4, `计数离标签只有 ${footnote.gap}px`);
      assert.deepEqual([footnote.smaller, footnote.dimmer, footnote.spaced], [true, true, true],
        '计数和标签同字号、同色,或者靠空格文本节点隔开');
      await tag(page, key).click();
      await page.locator(`${FILTER} [data-catalog-tag="${key}"][aria-pressed="true"]`).waitFor({ timeout: 2_000 });
      await chips(page).filter({ hasText: key }).waitFor();
      await page.locator(READOUT, { hasText: '3 个符合' }).waitFor();
      assert.equal(new URL(page.url()).searchParams.get('tag'), key);
      assert.deepEqual(counts.items.slice(items).map((url) => url.searchParams.get('tag')), [key],
        '点一枚标签应当只按新口径取一次列表');
      assert.deepEqual(counts.summaries.slice(summaries).map((url) => url.searchParams.get('tag')), [key],
        '独立读数应当按新口径取一次统计');
      assert.deepEqual(counts.facets.slice(facets).map((url) => url.searchParams.get('tag')), [key],
        '侧栏计数应当按新口径取一次聚合');
      /* 口径变了两排头像会按新口径再取一次(`getBarsData`),但同一位的那一格原地换数,不拆了重建。 */
      assert.equal(await page.evaluate((filter) => !!(document.querySelector(`${filter} [data-tier-performer]`) as
        (HTMLElement & { __kept?: boolean }) | null)?.__kept, FILTER), true, '加一条筛选把头像条拆了重建');
      /* 标签条的成员与次序不动:刚点的那枚还在原位,没被挪到最前面去。 */
      assert.deepEqual(await order(), members, '加一条筛选把标签条重排了');
      await chips(page).filter({ hasText: key }).getByRole('button').click();
      await page.locator(READOUT, { hasText: `${CATALOG.length} 个符合` }).waitFor();
      await page.locator(`${FILTER} [data-catalog-tag="${key}"][aria-pressed="false"]`).waitFor({ timeout: 2_000 });
      assert.equal(await chips(page).count(), 0);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('头像条横滚到右端先把手上这页摆完,再要下一页,要到空页就停', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page, counts } = opened;
      const row = page.locator(`${FILTER} [data-catalog-tier="performers"]`);
      const shown = () => row.locator('[data-tier-performer]').count();
      assert.equal(await shown(), 24, '首屏应当只摆 24 枚');
      for (let at = 0; at < 20 && await shown() < PERFORMERS.length; at += 1) {
        await row.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
        await page.waitForTimeout(120);
      }
      assert.equal(await shown(), PERFORMERS.length, '续页没有把第二页摆出来');
      for (let at = 0; at < 5; at += 1) {
        await row.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
        await page.waitForTimeout(120);
      }
      /* 请求不带是哪一排:厂牌那排第一页就摆得下,挂上时已在右端,要一次第 1 页(空的)就停。女优那排
         翻到第 1 页时参数串相同,共用那一次回包,接着自己要第 2 页。 */
      assert.deepEqual(counts.tops.map((url) => url.searchParams.get('page') ?? '').sort(), ['', '1', '2'],
        '续页的页号不对,或到底之后还在要');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('排序键:按下态当场到位,网格只重取一次;再点一次同一枚翻方向', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page, counts } = opened;
      const key = page.locator(`${FILTER} [data-entity-sort="new"]`);
      const before = counts.items.length;
      await key.click();
      await page.locator(`${FILTER} [data-entity-sort="new"][aria-pressed="true"]`).waitFor({ timeout: 2_000 });
      await until(() => counts.items.length > before, '换排序之后没有重取列表');
      await settle(page);
      assert.deepEqual(counts.items.slice(before).map((url) => url.searchParams.get('sort')), ['new'],
        '换一次排序应当只重取一次列表');
      /* 只数第一页:厂牌那排挂上时在右端,续要的那次空页与排序无关。 */
      assert.equal(counts.tops.filter((url) => !url.searchParams.has('page')).length, 1, '换排序不该重取两排头像');
      await key.click();
      await until(() => counts.items.length > before + 1, '翻方向之后没有重取列表');
      await settle(page);
      assert.deepEqual(counts.items.slice(before).map((url) => url.searchParams.get('dir') || ''), ['desc', 'asc']);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('卡片版式:只重画不重取,选择记进设置', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page, counts } = opened;
      const choice = (value: string) => page.locator(`${FILTER} input[name="home-layout"][value="${value}"]`);
      const before = counts.items.length;
      assert.equal(await choice('small').isChecked(), true);
      await page.locator(`${FILTER} label:has(input[name="home-layout"][value="big"])`).click();
      assert.equal(await choice('big').isChecked(), true);
      const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('peach.settings.v1') || '{}'));
      assert.equal(saved.homeLayout, 'big');
      assert.equal(counts.items.length, before, '换版式不该重取列表');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  for (const viewport of [DESKTOP, PHONE]) {
  it(`${viewport.name} JAV 馆藏保留目录版式、筛选和顶部搜索,三个分区可往返`, { timeout: 60_000 }, async () => {
    const opened = await openHome(browser, '/', viewport);
    try {
      const { page, counts } = opened;
      const before = counts.items.length;
      const homeIds = await page.locator('#grid [data-media-card]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-id')));
      if (viewport.mobile) await page.locator('#filterBtn').click();
      await page.locator('button[data-nav="jav"]:visible').first().click();
      await page.waitForFunction(() => location.pathname === '/' && new URLSearchParams(location.search).get('jav') === '1');
      await page.getByRole('tab', { name: '馆藏', exact: true, selected: true }).waitFor();
      await page.locator('#grid [data-media-card]').first().waitFor();
      await settle(page);
      assert.ok(counts.items.slice(before).some((url) => url.searchParams.get('jav') === '1'), 'JAV 口径没有重取列表');
      assert.equal(await page.evaluate(() => document.querySelector('#javLibraryHeader')!.getBoundingClientRect().bottom <= document.querySelector('#catalogFilter [data-catalog-tier="performers"]')!.getBoundingClientRect().top), true, 'JAV 标题和分区应在可见筛选行之前');
      assert.equal(await page.locator('#catalogPageHeading').isVisible(), false);
      assert.equal(await page.locator(`${FILTER} input[name="home-layout"]`).first().isVisible(), false);
      await page.locator(`${FILTER} input[name="jav-layout"]`).first().waitFor({ state: 'attached' });
      assert.ok(await page.locator(`${FILTER} [data-tier-performer]`).count() > 0, 'JAV 没有女优筛选');
      assert.ok(await page.locator(`${FILTER} [data-tier-studio]`).count() > 0, 'JAV 没有厂牌筛选');
      assert.ok(await page.locator(`${FILTER} [data-catalog-tag]`).count() > 0, 'JAV 没有标签筛选');
      assert.equal(await page.getByRole('textbox', { name: '搜索作品', exact: true }).count(), 0);
      const width = await page.evaluate(() => ({
        grid: document.querySelector('#grid')!.getBoundingClientRect().width,
        main: document.querySelector('#main')!.clientWidth,
      }));
      assert.ok(width.grid >= width.main - 40, 'JAV 网格没有铺满目录宽度');
      await page.locator(`${FILTER} label:has(input[name="jav-layout"][value="big"])`).click();
      assert.equal(await page.locator(`${FILTER} input[name="jav-layout"][value="big"]`).isChecked(), true);
      const beforeSearch = counts.items.length;
      if (viewport.mobile) await page.locator('#searchBtn').click();
      await page.locator('#q').fill('演示作品');
      await page.locator('#q').press('Enter');
      await withPageDiagnostics(page, '作品搜索没有按搜索词重取 JAV 馆藏', () => until(
        () => counts.items.slice(beforeSearch).some(url => url.searchParams.get('q') === '演示作品'),
        () => JSON.stringify({ url: page.url(), list: counts.items.slice(beforeSearch).map(url => url.search),
          summaries: counts.summaries.map(url => url.search) })));
      assert.equal(counts.items.at(-1)?.searchParams.get('jav'), '1');
      assert.equal(counts.items.at(-1)?.searchParams.get('q'), '演示作品');
      assert.equal(new URL(page.url()).searchParams.get('jav'), '1');
      assert.equal(new URL(page.url()).searchParams.get('q'), '演示作品');
      if (viewport.mobile) await page.locator('#searchBack').click();
      await page.getByRole('tab', { name: '发现', exact: true }).click();
      await page.getByRole('tab', { name: '发现', exact: true, selected: true }).waitFor();
      assert.equal(await page.locator('#javLibraryHeader').isVisible(), false);
      await page.getByRole('tab', { name: '想要', exact: true }).click();
      await page.getByRole('tab', { name: '想要', exact: true, selected: true }).waitFor();
      await page.getByRole('tab', { name: '馆藏', exact: true }).click();
      await page.getByRole('tab', { name: '馆藏', exact: true, selected: true }).waitFor();
      await page.locator('#grid [data-media-card]').first().waitFor();
      assert.equal(await page.locator(`${FILTER} input[name="jav-layout"][value="big"]`).isChecked(), true);
      const box = await layout(page);
      assert.ok(box.scrollWidth <= box.viewportWidth, 'JAV 页面横向溢出');
      await settle(page);
      const beforeHome = counts.items.length;
      if (viewport.mobile) await page.locator('#filterBtn').click();
      await page.locator('button[data-nav=""]:visible').first().click();
      await page.waitForFunction(() => location.pathname === '/' && !new URLSearchParams(location.search).has('jav'));
      await page.locator(`${FILTER} input[name="home-layout"]`).first().waitFor({ state: 'attached' });
      await page.locator(READOUT, { hasText: '个符合' }).waitFor();
      await settle(page);
      assert.equal(counts.items.length, beforeHome, '返回首页沿用原来的目录缓存');
      assert.deepEqual(await page.locator('#grid [data-media-card]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-id'))), homeIds);
      assert.equal(await page.locator('#javLibraryHeader').isVisible(), false);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });
  }

  it('未归属随首页位置保留,撤掉筛选后取全库,后退恢复未归属缓存', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser, '/?owner=none');
    try {
      const { page, counts } = opened;
      assert.equal(counts.items.at(-1)?.searchParams.get('owner'), 'none', '地址上的未归属没有进列表请求');
      let before = counts.items.length;
      const mainCards = page.locator('#grid [data-media-grid] > [data-media-card]');
      const filteredIds = await mainCards.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-id')));
      await page.locator('[data-nav=""]:visible').first().click();
      await settle(page);
      assert.equal(new URL(page.url()).searchParams.get('owner'), 'none');
      assert.equal(counts.items.length, before, '点击当前栏目保留筛选');
      await chips(page).filter({ hasText: '未归属' }).getByRole('button').click();
      await page.waitForFunction(() => !new URLSearchParams(location.search).has('owner'));
      await until(() => counts.items.length > before, '撤掉未归属之后没有重取列表');
      await settle(page);
      assert.equal(counts.items.at(-1)?.searchParams.has('owner'), false);
      assert.equal(await mainCards.count(), CATALOG.length);
      before = counts.items.length;
      await page.goBack();
      await page.waitForFunction(() => new URLSearchParams(location.search).get('owner') === 'none');
      await settle(page);
      assert.equal(counts.items.length, before, '后退恢复已取得的未归属结果');
      assert.equal(await chips(page).filter({ hasText: '未归属' }).count(), 1);
      assert.deepEqual(await mainCards.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-id'))), filteredIds);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('进详情再关回来:头像条还是原来那批节点,图还是解码过的那一张,也没有多发聚合请求', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page, counts } = opened;
      await page.waitForFunction((filter) => {
        const img = document.querySelector(`${filter} [data-tier-ring] > img`) as HTMLImageElement | null;
        return !!img && img.complete && img.naturalWidth > 0;
      }, FILTER, { timeout: 10_000 });
      await page.evaluate((filter) => {
        const first = document.querySelector(`${filter} [data-tier-performer]`) as HTMLElement & { __kept?: boolean };
        first.__kept = true;
        (first.querySelector('[data-tier-ring] > img') as HTMLImageElement & { __kept?: boolean }).__kept = true;
      }, FILTER);
      const [facets, tops] = [counts.facets.length, counts.tops.length];
      await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().click();
      await pathIs(page, `/assets/${ITEM.plain}`);
      await page.locator(DETAIL_READY).waitFor();
      await page.locator('#closeStage').click();
      await pathIs(page, '/');
      await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {});
      await settle(page);
      const kept = await page.evaluate((filter) => {
        const first = document.querySelector(`${filter} [data-tier-performer]`) as (HTMLElement & { __kept?: boolean }) | null;
        const img = first?.querySelector('[data-tier-ring] > img') as (HTMLImageElement & { __kept?: boolean }) | null;
        return { same: !!first?.__kept, img: !!img?.__kept && img.complete && img.naturalWidth > 0 };
      }, FILTER);
      assert.deepEqual(kept, { same: true, img: true }, '关掉详情时头像条被重建了');
      assert.equal(counts.facets.length, facets, '详情往返多取了一遍聚合');
      assert.equal(counts.tops.length, tops, '详情往返多取了一遍头像');
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('去索引页再回首页:收起时不留一套同名的浮层,回来之后玻璃垫在当前视图下面', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page } = opened;
      await page.locator('[data-nav="tags"]:visible').first().click();
      await pathIs(page, '/tags');
      await settle(page);
      assert.equal(await page.locator(`${FILTER} [data-catalog-frame]`).count(), 0, '收起的首页浮层还留在页面上');
      assert.equal(await page.locator(`${FILTER} [data-skeleton], ${FILTER} [aria-busy="true"]`).count(), 0);
      await page.locator('[data-nav=""]:visible').first().click();
      await pathIs(page, '/');
      await page.locator(READOUT, { hasText: '个符合' }).waitFor();
      await settle(page);
      const back = await page.evaluate((filter) => {
        const rect = (node: Element | null) => {
          if (!node || (node as HTMLElement).hidden) return null;
          const { left, width } = node.getBoundingClientRect();
          return { left: Math.round(left), width: Math.round(width) };
        };
        return {
          glide: rect(document.querySelector(`${filter} [data-view-glide]`)),
          key: rect(document.querySelector(`${filter} [data-catalog-view][aria-pressed="true"]`)),
        };
      }, FILTER);
      assert.ok(back.glide && back.key && Math.abs(back.glide.left - back.key.left) <= 1
        && Math.abs(back.glide.width - back.key.width) <= 1, `玻璃没有垫在当前视图下面 ${JSON.stringify(back)}`);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('手机 390 宽:视图和标签整段一起横滚,页面本身不被撑宽', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser, '/', PHONE);
    try {
      const { page } = opened;
      const scroller = page.locator(`${FILTER} [data-catalog-scroll]`);
      const before = await scroller.evaluate((element) => ({
        overflow: getComputedStyle(element).overflowX, room: element.scrollWidth - element.clientWidth,
        view: element.querySelector('[data-catalog-view]')!.getBoundingClientRect().left,
      }));
      assert.equal(before.overflow, 'auto', '窄屏下滚的应当是视图与标签外面那一层');
      assert.ok(before.room > 100, `视图与标签在 390 宽下应当放不下:多出 ${before.room}px`);
      await scroller.evaluate((element) => { element.scrollLeft = 200; });
      const moved = await scroller.evaluate((element) => ({
        left: element.scrollLeft, view: element.querySelector('[data-catalog-view]')!.getBoundingClientRect().left,
      }));
      assert.ok(moved.left > 0, '外层滚不动');
      assert.ok(moved.view < before.view - 100, '四枚视图没有跟着标签一起滚走');
      const controls = page.locator(`${FILTER} [data-filter-row="bottom"]`);
      const bottom = await controls.evaluate(element => {
        const summary = element.querySelector('[data-catalog-summary]')!.getBoundingClientRect();
        const sorts = element.querySelector('[data-entity-sorts]')!.getBoundingClientRect();
        return { height: element.getBoundingClientRect().height, summary: summary.y + summary.height / 2,
          sorts: sorts.y + sorts.height / 2, room: element.scrollWidth - element.clientWidth,
          overflow: getComputedStyle(element).overflowX };
      });
      assert.ok(Math.abs(bottom.summary - bottom.sorts) < 2, JSON.stringify(bottom));
      assert.ok(bottom.height <= 56 && bottom.room > 100 && bottom.overflow === 'auto', JSON.stringify(bottom));
      await controls.evaluate(element => { element.scrollLeft = 300 });
      assert.ok(await controls.evaluate(element => element.scrollLeft > 100));
      for (const width of [430, 390, 760, 761, 390]) {
        await page.setViewportSize({ width, height: 844 });
        const glass = await page.locator(`${FILTER} [data-filter-glass]`).evaluate(async element => {
          await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
          const style = getComputedStyle(element);
          return { filter: style.backdropFilter, width: element.getBoundingClientRect().width,
            viewport: document.documentElement.clientWidth };
        });
        assert.ok(glass.filter.includes('blur(') && glass.width < glass.viewport, JSON.stringify(glass));
      }
      const measured = await layout(page);
      assert.deepEqual(measured.offenders, [], `筛选条把页面撑宽了:${measured.offenders.join('、')}`);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });

  it('上次两排都没人:再进首页时取回之前不铺两排占位,浮层不随取回移位', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const { page } = opened;
      let delay = 0;
      // 后注册的先拦:这一组两排都没人,第二次进页拖住三秒,量的就是取回之前那一刻。
      await page.route((url) => url.pathname === '/api/tops', async (route) => {
        await new Promise((resolve) => setTimeout(resolve, delay));
        await route.fulfill({ json: { performers: [], studios: [] } });
      });
      await page.reload();
      await page.locator(`${FILTER} [data-catalog-root]:not([data-loading])`).waitFor();
      await settle(page);
      delay = 3_000;
      await page.reload({ waitUntil: 'domcontentloaded' });
      const frame = page.locator(`${FILTER} [data-catalog-frame]`);
      await frame.waitFor();
      const read = () => page.evaluate((filter) => ({
        rows: document.querySelectorAll(`${filter} [data-catalog-tier]`).length,
        loading: !!document.querySelector(`${filter} [data-catalog-root][data-loading]`),
        top: Math.round(document.querySelector(`${filter} [data-catalog-frame]`)!.getBoundingClientRect().top),
      }), FILTER);
      const waiting = await read();
      assert.deepEqual({ rows: waiting.rows, loading: waiting.loading }, { rows: 0, loading: true }, JSON.stringify(waiting));
      await page.locator(`${FILTER} [data-catalog-root]:not([data-loading])`).waitFor({ timeout: 10_000 });
      await settle(page);
      const done = await read();
      assert.equal(done.rows, 0);
      assert.ok(Math.abs(done.top - waiting.top) <= 1, `取回后浮层移位:${waiting.top} → ${done.top}`);
      assert.deepEqual(withoutPlayer(opened.problems), []);
    } finally {
      await opened.close();
    }
  });
});