frontend/e2e/catalog-list.test.ts
Rama master · réplica de lectura del repositorio original.
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { after, before, describe, it } from 'node:test';
import type { Browser } from 'playwright-core';
import { launch, layout, settle, VIEWPORTS, visit, withPageDiagnostics } from './harness.ts';
describe('馆藏卡片与列表', () => {
let browser: Browser;
before(async () => { browser = await launch() });
after(async () => { await browser?.close() });
for (const viewport of VIEWPORTS) it(`${viewport.name}:多选隐藏卡片悬浮操作,退出后恢复`, { timeout: 60000 }, async () => {
const opened = await visit(browser, '/', viewport);
const { page } = opened;
try {
await page.locator('#grid [data-media-card] [data-media-later]').first().waitFor({ state: 'attached' });
await page.locator('#selectMode').click();
await page.waitForFunction(() => document.querySelectorAll('#grid [data-media-card] [data-media-later]').length === 0);
assert.equal(await page.locator('#grid [data-media-card] [data-media-seek]').count(), 0);
assert.ok(await page.locator('#grid [data-media-check]').count() > 0);
await page.locator('#selectMode').click();
await page.locator('#grid [data-media-card] [data-media-later]').first().waitFor({ state: 'attached' });
assert.deepEqual(opened.problems, []);
} finally { await opened.close() }
});
for (const viewport of VIEWPORTS) {
it(`${viewport.name} 显示切换、文件夹开关、详情与刷新保留列表`, { timeout: 90_000 }, async () => {
const opened = await visit(browser, '/?browse=folders&loc=local', viewport);
const { page } = opened;
try {
await page.locator('#grid [data-media-card]').first().waitFor({ state: 'visible' });
const folder = page.getByRole('button', { name: '文件夹模式', exact: true });
assert.equal(await folder.getAttribute('aria-pressed'), 'true');
assert.equal(await folder.evaluate(node => !!node.closest('[data-entity-sorts]')), true);
const choose = (name: string) => page.getByRole('radiogroup', { name: '馆藏显示方式' })
.locator('label').filter({ has: page.getByRole('radio', { name, exact: true }) }).click();
await choose('列表');
await page.locator('[data-catalog-table] [data-catalog-row]').first().waitFor();
await settle(page, '#grid');
const count = await page.locator('#grid [data-catalog-row]').count();
assert.ok(count > 0);
const listedIds = await page.locator('#grid [data-catalog-row]').evaluateAll(rows => rows.map(row => row.getAttribute('data-id')).sort());
assert.equal(await page.locator('#grid [data-mix-card]').count(), 0);
const geometry = await page.locator('#grid [data-catalog-row]').first().evaluate(node => {
const row = node.getBoundingClientRect();
const picture = node.querySelector('[data-list-picture]')!.getBoundingClientRect();
const title = node.querySelector('[data-list-name]')!.getBoundingClientRect();
const people = node.querySelector('[data-list-people]');
const cell = (selector: string) => node.querySelector(selector)?.closest('td')?.getBoundingClientRect().left ?? -1;
return { width: row.width, height: row.height, pictureRight: picture.right, titleLeft: title.left,
radius: getComputedStyle(node).borderBottomLeftRadius, identityAvatar: !!node.querySelector('[data-list-identity] [data-media-avatar]'),
peopleAvatar: !!people?.querySelector('[data-media-avatar]'), nameCell: cell('[data-list-identity]'), peopleCell: cell('[data-list-people]') };
});
assert.ok(geometry.height <= 120 && geometry.width > geometry.height * 2, JSON.stringify(geometry));
assert.ok(geometry.titleLeft > geometry.pictureRight, JSON.stringify(geometry));
assert.equal(geometry.radius, '0px');
assert.ok(!geometry.identityAvatar && geometry.peopleAvatar && geometry.peopleCell > geometry.nameCell,
`头像与署名拆成作品列之后单独的艺人列:${JSON.stringify(geometry)}`);
const screenshots = join('..', 'build', 'agent-verification', 'folders');
mkdirSync(screenshots, { recursive: true });
await page.screenshot({ path: join(screenshots, `${viewport.name}-list.png`), fullPage: false });
await page.reload({ waitUntil: 'load' });
await page.locator('[data-catalog-table] [data-catalog-row]').first().waitFor();
assert.equal(await page.getByRole('radio', { name: '列表', exact: true }).isChecked(), true);
const pageSize = page.getByRole('button', { name: /每页显示数量/ });
await pageSize.scrollIntoViewIfNeeded();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await pageSize.click();
await page.getByRole('option', { name: '每页 10 部', exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll('[data-catalog-row]').length === 10);
await page.getByRole('button', { name: '下一页', exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll('[data-catalog-row]').length === 2);
await page.locator('#grid [data-list-open]').first().click();
await page.waitForURL(/\/(?:assets|works)\//);
await page.goBack();
await page.locator('[data-catalog-table] [data-catalog-row]').first().waitFor();
await page.waitForFunction(() => document.querySelectorAll('[data-catalog-row]').length === 2);
assert.equal(await page.getByRole('button', { name: '第 2 页', exact: true }).getAttribute('aria-current'), 'page');
await choose('卡片');
await page.locator('[data-catalog-view-mode="cards"] [data-media-card]').first().waitFor();
// 卡片首批只覆盖视口;列表的一页可能已经包含全部样本,先正常续载再比较同一集合。
while (await page.locator('#grid [data-media-card]').count() < count) {
const before = await page.locator('#grid [data-media-card]').count();
if (!await page.locator('#grid [data-load-more]').count()) break;
await page.evaluate(() => document.querySelector('#grid [data-load-more]')?.scrollIntoView());
await page.waitForFunction(before => document.querySelectorAll('#grid [data-media-card]').length > before
|| !document.querySelector('#grid [data-load-more]'), before);
}
assert.equal(await page.locator('#grid [data-media-card]').count(), count);
assert.deepEqual(await page.locator('#grid [data-media-card]').evaluateAll(cards => cards.map(card => card.getAttribute('data-id')).sort()),
listedIds, '切换和刷新后卡片应展示列表中的同一批作品');
if (viewport.mobile) {
await page.evaluate(() => {
document.documentElement.dataset.theme = 'dark';
document.documentElement.classList.add('dark');
});
await page.screenshot({ path: join(screenshots, 'mobile-cards-dark.png'), fullPage: false });
}
const measured = await layout(page);
assert.ok(measured.scrollWidth <= measured.viewportWidth + 1, JSON.stringify(measured));
assert.deepEqual(opened.problems.filter(line => !line.includes('VIDEOJS')), []);
} finally { await opened.close() }
});
it(`${viewport.name} 全集排序、每页数量与跨页选择`, { timeout: 90_000 }, async () => {
const requests: URLSearchParams[] = [];
const entries = Array.from({ length: 45 }, (_, index) => ({
id: 9000 + index, name: `条目 ${String(index + 1).padStart(2, '0')}${index === 44 ? '超长文件名'.repeat(60) : ''}.mp4`,
medium: 'video', location: 'local', duration: 45 - index, size: index * 1024,
}));
/* 导航前挂上:首屏网格的请求可能早于 load,挂晚了它会落到真实服务上。 */
const opened = await visit(browser, '/?browse=folders&loc=local', viewport, page => page.route('**/api/items?*', route => {
const params = new URL(route.request().url()).searchParams;
requests.push(params);
const direction = params.get('dir') === 'asc' ? 1 : -1;
const sorted = [...entries].sort((a, b) => params.get('sort') === 'dur'
? direction * (a.duration - b.duration) : direction * a.name.localeCompare(b.name));
const offset = Number(params.get('offset') || 0), limit = Number(params.get('limit') || 60);
return route.fulfill({ json: { items: sorted.slice(offset, offset + limit), total: sorted.length } });
}));
const { page } = opened;
try {
await page.locator('#grid [data-media-card]').first().waitFor({ state: 'visible' });
await page.getByRole('radiogroup', { name: '馆藏显示方式' }).locator('label')
.filter({ has: page.getByRole('radio', { name: '列表', exact: true }) }).click();
await withPageDiagnostics(page, '切到列表后第一页没有出现', () =>
page.locator('[data-catalog-row][data-id="9044"]').waitFor());
assert.equal(await page.locator('[data-catalog-row]').count(), 20);
assert.equal(requests.at(-1)!.get('limit'), '20');
assert.equal(requests.at(-1)!.get('loc'), 'local');
assert.equal(requests.at(-1)!.get('browse'), 'folders');
const first = page.locator('[data-catalog-row]').first();
const firstId = await first.getAttribute('data-id');
await page.locator('#selectMode').click();
await first.locator('label').click();
assert.equal(await first.getAttribute('data-board-selected'), 'true');
await page.getByRole('button', { name: '下一页', exact: true }).click();
await page.locator('[data-catalog-row][data-id="9024"]').waitFor();
assert.equal(requests.at(-1)!.get('offset'), '20');
await page.locator('[data-catalog-row]').first().locator('label').click();
await page.getByRole('button', { name: '上一页', exact: true }).click();
await page.locator(`[data-catalog-row][data-id="${firstId}"][data-board-selected]`).waitFor();
const sortButton = page.getByRole('button', { name: /作品列表排序/ });
await sortButton.scrollIntoViewIfNeeded();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await sortButton.click();
const sortedResponse = page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/items' && url.searchParams.get('sort') === 'dur' && url.searchParams.get('dir') === 'asc';
});
try { await page.getByRole('option', { name: '时长', exact: true }).click({ timeout: 5000 }) }
catch (error) {
console.info('排序菜单', await sortButton.getAttribute('aria-expanded'), await page.locator('[role="option"]').allTextContents());
throw error;
}
await sortedResponse;
await page.locator('[data-catalog-row][data-id="9044"]').waitFor();
await page.waitForFunction(() => document.querySelector('[data-catalog-row]')?.getAttribute('data-id') === '9044');
assert.equal(requests.at(-1)!.get('sort'), 'dur');
assert.equal(requests.at(-1)!.get('dir'), 'asc');
assert.equal(requests.at(-1)!.get('offset'), '0');
const pageSizeButton = page.getByRole('button', { name: /每页显示数量/ });
await pageSizeButton.scrollIntoViewIfNeeded();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await pageSizeButton.click();
try { await page.getByRole('option', { name: '每页 10 部', exact: true }).click({ timeout: 5000 }) }
catch (error) {
console.info('分页菜单', await pageSizeButton.getAttribute('aria-expanded'),
await page.locator('[role="option"]').allTextContents(), await pageSizeButton.boundingBox());
await page.screenshot({ path: `../build/agent-verification/pagination-menu-${viewport.name}.png` });
throw error;
}
await page.waitForFunction(() => document.querySelectorAll('[data-catalog-row]').length === 10);
assert.equal(requests.at(-1)!.get('limit'), '10');
if (!viewport.mobile) {
const namedResponse = page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/items' && url.searchParams.get('sort') === 'name';
});
await page.getByRole('columnheader', { name: /^作品/ }).click();
await namedResponse;
await page.waitForFunction(() => document.querySelector('[data-catalog-row]')?.getAttribute('data-id') === '9000');
assert.equal(requests.at(-1)!.get('dir'), 'asc');
}
const durationSort = page.locator('[data-entity-sort="dur"]');
await durationSort.scrollIntoViewIfNeeded();
const durationResponse = page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/items' && url.searchParams.get('sort') === 'dur' && url.searchParams.get('dir') === 'desc';
});
await durationSort.click();
await durationResponse;
await page.waitForFunction(() => document.querySelector('[data-catalog-row]')?.getAttribute('data-id') === '9000');
assert.equal(await durationSort.getAttribute('aria-pressed'), 'true');
assert.equal(requests.at(-1)!.get('offset'), '0');
const measured = await layout(page);
assert.ok(measured.scrollWidth <= measured.viewportWidth + 1, JSON.stringify(measured));
assert.deepEqual(opened.problems.filter(line => !line.includes('VIDEOJS')), []);
} finally { await opened.close() }
});
}
});