frontend/e2e/catalog-filter.test.ts
Rama master · réplica de lectura del repositorio original.
/* 首页上方那一整块:两排头像、玻璃浮层的两排(视图与标签、读数与排序)和网格上面那条交集条
* (`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();
}
});
});