frontend/e2e/catalog-table-layout.test.ts
Rama master · réplica de lectura del repositorio original.
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { Browser, Page } from 'playwright-core';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { launch, layout, VIEWPORTS, visit } from './harness.ts';
async function showTable(page: Page) {
const items = Array.from({ length: 12 }, (_, index) => ({ id: 98000 + index, name: `示例影片 ${index + 1}.mp4`,
medium: 'video', location: 'local', duration: 60, size: 1000000, width: 1920, height: 1080, rating: 80 }));
await page.route('**/api/items?*', route => route.fulfill({ json: { items, total: items.length } }));
await page.getByRole('radiogroup', { name: '馆藏显示方式' }).locator('label')
.filter({ has: page.getByRole('radio', { name: '列表', exact: true }) }).click();
await page.locator('[data-catalog-row][data-id="98000"]').waitFor();
}
describe('馆藏表格宽屏与选择操作', () => {
let browser: Browser;
before(async () => { browser = await launch() });
after(async () => { await browser?.close() });
it('行内评分保存、取消、重试和撤销均保持普通浏览状态', { timeout: 60000 }, async () => {
const opened = await visit(browser, '/', VIEWPORTS[0]!);
const { page } = opened;
let release!: () => void;
const pending = new Promise<void>(resolve => { release = resolve });
try {
await showTable(page);
const writes: number[] = [];
let failed = false;
await page.route('**/api/feedback', async route => {
const body = route.request().postDataJSON();
assert.equal(body.id, 98000); assert.equal(body.kind, 'rate');
writes.push(body.value);
if (writes.length === 1) await pending;
if (body.value === 40 && !failed) {
failed = true;
return route.fulfill({ status: 500, json: { error: '示例评分失败' } });
}
return route.fulfill({ json: { rating: body.value || null } });
});
const row = page.locator('[data-catalog-row]').first();
const five = row.getByRole('button', { name: '评为 5 星', exact: true });
await five.dblclick();
await page.waitForFunction(() => document.querySelector('[data-catalog-rating] button[aria-busy="true"]'));
assert.deepEqual(writes, [100]);
release();
await row.getByRole('group', { name: '评分 5 / 5', exact: true }).waitFor();
await row.getByRole('button', { name: '取消评分(当前 5 星)', exact: true }).click();
await row.getByRole('group', { name: '未评分', exact: true }).waitFor();
await row.getByRole('button', { name: '评为 2 星', exact: true }).click();
await row.getByText('示例评分失败', { exact: true }).waitFor();
await row.getByRole('button', { name: '立即重试评分', exact: true }).click();
await row.getByRole('group', { name: '评分 2 / 5', exact: true }).waitFor();
await page.locator('[data-sonner-toast]').filter({ hasText: '已评 2 星' }).getByRole('button', { name: '撤销', exact: true }).click();
await row.getByRole('group', { name: '未评分', exact: true }).waitFor();
assert.deepEqual(writes, [100, 0, 40, 40, 0]);
assert.equal(await page.locator('#selectMode').getAttribute('aria-pressed'), 'false');
assert.equal(new URL(page.url()).pathname, '/');
assert.deepEqual(opened.problems.filter(line => !line.includes('VIDEOJS') && !line.includes('/api/feedback') && !line.includes('500 (Internal Server Error)')), []);
} finally { release(); await opened.close() }
});
for (const folders of [false, true]) it(`宽屏${folders ? '文件夹' : '普通'}列表铺满外框且控件等高`, { timeout: 60000 }, async () => {
const opened = await visit(browser, folders ? '/?browse=folders' : '/', { name: 'wide', width: 2560, height: 1440, mobile: false });
const { page } = opened;
try {
await showTable(page);
const geometry = await page.locator('[data-catalog-table]').evaluate(node => {
const scroll = node.querySelector<HTMLElement>('.overflow-x-auto')!;
const table = node.querySelector('table')!.getBoundingClientRect();
const row = node.querySelector('[data-catalog-row]')!.getBoundingClientRect();
const header = node.querySelector('thead')!.getBoundingClientRect();
const heights = [...node.querySelectorAll('[role="toolbar"] button')].map(button => button.getBoundingClientRect().height);
const others = [...node.querySelectorAll('thead th:not([data-key="name"])')]
.reduce((sum, cell) => sum + cell.getBoundingClientRect().width, 0);
return { available: scroll.clientWidth, table: table.width, row: row.width, header: header.width,
name: node.querySelector('th[data-key="name"]')!.getBoundingClientRect().width, others, heights };
});
assert.ok(Math.abs(geometry.table - geometry.available) <= 1, JSON.stringify(geometry));
assert.ok(Math.abs(geometry.row - geometry.available) <= 1, JSON.stringify(geometry));
assert.ok(Math.abs(geometry.header - geometry.available) <= 1, JSON.stringify(geometry));
assert.ok(geometry.name > 400, JSON.stringify(geometry));
assert.ok(Math.abs(geometry.name + geometry.others - geometry.available) <= 2, JSON.stringify(geometry));
assert.ok(Math.max(...geometry.heights) - Math.min(...geometry.heights) <= 1, JSON.stringify(geometry));
const stars = await page.locator('[data-catalog-row]').first().locator('[data-catalog-rating]').evaluate(node => {
const icon = node.querySelector('svg')!;
const marker = document.createElement('span'); marker.style.color = 'var(--rating)'; node.append(marker);
const expected = getComputedStyle(marker).color; marker.remove();
return { width: icon.getBoundingClientRect().width, color: getComputedStyle(icon).color, expected };
});
assert.equal(stars.width, 19);
assert.equal(stars.color, stars.expected);
const screenshots = join('..', 'build', 'agent-verification', 'library-table-layout');
mkdirSync(screenshots, { recursive: true });
await page.screenshot({ path: join(screenshots, folders ? 'wide-folders.png' : 'wide.png') });
assert.deepEqual(opened.problems.filter(line => !line.includes('VIDEOJS')), []);
} finally { await opened.close() }
});
for (const viewport of VIEWPORTS) it(`${viewport.name}:普通浏览、拖动与手动多选互不干扰`, { timeout: 60000 }, async () => {
const opened = await visit(browser, '/', viewport);
const { page } = opened;
try {
await showTable(page);
const scope = page.locator('[data-catalog-table]');
const row = scope.locator('[data-catalog-row]').first();
assert.equal(await scope.getByRole('checkbox').count(), 0);
const duration = row.locator('td').filter({ hasText: /^1:00$/ });
await duration.scrollIntoViewIfNeeded();
await duration.click();
assert.equal(await page.locator('#selectMode').getAttribute('aria-pressed'), 'false');
const scroll = scope.locator('.overflow-x-auto');
const range = await scroll.evaluate(node => node.scrollWidth - node.clientWidth);
await scroll.evaluate(node => { node.scrollLeft = 0 });
const box = await scroll.boundingBox(); assert.ok(box);
const startX = box.x + box.width - 40;
const y = (await row.boundingBox())!.y + (await row.boundingBox())!.height - 8;
await page.mouse.move(startX, y); await page.mouse.down();
await page.mouse.move(startX - 100, y, { steps: 10 }); await page.mouse.up();
assert.ok(await scroll.evaluate(node => node.scrollLeft) >= Math.min(100, range) - 2);
assert.equal(await page.locator('#selectMode').getAttribute('aria-pressed'), 'false');
assert.equal(await scope.locator('[data-board-selected]').count(), 0);
// 在表格外松手不会产生容器内 click,后续勾选仍须响应第一次点击。
await scroll.evaluate(node => { node.scrollLeft = 0 });
await page.mouse.move(startX, y); await page.mouse.down();
await page.mouse.move(box.x - 20, y, { steps: 10 }); await page.mouse.up();
await page.locator('#selectMode').click();
await row.locator('label').waitFor();
await row.locator('label').click();
await page.locator('[data-batch-group="clear"]').click();
await page.waitForFunction(() => !document.body.classList.contains('select-mode'));
assert.equal(await scope.getByRole('checkbox').count(), 0);
assert.equal(await scope.locator('[data-board-selected]').count(), 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() }
});
});