import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { Browser, Locator, Page } from 'playwright-core'; import { launch, layout, settle, visit, VIEWPORTS } from './harness.ts'; import { stubServer } from './settings-fixture.ts'; const box = (target: Locator) => target.evaluate(node => { const { x, y, width, height } = node.getBoundingClientRect(); return { x, y, width, height }; }); type Box = Awaited>; /** 找节点与量矩形在页面里的同一次同步调用里做:骨架会被同一副几何的新节点换下,分两步量会量到已脱离文档的旧节点。 */ const liveBox = async (page: Page, selector: string): Promise => { const found = await page.waitForFunction(sel => { const node = document.querySelector(sel); if (!node) return null; const { x, y, width, height } = node.getBoundingClientRect(); return { x, y, width, height }; }, selector, { polling: 'raf' }); return await found.jsonValue() as Box; }; const aligned = (waiting: Box, ready: Box, keys: readonly (keyof Box)[] = ['x', 'y', 'width', 'height']) => { for (const key of keys) assert.ok(Math.abs(waiting[key] - ready[key]) <= 1, `${key}: ${waiting[key]} / ${ready[key]}`); }; /* 冷启动时壳层的过渡(`body` 的 padding-left、侧栏宽度等)还在走,整页正文跟着它横移;机器一忙, 这段过渡会拖得更长。量骨架前先等全部 CSS 过渡落定:这里比的是骨架与接管后的落位,不是量的那一刻 过渡走到了哪儿。骨架的循环闪光是 CSS 动画,不在等待之列。 */ const transitionsSettled = (page: Page) => page.evaluate(() => Promise.all(document.getAnimations() .filter((motion) => motion instanceof CSSTransition) .map((motion) => motion.finished.catch(() => undefined)))); describe('管理页面容器与骨架', () => { let browser: Browser; before(async () => { browser = await launch(); }); after(async () => { await browser?.close(); }); for (const reducedMotion of ['no-preference', 'reduce'] as const) { it(`管理菜单拖动显示插入位置并保存顺序(${reducedMotion})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/data-cleanup', VIEWPORTS.find(viewport => !viewport.mobile)!); const { page } = opened; try { const server = await stubServer(page); await page.emulateMedia({ reducedMotion }); await page.reload({ waitUntil: 'load' }); await settle(page); const menu = page.locator('[data-manage-menu]'); await menu.locator('[data-manage="configuration"]').waitFor({ state: 'visible' }); const keys = () => menu.locator('button[data-manage]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-manage'))); const original = await keys(); const picked = menu.locator('[data-manage="activity"]'); const neighbor = menu.locator('[data-manage="taste"]'); const start = (await picked.boundingBox())!; const target = (await neighbor.boundingBox())!; await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down(); await page.mouse.move(target.x + 2, target.y + target.height / 2, { steps: 10 }); assert.equal(await picked.getAttribute('data-dragging'), ''); const line = menu.locator('[data-manage-drop-line]'); assert.ok(await line.isVisible(), '插入线应显示在目标位置'); assert.notEqual(await line.locator('span').evaluate(node => getComputedStyle(node).backgroundColor), 'rgba(0, 0, 0, 0)'); assert.equal(await neighbor.evaluate(node => node.style.getPropertyValue('--sidebar-nudge')), '10px'); assert.equal(await menu.locator('[data-manage="cleanup"]').evaluate(node => node.style.getPropertyValue('--sidebar-delay')), '24ms'); assert.equal(await menu.locator('[data-pill-thumb]').evaluate(node => getComputedStyle(node).opacity), '0'); if (reducedMotion === 'no-preference') { await page.waitForFunction(() => parseFloat(getComputedStyle(document.querySelector('[data-manage="taste"]')!).translate) > 0); await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-manage="activity"]')!).scale === '0.9'); assert.equal(await picked.evaluate(node => { const rect = node.getBoundingClientRect(); return document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)?.closest('[data-dragging]') === node; }), true, '拾起项移出菜单行后仍应完整可见'); } else { assert.equal(await neighbor.evaluate(node => getComputedStyle(node).translate), 'none'); assert.equal(await picked.evaluate(node => getComputedStyle(node).position), 'static'); } const saved = page.waitForResponse(response => response.url().endsWith('/api/settings') && response.request().method() === 'POST'); await page.mouse.up(); const expected = original.filter(key => key !== 'activity'); expected.splice(1, 0, 'activity'); await saved; assert.deepEqual(await keys(), expected); assert.deepEqual(server.posts.find(post => 'manageOrder' in post.body)?.body.manageOrder, expected); assert.equal(await menu.locator('[data-dragging],[data-manage-drop-line]').count(), 0); assert.equal(await menu.evaluate(node => getComputedStyle(node).overflowY), 'hidden'); assert.equal(await page.locator('[data-manage-bar] .ov-y:not([hidden])').count(), 0, '放下后菜单右侧应清除纵向滚动条'); assert.equal(new URL(page.url()).pathname, '/data-cleanup'); await page.reload({ waitUntil: 'load' }); await settle(page); await menu.locator('[data-manage="configuration"]').waitFor({ state: 'visible' }); assert.deepEqual(await keys(), expected); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } for (const viewport of VIEWPORTS) { it(`扫描和链接操作位于分隔底栏(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/data-cleanup', viewport); try { await settle(opened.page); for (const name of ['扫描与采集', '链接管理']) { if (name === '链接管理') await opened.page.goto(new URL('/data-cleanup/links', opened.page.url()).href); const card = opened.page.locator('section').filter({ has: opened.page.getByRole('heading', { name, exact: true }) }).last(); const geometry = await card.evaluate(node => { const children = Array.from(node.children); const body = children[0]!.getBoundingClientRect(); const footer = children.at(-1)!; const rect = footer.getBoundingClientRect(); return { bodyBottom: body.bottom, footerTop: rect.top, width: rect.width, bodyWidth: body.width, border: getComputedStyle(footer).borderTopWidth }; }); assert.ok(Math.abs(geometry.bodyBottom - geometry.footerTop) <= 1); assert.equal(geometry.width, geometry.bodyWidth); assert.equal(geometry.border, '1px'); } } finally { await opened.close(); } }); it(`统计读数和库存图在接管时对齐(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/stats', viewport); let release = () => {}; try { const page = opened.page; await page.getByRole('tablist', { name: '统计视图' }).waitFor(); await page.emulateMedia({ reducedMotion: 'no-preference' }); const cpu = await page.context().newCDPSession(page); await cpu.send('Emulation.setCPUThrottlingRate', { rate: 4 }); const pending = new Promise(resolve => { release = resolve; }); await page.route('**/api/stats*', async route => { const response = await route.fetch(); await pending; await route.fulfill({ response }); }); await page.addInitScript(() => { const frames: { inventory: boolean; ranking: boolean; metricsY: number; dimensionsY: number }[] = []; (window as unknown as { statsPaintFrames: typeof frames }).statsPaintFrames = frames; const sample = () => { const metrics = document.querySelector('[role="tablist"][aria-label="统计视图"]'); const dimensions = document.querySelector('[role="tablist"][aria-label="统计维度"]'); if (metrics && dimensions) frames.push({ inventory: !!document.querySelector('[role="img"][aria-label="网盘与本地"]'), ranking: !!document.querySelector('[role="tabpanel"][id$="tags"]'), metricsY: metrics.getBoundingClientRect().y, dimensionsY: dimensions.getBoundingClientRect().y, }); if (frames.length < 12) requestAnimationFrame(sample); }; requestAnimationFrame(sample); }); await page.reload({ waitUntil: 'load' }); const skeleton = page.locator('[data-skeleton="board/stats"]'); await skeleton.waitFor(); await transitionsSettled(page); const metrics = await box(skeleton.locator('[data-stats-metrics]')); const chart = await box(skeleton.locator('[data-stats-chart]').first()); const waitingLayout = await layout(page); release(); await page.getByRole('tablist', { name: '统计视图' }).waitFor(); await settle(page); aligned(metrics, await box(page.getByRole('tablist', { name: '统计视图' }))); aligned(chart, await box(page.getByRole('img', { name: '网盘与本地', exact: true }).locator('..')), ['x', 'y', 'width']); await page.waitForFunction(() => (window as unknown as { statsPaintFrames: unknown[] }).statsPaintFrames.length >= 12); const frames = await page.evaluate(() => (window as unknown as { statsPaintFrames: { inventory: boolean; ranking: boolean; metricsY: number; dimensionsY: number }[]; }).statsPaintFrames); assert.ok(frames.every(frame => frame.inventory && frame.ranking), '统计首个可见帧缺少选中页签内容'); for (const frame of frames) { assert.ok(Math.abs(frame.metricsY - frames[0]!.metricsY) <= 1, '读数卡加载完成后发生位移'); assert.ok(Math.abs(frame.dimensionsY - frames[0]!.dimensionsY) <= 1, '统计维度加载完成后发生位移'); } assert.ok(waitingLayout.scrollWidth <= waitingLayout.viewportWidth); assert.deepEqual(opened.problems, []); } finally { release(); await opened.close(); } }); it(`重复文件汇总与文件行使用正文尺寸(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/duplicates', viewport); let release = () => {}; try { const page = opened.page; const pending = new Promise(resolve => { release = resolve; }); await page.route('**/api/duplicates?**', async route => { await pending; await route.fulfill({ json: { total: 1, files: 2, reclaimable: 1000, groups: [{ code: 'DEMO-001', count: 2, identical: true, drives: ['R:'], cross_drive: false, reclaimable: 1000, files: [1, 2].map(id => ({ id, name: `DEMO-${id}.mp4`, path: `R:\\Media\\DEMO-${id}.mp4`, location: 'local', drive: 'R:', size: 1000, duration: 3600, is_largest: id === 1, is_longest: id === 2 })), }] } }); }); await page.reload({ waitUntil: 'load' }); const skeleton = page.locator('[data-skeleton="board/duplicates"]'); await skeleton.waitFor(); await transitionsSettled(page); const summary = await box(skeleton.locator('[data-collection-summary]')); const row = await box(skeleton.locator('.duplicate-row').first()); release(); const body = page.locator('#stats section[aria-label="DEMO-001"]'); await body.waitFor(); await settle(page); aligned(summary, await box(page.locator('#stats [data-collection-summary]'))); aligned(row, await box(body.locator('.duplicate-row').first())); const dimensions = await layout(page); assert.ok(dimensions.scrollWidth <= dimensions.viewportWidth); } finally { release(); await opened.close(); } }); it(`高清版骨架与落地同一副几何,摘要在两种主题下都和页面底色分得开(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/quality-goals', viewport); let release = () => {}; try { const page = opened.page; const pending = new Promise(resolve => { release = resolve; }); await page.route('**/api/quality-goals**', async route => { await pending; await route.fulfill({ json: { total: 3, offset: 0, has_more: false, items: [1, 2, 3].map(id => ({ id, name: `DEMO-${id}.mp4`, code: `DEMO-${id}`, location: 'local', size: 1e9, duration: 3600, reason: '', cost: '', has_thumb: false, has_cover: false })) } }); }); await page.reload({ waitUntil: 'load' }); const skeleton = page.locator('[data-skeleton="board/quality-goals"]'); await skeleton.waitFor(); await transitionsSettled(page); const summary = await box(skeleton.locator('[data-collection-summary]')); const card = await box(skeleton.locator('.card-grid-cover > li').first()); release(); const first = page.locator('#stats li[data-goal-id]').first(); await first.waitFor(); await settle(page); aligned(summary, await box(page.locator('#stats [data-collection-summary]'))); aligned(card, await box(first)); // 浅色下 primary 就是页面底色:摘要取 primary 的话整块化进页面,看不出那是一块面。 const faces = await page.evaluate(() => { const root = document.documentElement; const before = { theme: root.dataset.theme, dark: root.classList.contains('dark') }; const read = (theme: string) => { root.dataset.theme = theme; root.classList.toggle('dark', theme === 'dark'); return [getComputedStyle(document.querySelector('#stats [data-collection-summary]')!).backgroundColor, getComputedStyle(document.body).backgroundColor]; }; const result = { light: read('light'), dark: read('dark') }; if (before.theme === undefined) delete root.dataset.theme; else root.dataset.theme = before.theme; root.classList.toggle('dark', before.dark); return result; }); for (const [theme, [face, ground]] of Object.entries(faces)) { assert.notEqual(face, ground, `${theme} 主题下高清版摘要和页面同一个底色:${face}`); } } finally { release(); await opened.close(); } }); it(`垃圾卡骨架与落地同一副几何(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/junk-files', viewport); let release = () => {}; try { const page = opened.page; const pending = new Promise(resolve => { release = resolve; }); await page.route('**/api/ads?**', async route => { await pending; await route.fulfill({ json: { total: 2, all_total: 2, dismissed_total: 0, counts: { video: 2 }, items: [1, 2].map(id => ({ id, name: `推广-${id}.mp4`, junk_kind: 'video', why: '文件名像推广', size: 1048576, location: 'local', cost: '' })) } }); }); await page.reload({ waitUntil: 'load' }); await page.locator('#grid .catalog-skeleton .skeletoncard').first().waitFor(); await transitionsSettled(page); const card = await liveBox(page, '#grid .catalog-skeleton .skeletoncard'); release(); await page.locator('#grid [data-junk-card]').first().waitFor(); await settle(page); aligned(card, await liveBox(page, '#grid [data-junk-card]')); } finally { release(); await opened.close(); } }); it(`回收站网格与标题、汇总栏同宽(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/trash', viewport); try { await opened.page.locator('[data-manage-lede] [data-lede-text]').waitFor(); await settle(opened.page); const title = await box(opened.page.locator('[data-manage-title]')); const grid = await box(opened.page.locator('#grid')); const lede = await box(opened.page.locator('[data-manage-lede]')); aligned(title, grid, ['x', 'width']); aligned(title, lede, ['x', 'width']); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } }); describe('管理页在极端内容下不撑出视口', () => { const narrow = { name: 'narrow', width: 320, height: 720, mobile: true }; const LONG_PATH = `R:/media/${'很长的没有空格的目录名'.repeat(12)}/FC2-PPV-4728193.mp4`; let browser: Browser; before(async () => { browser = await launch(); }); after(async () => { await browser?.close(); }); it('复核页骨架在窄屏上不越过视口,数据到了以后侧栏仍收着', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/review', narrow); let release = () => {}; try { const page = opened.page; const pending = new Promise(resolve => { release = resolve; }); await page.route('**/api/review**', async route => { await pending; await route.continue(); }); await page.evaluate(() => sessionStorage.removeItem('board.sidebar')); await page.reload({ waitUntil: 'load' }); await page.locator('[data-skeleton="review"]').waitFor(); const skeleton = await page.evaluate(() => ({ workspace: document.querySelector('[data-skeleton="review"]')!.getBoundingClientRect().width, page: document.documentElement.scrollWidth, })); assert.ok(skeleton.workspace <= narrow.width, `骨架宽 ${skeleton.workspace}`); assert.ok(skeleton.page <= narrow.width, `页面宽 ${skeleton.page}`); release(); await page.locator('[data-skeleton="review"]').waitFor({ state: 'detached' }); await settle(page); assert.equal(await page.evaluate(() => document.body.classList.contains('drawer-open')), false); } finally { release(); await opened.close(); } }); it('高清版的判定原因是一长串路径时在卡内折行,不把页面撑宽', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/quality-goals', narrow); try { const page = opened.page; await page.route('**/api/quality-goals**', route => route.fulfill({ json: { total: 1234567, offset: 0, has_more: true, items: [{ id: 1, name: 'FC2-PPV-4728193.mp4', code: 'FC2-PPV-4728193', location: 'local', size: 1e9, duration: 3600, reason: LONG_PATH, cost: '', has_thumb: false, has_cover: false }] } })); await page.reload({ waitUntil: 'load' }); const card = page.locator('#stats li[data-goal-id]').first(); await card.waitFor(); await settle(page); const right = (await box(card)).x + (await box(card)).width; assert.ok(right <= narrow.width, `卡片右缘 ${right}`); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth) <= narrow.width); assert.equal(await page.locator('#stats [data-collection-summary] strong').textContent(), '1,234,567 部作品'); assert.match(await page.locator('#stats').textContent() ?? '', /显示前 1 部,共 1,234,567 部。/); } finally { await opened.close(); } }); });