/* 设计决定:数据管理、索引页与资料页头。读 `getComputedStyle` 断言用户定过的外观;共用的桩与浏览器生命周期在 `design-fixture.ts`。 */ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Browser, Page } from 'playwright-core'; import { layout, settle, visit } from './harness.ts'; import { DESKTOP, MOBILE, tokenColor, openDuplicates, openIndexPage, openPlaylistsPage, openFollowManage, holdApi, openPerformer, PROFILED, openProfiledPerformer, popmenuShadow, heroGeometry, disabledTokens, waitingActionFaces, assertDisabledFace, followTab, followSource, installDesignBrowser, } from './design-fixture.ts'; describe('设计决定:数据管理、索引页与资料页头', () => { let browser: Browser; installDesignBrowser((next) => { browser = next; }); it('关注列表操作区按整组换行,并在窄屏内继续排布控件', { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser, DESKTOP, { counts: { new: 17_097 }, extra: [ followSource(4, 'kou', 'Fanbox', 'kou · Fanbox'), followSource(5, 'kou', 'Patreon', 'kou · Patreon'), followSource(6, 'kou', 'Rule34Video', 'kou · Rule34Video'), ], }); try { const { page } = opened; for (const width of [1698, 1000, 820, 760, 650, 390]) { await page.setViewportSize({ width, height: 900 }); await settle(page); const geometry = await page.locator('[data-follow-toolbar]').evaluate((root) => { const box = (element: Element) => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; }; const actions = root.querySelector('[data-follow-toolbar-actions]')!; return { toolbar: box(root), heading: box(root.querySelector('[data-follow-toolbar-heading]')!), actions: box(actions), buttons: [...actions.querySelectorAll('button')].map(box), }; }); const { toolbar, heading, actions, buttons } = geometry; assert.ok(actions.left >= toolbar.left - 1 && actions.right <= toolbar.right + 1, `${width}px 操作区越出工具栏`); for (const button of buttons) { assert.ok(button.left >= actions.left - 1 && button.right <= actions.right + 1, `${width}px 控件越出操作区`); } const below = actions.top >= heading.bottom - 1; if (below) { assert.ok(Math.abs(actions.left - toolbar.left) < 1, `${width}px 换行操作区未左对齐`); } else { assert.ok(actions.left >= heading.right, `${width}px 操作区与标题重叠`); assert.ok(Math.max(...buttons.map((b) => b.top)) - Math.min(...buttons.map((b) => b.top)) < 2, `${width}px 与标题同行时只有部分控件换行`); } if (width === 1698) assert.equal(below, false); if (width <= 650) assert.equal(below, true); const authorButtons = await page.locator('[data-follow-author-controls]').evaluateAll(groups => groups.map(group => [...group.querySelectorAll(':scope > span:last-child > button')].map(button => button.getBoundingClientRect().top))); assert.ok(authorButtons.every(tops => tops.length === 2 && Math.abs(tops[0]! - tops[1]!) < 1), `${width}px 创作者卡的全选与收起按钮被拆行`); } assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); const cleanupCards = [ { path: '/data-cleanup', label: '扫描与采集', skeleton: 'cleanup', names: ['同步新增文件', '更多同步方式'] }, { path: '/data-cleanup/media-repair', label: '媒体修复', skeleton: 'cleanup/media-repair', names: ['开始修复'] }, ]; for (const viewport of [DESKTOP, MOBILE]) for (const item of cleanupCards) { it(`${item.label}骨架与内容卡共用外框、内边距和操作区布局(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await visit(browser, item.path, viewport); try { const page = opened.page; await page.route('**/api/library-processing', route => route.fulfill({ json: { status: 'idle' } })); const release = await holdApi(page); await page.reload({ waitUntil: 'load' }); const card = `section[aria-label="${item.label}"]`; await page.locator(`[data-skeleton="${item.skeleton}"] ${card} footer`).waitFor({ timeout: 15_000 }); const geometry = () => page.locator(card).evaluateAll(cards => cards.map(card => { const style = (part: Element) => { const css = getComputedStyle(part); return [css.display, css.padding, css.gap, css.borderTopWidth, css.borderRadius, css.backgroundColor]; }; return { width: Math.round(card.getBoundingClientRect().width), height: Math.round(card.getBoundingClientRect().height), face: style(card), body: style(card.firstElementChild!), footer: style(card.querySelector('footer')!) }; })); const waiting = await geometry(); assert.equal(waiting.length, 1); assert.equal(waiting[0]!.face[3], '0px', '骨架卡还带着外框,页面上的卡只靠填充底'); const expected = await disabledTokens(page); const faces = await waitingActionFaces(page, `[data-skeleton="${item.skeleton}"] [data-skeleton-action]`); assert.deepEqual(faces.map(face => face.name).filter(Boolean), item.names); for (const face of faces) assertDisabledFace(face, expected); const buttons = page.locator(`${card} footer button`).filter({ hasText: /同步新增文件|开始修复/ }).or( page.locator(`${card} footer button[aria-label="更多同步方式"]`)); const sizes = await buttons.evaluateAll(nodes => nodes.map(node => { const box = node.getBoundingClientRect(); return [box.width, box.height]; })); if (item.skeleton === 'cleanup') { const ring = await page.locator(`${card} [data-split-button]`).evaluate(node => getComputedStyle(node).boxShadow); assert.ok(ring.includes(expected.ring), `骨架分体键外圈不是禁用描边:${ring}`); } release(); await page.locator(`${card} footer button:not([data-skeleton-action])`).first().waitFor({ timeout: 15_000 }); await settle(page); assert.deepEqual(await geometry(), waiting, '骨架与内容卡的容器布局不一致'); assert.deepEqual(await buttons.evaluateAll(nodes => nodes.map(node => { const box = node.getBoundingClientRect(); return [box.width, box.height]; })), sizes, '操作按钮接管时尺寸跳变'); assert.deepEqual(await buttons.evaluateAll(nodes => nodes.map(node => (node as HTMLButtonElement).disabled)), item.names.map(() => false)); for (const face of await buttons.evaluateAll(nodes => nodes.map(node => getComputedStyle(node).backgroundImage))) { assert.match(face, /gradient/, '数据到了操作按钮仍未恢复主操作样式'); } assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); } const cleanupTools = [ ['/data-cleanup/organize', 'organize'], ['/data-cleanup/resource-sync', 'resource-sync'], ['/data-cleanup/orphan-records', 'orphan-records'], ['/data-cleanup/links', 'links'], ['/data-cleanup/batch-delete', 'batch-delete'], ] as const; for (const viewport of [DESKTOP, MOBILE]) { it(`维护工具二级页的骨架画在正文那一列里,首张卡与接管后同位同宽(${viewport.name})`, { timeout: 90_000 }, async () => { const opened = await visit(browser, cleanupTools[0][0], viewport); try { const page = opened.page; const release = await holdApi(page); const frame = (column: string) => page.locator(column).evaluate((node) => { const box = (part: Element) => { const { left, width } = part.getBoundingClientRect(); return [Math.round(left), Math.round(width)]; }; return { column: box(node), card: box(node.firstElementChild!) }; }); const waiting: Record = {}; for (const [path, key] of cleanupTools) { await page.goto(new URL(path, page.url()).href, { waitUntil: 'load' }); await page.locator(`[data-skeleton="cleanup/${key}"] [inert] > *`).first().waitFor({ timeout: 15_000 }); waiting[path] = await frame(`[data-skeleton="cleanup/${key}"] [inert]`); } release(); // 接管那一拍旧骨架挪进淡出层 `.skelfade`,不再挂在 `[data-skeleton]` 下,两层都要排除。 const content = '#stats .max-w-board:not([data-skeleton] *, .skelfade *)'; for (const [path] of cleanupTools) { await page.goto(new URL(path, page.url()).href, { waitUntil: 'load' }); await page.locator(`${content} > *`).first().waitFor({ timeout: 15_000 }); await page.locator('#stats .skelfade').waitFor({ state: 'detached', timeout: 15_000 }); await settle(page); assert.deepEqual(await frame(content), waiting[path], `${path} 骨架与内容的正文列或首张卡位置不一致`); } assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); } it('工具页标题采用 medium,链接读数使用既定字号与行高', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/data-cleanup/links', DESKTOP); try { const page = opened.page; for (const [path, id, name] of [ ['/data-cleanup/links', 'link-manager', '链接管理'], ['/data-cleanup/resource-sync', 'resource-sync', '资源同步'], ]) { await page.goto(new URL(path!, page.url()).href); await page.locator(`#${id}-title`).waitFor({ timeout: 15_000 }); await settle(page); const heading = await page.locator(`#${id}-title`).evaluate(node => { const css = getComputedStyle(node); return [css.fontSize, css.fontWeight, css.lineHeight, css.marginBottom]; }); assert.deepEqual(heading, ['20px', '500', '26px', '8px'], `${name}标题样式`); assert.equal(await page.locator(`#${id} > h2, #${id} > h3`).count(), 0, '出现了框外标题'); if (id === 'link-manager') { const styles = await page.locator('#link-manager .link-stat-grid').evaluate(node => { const label = getComputedStyle(node.querySelector('span')!); const figure = getComputedStyle(node.querySelector('b')!); return [label.fontSize, label.lineHeight, figure.fontSize, figure.fontWeight, figure.lineHeight]; }); assert.deepEqual(styles, ['12px', '20px', '20px', '700', '20px']); } } assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); it('可点的卡悬停时铺卡面掺 5% 主文字色的那一档面,不是透明', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/data-cleanup', DESKTOP); try { const page = opened.page; const selector = '.peach-react [data-cleanup-go]'; const card = page.locator(selector).first(); await card.waitFor({ timeout: 15_000 }); await settle(page); const rest = await card.evaluate((element) => getComputedStyle(element).backgroundColor); await card.hover(); // 过渡跑完才读:等到底色变了,或三秒后照实读出没变的那个值。 await page.waitForFunction(([target, before]) => getComputedStyle(document.querySelector(target)!).backgroundColor !== before, [selector, rest] as const, { timeout: 3_000 }).catch(() => undefined); const hovered = await card.evaluate((element) => getComputedStyle(element).backgroundColor); assert.notEqual(hovered, 'rgba(0, 0, 0, 0)', '悬停底色落成了透明:token 在根上就折掉了'); assert.notEqual(hovered, rest, '悬停没有换面'); assert.equal(hovered, await tokenColor(page, '.peach-react', '--card-hover'), '悬停底色不是卡面掺 5% 主文字色'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('索引页名册格用遗留卡面那一套:格底 --ground、头像框 --sunk、首字与读数 --muted 配 Bahnschrift,悬停掺 6% 主文字色;页头过滤框与版式切换同为 36px 高,切换两枚共 66px 宽', { timeout: 60_000 }, async () => { const opened = await openIndexPage(browser, '/performers'); try { const page = opened.page; const selector = '#index [data-index-cell]'; const cell = page.locator(selector).first(); const rest = await cell.evaluate((element) => getComputedStyle(element).backgroundColor); const face = await page.evaluate(() => { const ring = document.querySelector('#index [data-person-ring]')!; const initial = document.querySelector('#index [data-person-ring] .ini')!; const readout = document.querySelector('#index [data-index-readout]')!; return { ring: getComputedStyle(ring).backgroundColor, initial: getComputedStyle(initial).color, font: getComputedStyle(initial).fontFamily, readout: getComputedStyle(readout).color }; }); assert.equal(rest, await tokenColor(page, 'html', '--ground'), '名册格底不是 --ground'); assert.equal(face.ring, await tokenColor(page, 'html', '--sunk'), '头像框底不是 --sunk'); const muted = await page.evaluate(() => { const probe = document.createElement('div'); probe.style.color = 'var(--muted)'; document.documentElement.append(probe); const value = getComputedStyle(probe).color; probe.remove(); return value; }); assert.deepEqual([face.initial, face.readout], [muted, muted], '首字与读数不是 --muted'); assert.match(face.font, /^Bahnschrift/, '首字不是 Bahnschrift'); await cell.hover(); await page.waitForFunction(([target, before]) => getComputedStyle(document.querySelector(target)!).backgroundColor !== before, [selector, rest] as const, { timeout: 3_000 }).catch(() => undefined); const hovered = await cell.evaluate((element) => getComputedStyle(element).backgroundColor); const lifted = await page.evaluate(() => { const probe = document.createElement('div'); probe.style.backgroundColor = 'color-mix(in srgb, var(--ink) 6%, var(--ground))'; document.documentElement.append(probe); const value = getComputedStyle(probe).backgroundColor; probe.remove(); return value; }); assert.equal(hovered, lifted, '名册格悬停不是格底掺 6% 主文字色'); const head = await page.evaluate(() => { const input = document.querySelector('#index [data-index-search] input')!; const shell = input.closest('[role="presentation"]')!; const toggle = document.querySelector('#index [data-index-layout]')!; return { search: getComputedStyle(shell).height, toggle: getComputedStyle(toggle).height, width: getComputedStyle(toggle).width }; }); assert.deepEqual(head, { search: '36px', toggle: '36px', width: '66px' }); // 人脸放大把 img 撑得比框还宽再负偏移;Preflight 的 `max-width:100%` 会把它压回框宽。 const photo = page.locator('#index [data-person-ring] img').first(); await photo.waitFor({ state: 'attached', timeout: 5_000 }); await page.waitForFunction(() => document.querySelector('#index [data-person-ring]')?.hasAttribute('data-native-small'), undefined, { timeout: 5_000 }); assert.equal(await photo.evaluate((element) => getComputedStyle(element).maxWidth), 'none'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('索引页分区使用中性填充选中态,方向键能切换', { timeout: 60_000 }, async () => { const opened = await openIndexPage(browser, '/studios'); try { const page = opened.page; const selected = page.locator('#index [role="tab"][aria-selected="true"]'); const look = await selected.evaluate((element) => { const style = getComputedStyle(element); const thumb = element.closest('[data-pill-nav]')!.querySelector('[data-pill-thumb]')!; return { background: style.backgroundColor, selectedSurface: getComputedStyle(thumb).backgroundColor, border: style.borderBottomWidth, weight: style.fontWeight }; }); assert.equal(look.background, 'rgba(0, 0, 0, 0)'); assert.notEqual(look.selectedSurface, 'rgba(0, 0, 0, 0)'); assert.equal(look.border, '0px'); assert.equal(look.weight, '400'); await selected.focus(); await page.keyboard.press('ArrowRight'); await page.locator('#index [role="tab"][aria-selected="true"][data-tab="series"]').waitFor({ timeout: 5_000 }); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('播放列表卡:封面后压两层纸边、黑底封面、玻璃徽标、38px 头像有图叠 22px、只有首字母叠 8px;标题 32/44,菜单键静止透明', { timeout: 60_000 }, async () => { const opened = await openPlaylistsPage(browser); try { const page = opened.page; const look = await page.evaluate(() => { const card = document.querySelector('#stats [data-playlist-card="1"]')!; const css = (selector: string) => getComputedStyle(card.querySelector(selector)!); const stack = card.querySelector('[data-mix-stack]')!; const back = getComputedStyle(stack, '::before'); const mid = getComputedStyle(stack, '::after'); const title = getComputedStyle(document.querySelector('#stats header h2')!); const cover = css('[data-mix-cover]'); const badge = css('[data-mix-badge]'); const menu = css('[data-playlist-menu]'); const blank = getComputedStyle(document.querySelector('#stats [data-playlist-card="3"] [data-mix-cover] > span')!); return { title: [title.fontSize, title.lineHeight, title.fontWeight], gap: getComputedStyle(document.querySelector('#stats [data-playlist-grid]')!).gap, back: [back.borderTopWidth, back.inset, back.transform, back.opacity], mid: [mid.borderTopWidth, mid.inset, mid.transform, mid.opacity], cover: [cover.backgroundColor, cover.clipPath], badge: [badge.backgroundColor, badge.backdropFilter, badge.minHeight, badge.borderRadius, badge.right, badge.bottom], avatars: [...card.querySelectorAll('[data-mix-avatars] button')].map((button) => { const style = getComputedStyle(button); return [style.width, style.marginLeft, style.zIndex]; }), menu: [menu.width, menu.height, menu.borderRadius, menu.backgroundColor], blank: [blank.letterSpacing, blank.textTransform], }; }); assert.deepEqual(look, { title: ['32px', '44px', '500'], gap: '18px', back: ['1px', '0px 12px 8px', 'matrix(1, 0, 0, 1, 0, -7)', '0.54'], mid: ['1px', '0px 6px 4px', 'matrix(1, 0, 0, 1, 0, -4)', '0.78'], cover: ['rgb(0, 0, 0)', 'inset(0px round 14px)'], badge: ['rgba(12, 8, 8, 0.72)', 'blur(10px)', '28px', '10px', '9px', '9px'], avatars: [['38px', '0px', '5'], ['38px', '-8px', '4'], ['38px', '-8px', '3']], menu: ['30px', '30px', '10px', 'rgba(0, 0, 0, 0)'], blank: ['1.44px', 'uppercase'], }); // 指到的头像抬起 4px、放大到 1.05,右边那位朝标题让开 10px。 await page.locator('#stats [data-playlist-card="1"] [data-mix-avatars] button').first().hover(); const lifted = await page.evaluate(() => { document.getAnimations().forEach((animation) => animation.finish()); return [...document.querySelectorAll('#stats [data-playlist-card="1"] [data-mix-avatars] button')] .slice(0, 2).map((button) => getComputedStyle(button).transform); }); assert.deepEqual(lifted, ['matrix(1.05, 0, 0, 1.05, 0, -4)', 'matrix(1, 0, 0, 1, 10, -1.8)']); // 点点点菜单外宽 172px,同旧卡片菜单。 await page.locator('#stats [data-playlist-card="1"] [data-playlist-menu]').click(); const panel = page.locator(':has(> [role="dialog"][aria-label="播放列表操作:列表1"])'); await panel.waitFor({ timeout: 5_000 }); assert.equal(await panel.evaluate((element) => getComputedStyle(element).width), '172px'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('播放列表页窄屏:页头改竖排,新建框字号 16px 免得 iOS 聚焦时放大', { timeout: 60_000 }, async () => { const opened = await openPlaylistsPage(browser, MOBILE); try { const narrow = await opened.page.evaluate(() => ({ header: getComputedStyle(document.querySelector('#stats header')!).flexDirection, input: getComputedStyle(document.querySelector('#stats [data-playlist-create] input')!).fontSize, title: getComputedStyle(document.querySelector('#stats header h2')!).fontSize, })); assert.deepEqual(narrow, { header: 'column', input: '16px', title: '32px' }); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('分段底板支持鼠标反向、键盘即时切换与减少动态效果', { timeout: 60_000 }, async () => { const opened = await openIndexPage(browser, '/performers'); const { page } = opened; try { await page.emulateMedia({ reducedMotion: 'no-preference' }); const track = page.locator('[data-index-layout]'); const choices = track.locator('label'); const pane = track.locator('[data-moving-surface]'); await pane.waitFor(); const aligned = async () => { await page.waitForFunction(() => { const host = document.querySelector('[data-index-layout]')!; const pane = host.querySelector('[data-moving-surface]')!.getBoundingClientRect(); const selected = host.querySelector('label[data-selected]')!.getBoundingClientRect(); return Math.abs(pane.x - selected.x) < 1 && Math.abs(pane.width - selected.width) < 1; }); }; const assertInstant = async () => { const offset = await pane.evaluate((node) => { const selected = node.parentElement!.querySelector('label[data-selected]')!.getBoundingClientRect(); const box = node.getBoundingClientRect(); return { x: box.x - selected.x, width: box.width - selected.width }; }); assert.ok(Math.abs(offset.x) < 1 && Math.abs(offset.width) < 1, `即时切换的底板位置:${JSON.stringify(offset)}`); }; await choices.nth(0).click(); await aligned(); const endpoints = await choices.evaluateAll((nodes) => nodes.slice(0, 2).map((node) => node.getBoundingClientRect().x)); await pane.evaluate((node) => { const target = node.parentElement!.querySelectorAll('label')[1]; target.addEventListener('pointerdown', () => { const samples: number[] = []; const sample = () => { samples.push(node.getBoundingClientRect().x); if (samples.length < 24) requestAnimationFrame(sample); else (node as HTMLElement).dataset.motionSamples = JSON.stringify(samples); }; requestAnimationFrame(sample); }, { once: true }); }); await choices.nth(1).click(); await page.waitForFunction(() => document.querySelector('[data-index-layout] [data-motion-samples]')); const samples = JSON.parse((await pane.getAttribute('data-motion-samples'))!) as number[]; assert.ok(samples.some((x) => x > Math.min(...endpoints) + 1 && x < Math.max(...endpoints) - 1), `鼠标切换应经过两项之间的位置:${JSON.stringify(samples)}`); await choices.nth(0).click(); await choices.nth(1).click(); await choices.nth(0).click(); await aligned(); assert.equal(await pane.count(), 1); assert.equal(await pane.getAttribute('aria-hidden'), 'true'); await track.getByRole('radio').nth(0).focus(); await page.keyboard.press('ArrowRight'); await assertInstant(); assert.equal(await track.getByRole('radio').nth(1).isChecked(), true); assert.equal(await pane.evaluate((node) => node.getAnimations().length), 0); assert.match(await choices.nth(1).evaluate((node) => getComputedStyle(node).getPropertyValue('--tw-ring-shadow')), /2px/, '选中底板不能抹掉键盘焦点环'); await page.emulateMedia({ reducedMotion: 'reduce' }); await choices.nth(0).click(); await assertInstant(); assert.equal(await pane.evaluate((node) => node.getAnimations().length), 0); await page.setViewportSize({ width: 390, height: 844 }); await aligned(); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('输入框失败态只把框线换成 danger 色:填充照静止态,原因用说明文字的灰', { timeout: 60_000 }, async () => { const opened = await openPlaylistsPage(browser); const { page } = opened; try { const form = page.locator('[data-playlist-create]'); const resting = await form.locator('[data-input-size] > div[data-rac]').evaluate((node) => getComputedStyle(node).backgroundColor); await form.getByRole('button', { name: '新建' }).click(); await form.locator('[data-input-size] > div[data-rac][data-invalid]').waitFor(); const looks = await form.locator('[data-input-size]').evaluate((root) => { const probe = (token: string) => { const swatch = document.createElement('i'); swatch.style.color = `var(${token})`; root.append(swatch); const color = getComputedStyle(swatch).color; swatch.remove(); return color; }; const group = getComputedStyle(root.querySelector(':scope > div[data-rac]')!); return { fill: group.backgroundColor, outline: group.outlineColor, width: group.outlineWidth, reason: getComputedStyle(root.querySelector(':scope > [slot="errorMessage"]')!).color, danger: probe('--color-border-error-default'), secondary: probe('--color-text-secondary'), }; }); assert.deepEqual([looks.fill, looks.outline, looks.width, looks.reason], [resting, looks.danger, '1px', looks.secondary]); } finally { await opened.close(); } }); it('短菜单共享悬停面,弹窗关闭释放焦点与遮罩', { timeout: 60_000 }, async () => { const opened = await openPlaylistsPage(browser); const { page } = opened; try { await page.emulateMedia({ reducedMotion: 'no-preference' }); const trigger = page.locator('[data-playlist-card="1"] [data-playlist-menu]'); await trigger.click(); const menu = page.getByRole('dialog', { name: '播放列表操作:列表1' }); await menu.getByRole('button', { name: '编辑名称' }).hover(); const pane = menu.locator('[data-moving-surface]'); await pane.waitFor(); await menu.getByRole('button', { name: '删除播放列表' }).hover(); await page.waitForFunction(() => { const host = document.querySelector('[data-surface-host="hover"]')!; const pane = host.querySelector('[data-moving-surface]')!.getBoundingClientRect(); const button = [...host.querySelectorAll('button')].at(-1)!.getBoundingClientRect(); return Math.abs(pane.y - button.y) < 1; }); await page.keyboard.press('Tab'); assert.equal(await pane.isVisible(), false); await menu.getByRole('button', { name: '编辑名称' }).click(); const modal = page.locator('[data-modal-motion]'); await modal.waitFor(); await modal.getByRole('textbox', { name: '名称' }).waitFor(); assert.equal(await modal.locator('[data-modal-surface]').evaluate((node) => getComputedStyle(node).transitionProperty.includes('transform')), true); await modal.locator('form').getByRole('button', { name: '取消', exact: true }).click(); await modal.waitFor({ state: 'detached' }); await trigger.click(); await menu.getByRole('button', { name: '编辑名称' }).focus(); await page.keyboard.press('Enter'); await modal.waitFor(); assert.equal(await modal.getAttribute('data-motion-instant'), 'true'); await page.waitForFunction(() => Boolean(document.activeElement?.closest('[data-modal-motion]')), null, { timeout: 5_000 }); await page.keyboard.press('Escape'); await modal.waitFor({ state: 'detached' }); assert.equal(await page.evaluate(() => document.activeElement?.closest('[data-modal-motion]') !== null), false); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('批量选择条更新计数保持节点,清空后退出且不保留可操作控件', { timeout: 60_000 }, async () => { const opened = await openIndexPage(browser, '/tags'); const { page } = opened; try { await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.locator('#selectMode').click(); const tags = page.locator('#index [data-alpha-tag]'); await tags.nth(0).click(); const dock = page.locator('[data-selection-dock]'); await dock.waitFor(); await dock.evaluate((node) => node.setAttribute('data-test-identity', 'retained')); await tags.nth(1).click(); assert.equal(await dock.getAttribute('data-test-identity'), 'retained'); assert.match(await dock.textContent() ?? '', /已选 2 个标签/); await dock.getByRole('button', { name: '清空', exact: true }).click(); await page.waitForFunction(() => { const dock = document.querySelector('[data-selection-dock]'); return !dock || (dock.inert && dock.getAttribute('aria-hidden') === 'true'); }); await dock.waitFor({ state: 'detached' }); await tags.nth(0).click(); await dock.waitFor(); await page.emulateMedia({ reducedMotion: 'reduce' }); await dock.getByRole('button', { name: '清空', exact: true }).click(); await dock.waitFor({ state: 'detached' }); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('标签多选坞出现时页底按实际高度留白;在线词表的类型色走上游 tag_type 那一套', { timeout: 60_000 }, async () => { const opened = await openIndexPage(browser, '/tags'); try { const page = opened.page; await page.locator('#selectMode').click({ timeout: 5_000 }); await page.locator('#index [data-alpha-tag]').nth(0).click({ timeout: 5_000 }); await page.locator('#index [data-alpha-tag]').nth(1).click({ timeout: 5_000 }); const dock = page.locator('[data-selection-dock]'); await dock.waitFor({ timeout: 5_000 }); const clearance = await dock.evaluate((element) => ({ padding: parseFloat(getComputedStyle(document.querySelector('#index .peach-react')!).paddingBottom), required: (element as HTMLElement).offsetHeight + parseFloat(getComputedStyle(element).bottom) + 16, })); assert.ok(clearance.padding >= clearance.required, `选择坞下方留白不足:${JSON.stringify(clearance)}`); await page.locator('#index [role="tab"][data-tab="online"]').click({ timeout: 5_000 }); const dot = page.locator('#index [data-alpha-tag][data-tag-cat="r34-artist"] [data-tag-dot]'); await dot.waitFor({ timeout: 5_000 }); assert.equal(await dot.evaluate((element) => getComputedStyle(element).backgroundColor), 'rgb(227, 108, 108)'); assert.equal(await dock.count(), 0, '在线词表不给多选坞'); assert.equal(await page.evaluate(() => document.documentElement.style.getPropertyValue('--selection-dock-space')), ''); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('标签云一枚里名字与计数只隔一个空格宽;筛选浮层的 Aa 字形摆进 16×16 的框', { timeout: 60_000 }, async () => { const opened = await openIndexPage(browser, '/tags?view=cloud'); try { const page = opened.page; const spacing = await page.locator('#index [data-tag-chip]').first().evaluate((chip) => { // 名字那段文字按字符切开量:字的右缘到计数左缘,与末尾那个空格自己画出来的宽度相比。 const name = chip.firstElementChild!.firstChild!; const end = name.textContent!.trimEnd().length; const box = (from: number, to: number) => { const part = document.createRange(); part.setStart(name, from); part.setEnd(name, to); return part.getBoundingClientRect(); }; const count = chip.querySelector('[data-tag-n]')!.getBoundingClientRect(); return { gap: count.left - box(0, end).right, space: box(end, name.textContent!.length).width, columnGap: getComputedStyle(chip).columnGap }; }); assert.equal(spacing.columnGap, 'normal', '名字与计数之间不另加 flex 间距'); assert.ok(spacing.space > 2 && Math.abs(spacing.gap - spacing.space) < 0.5, `名字与计数隔了 ${spacing.gap}px,一个空格是 ${spacing.space}px`); const glyph = page.locator('#index svg:has(use[href="#i-text-aa"])'); const letterGlyph = await glyph.evaluate((element) => { const style = getComputedStyle(element); const ink = (element as SVGSVGElement).getBBox(); return { width: parseFloat(style.width), height: parseFloat(style.height), inkHeight: ink.height }; }); assert.equal(letterGlyph.height, 16); assert.ok(letterGlyph.width / letterGlyph.height > 1.4, '字母表字形按高度保留横向比例'); assert.ok(letterGlyph.inkHeight / 24 > 0.8, '字母表字形的实际墨迹占满同级图标活区'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('重复文件的汇总条是玻璃面,组卡 14px 圆角、组头 20px 内边距,每行上方一条 1px 分隔线', { timeout: 60_000 }, async () => { const opened = await openDuplicates(browser); try { const faces = await opened.page.evaluate(() => { const glass = getComputedStyle(document.querySelector('[data-filter-glass]')!); const group = document.querySelector('#stats section[aria-label="DUPE-002"]')!; const head = group.firstElementChild!; const card = getComputedStyle(group); const header = getComputedStyle(head); const code = getComputedStyle(head.querySelector('b')!); return { glass: { filter: glass.backdropFilter, padding: glass.padding, height: glass.height, compact: parseFloat(glass.getPropertyValue('--control-h-compact')) }, card: { radius: card.borderRadius, overflow: card.overflow, face: card.backgroundColor }, head: { padding: header.padding, face: header.backgroundColor, size: code.fontSize, weight: code.fontWeight }, rows: [...group.querySelectorAll('.duplicate-row')].map((row) => { const computed = getComputedStyle(row); return [computed.borderTopWidth, computed.padding]; }), }; }); assert.match(faces.glass.filter, /blur\(/, '汇总条没有背景模糊,不是玻璃面'); // 汇总条里的控件取紧凑档,条高是上下各 10px 内边距加一档紧凑控件。 assert.deepEqual([faces.glass.padding, faces.glass.height], ['10px 16px', `${20 + faces.glass.compact}px`]); assert.deepEqual([faces.card.radius, faces.card.overflow], ['14px', 'hidden'], '组卡圆角或裁切不对'); assert.notEqual(faces.head.face, faces.card.face, '组头和组卡同色,番号那一行分不出来'); assert.deepEqual([faces.head.padding, faces.head.size, faces.head.weight], ['20px', '20px', '500']); assert.deepEqual(faces.rows, [['1px', '16px 20px'], ['1px', '16px 20px']], '组内每一行上方不是 1px 分隔线,或内边距不是 16/20'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('统计、高清版和配置空清单有背景与边框,窄屏不越界', { timeout: 60_000 }, async () => { const cases = [ { path: '/stats', title: '还没有视频', height: 320 }, { path: '/quality-goals', title: '没有标记中的高清版目标', height: 320 }, { path: '/settings/diagnostics', title: '清单为空', height: 240 }, { path: '/settings/subscriptions', title: '没有可配置的来源', height: 240 }, ]; for (const viewport of [DESKTOP, MOBILE]) { for (const example of cases) { const opened = await visit(browser, example.path, viewport, async page => { await page.route('**/api/stats', async route => { const report = await (await route.fetch()).json(); await route.fulfill({ json: { ...report, by_loc: [] } }); }); await page.route('**/api/quality-goals?**', route => route.fulfill({ json: { total: 0, items: [] } })); await page.route('**/api/follow/credentials', route => route.fulfill({ json: { providers: [] } })); await page.route('**/api/diagnostics', async route => { const report = await (await route.fetch()).json(); report.library.groups = { empty: { label: '空清单样例', status: 'ok', count: 0, items: [], truncated: false } }; await route.fulfill({ json: report }); }); }); try { const { page } = opened; if (example.path === '/settings/diagnostics') { await page.locator('summary').filter({ hasText: '空清单样例 · 0 项' }).click(); } const empty = page.locator('[data-empty-state]').filter({ has: page.getByRole('heading', { name: example.title, exact: true }), }); await empty.waitFor(); await settle(page); const face = await empty.evaluate(node => { const style = getComputedStyle(node); const glyph = node.querySelector('svg')!; const frame = glyph.parentElement!; return { background: style.backgroundColor, border: style.borderTopWidth, color: style.borderTopColor, height: node.getBoundingClientRect().height, glyph: getComputedStyle(glyph).width, frame: getComputedStyle(frame).width, title: getComputedStyle(node.querySelector('h3')!).fontSize }; }); assert.equal(face.background, await tokenColor(page, '.peach-react', '--color-background-secondary-default')); assert.equal(face.border, '1px', `${example.path}空态缺少边框`); assert.equal(face.color, await tokenColor(page, '.peach-react', '--color-separator-border')); assert.ok(face.height >= example.height); assert.deepEqual([face.glyph, face.frame, face.title], ['32px', '54px', '14px']); assert.deepEqual((await layout(page)).offenders, []); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } } } }); it('标准空态共用底色、圆角、320px 高度与 54px 图标框', { timeout: 60_000 }, async () => { // 演示库在一轮里凑得出重复组,桩成空的才一定落到整页空态上。 const opened = await visit(browser, '/duplicates', DESKTOP); try { const page = opened.page; await page.route('**/api/duplicates?**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ total: 0, files: 0, reclaimable: 0, groups: [] }), })); await page.reload({ waitUntil: 'load' }); await page.locator('#stats [data-empty-state]').waitFor({ timeout: 15_000 }); await settle(page); const face = await page.evaluate(() => { const root = document.querySelector('#stats [data-empty-state]')!; const glyph = root.querySelector('svg')!; const box = glyph.parentElement!; const shell = getComputedStyle(root); const frame = getComputedStyle(box); const title = getComputedStyle(root.querySelector('h3')!); const copy = getComputedStyle(root.querySelector('p')!); return { shell: { face: shell.backgroundColor, line: shell.borderTopColor, width: shell.borderTopWidth, radius: shell.borderTopLeftRadius, minHeight: shell.minHeight }, boxed: box !== root, frame: { width: frame.width, height: frame.height, radius: frame.borderTopLeftRadius, line: frame.borderTopWidth, glyph: getComputedStyle(glyph).width }, title: [title.fontSize, title.fontWeight], copy: [copy.maxWidth, copy.lineHeight], }; }); assert.equal(face.shell.face, await tokenColor(page, '.peach-react', '--color-background-secondary-default'), '整页空态没有沉一档底色'); assert.equal(face.shell.line, await tokenColor(page, '.peach-react', '--color-separator-border'), '外框不是分隔线色'); assert.deepEqual([face.shell.width, face.shell.radius, face.shell.minHeight], ['1px', '20px', '320px']); assert.ok(face.boxed, '整页空态的图标没有装进方框'); assert.deepEqual(face.frame, { width: '54px', height: '54px', radius: '20px', line: '1px', glyph: '32px' }); assert.deepEqual(face.title, ['14px', '600']); assert.deepEqual(face.copy, ['340px', '20.15px']); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } // 任务记录使用同一档标准空态。 const activity = await visit(browser, '/tasks', DESKTOP); try { await activity.page.route('**/api/tasks', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ available: true, running: [], skipped: [], finished: [] }), })); await activity.page.reload({ waitUntil: 'load' }); const empty = activity.page.locator('[data-empty-state]'); await empty.waitFor({ timeout: 15_000 }); await settle(activity.page); assert.equal(await empty.evaluate((root) => root.querySelector('svg')!.parentElement === root), false, '任务空态缺少图标框'); assert.deepEqual(activity.problems, []); } finally { await activity.close(); } }); it('骨架里的占位和按键悬停不给任何反馈,也点不中', { timeout: 120_000 }, async () => { const name = '七沢みあ'; const pages: { path: string; ready: string; targets: string[]; prepare?: (page: Page) => Promise }[] = [ { path: `/performers/${encodeURIComponent(name)}`, ready: '[data-skeleton="entity/performer"] [data-entity-foot] [data-hero-ring]', targets: ['[data-skeleton] [data-entity-foot] [data-hero-ring]'], prepare: (page) => page.route(/\/api\/entity\/shapes/, (route) => route.fulfill({ json: { ok: true, entities: [{ id: 90_001, kind: 'performer', names: [name], parts: ['costars'] }] } })) }, { path: '/data-cleanup', ready: '[data-skeleton="cleanup"] [data-skeleton-action]', targets: ['[data-skeleton] .inline-grid > div', '[data-skeleton] a[href="/sources"]', '[data-skeleton] [data-skeleton-action]'] }, { path: '/data-cleanup/media-repair', ready: '[data-skeleton="cleanup/media-repair"] [data-skeleton-action]', targets: ['[data-skeleton] [data-skeleton-action]'] }, { path: '/review', ready: '[data-skeleton="review"] .reviewtabs button', targets: ['[data-skeleton] .reviewtabs button', '[data-skeleton] .skeletoncard'] }, { path: '/follow', ready: '.followauthors .avskeleton', targets: ['.followauthors .avskeleton', '.followworks .brandskeleton', '[data-skeleton^="cards/"] > div > *'] }, { path: '/subscriptions', ready: '[data-skeleton="board/subscriptions"] .ui-follow-skeleton-toolbar', targets: ['[data-skeleton] .ui-follow-skeleton-toolbar [data-follow-toolbar-controls] button', '[data-skeleton] .ui-follow-skeleton-toolbar [data-follow-toolbar-actions] > button:nth-of-type(1)'] }, ]; for (const { path, ready, targets, prepare } of pages) { const opened = await visit(browser, '/', DESKTOP); try { const page = opened.page; // 后登记的路由先拿到请求:桩要排在挂住 /api/ 那条之后,不然它也被挂住。 await holdApi(page); await prepare?.(page); await page.goto(new URL(path, page.url()).href, { waitUntil: 'load' }); await page.locator(ready).first().waitFor({ state: 'visible', timeout: 15_000 }); for (const target of targets) { const node = page.locator(target).first(); await node.waitFor({ state: 'visible', timeout: 5_000 }); // 落在首屏以下的目标(数据管理的整理卡)先滚进视口:`elementFromPoint` 只认视口里的点。 await node.scrollIntoViewIfNeeded(); const box = (await node.boundingBox())!; const look = () => node.evaluate((element) => [element, ...element.querySelectorAll('*')].slice(0, 6).map((part) => { const style = getComputedStyle(part); return [style.borderColor, style.boxShadow, style.backgroundColor, style.backgroundImage, style.outlineStyle, style.color, style.scale, style.transform].join(' | '); })); await page.mouse.move(0, 0); const rest = await look(); const [x, y] = [box.x + box.width / 2, box.y + box.height / 2]; await page.mouse.move(x, y); assert.deepEqual(await look(), rest, `${path} ${target} 悬停时变了样子`); // 分层骨架那一排(`data-skeleton-tier`)是最终那条轨道本身,点中它等于点在页面底上; // 要问的是指针有没有落在某一枚占位或整块骨架里。 const hit = await page.evaluate(([px, py]) => { const at = document.elementFromPoint(px!, py!); return { cursor: at ? getComputedStyle(at).cursor : '', inside: !!at?.closest('[data-skeleton],.avskeleton,.brandskeleton,.tagskeleton,.skeletoncard'), disabled: !!at?.closest('button:disabled') }; }, [x, y]); assert.ok(['auto', 'default', 'not-allowed'].includes(hit.cursor), `${path} ${target} 悬停换了光标:${hit.cursor}`); assert.ok(!hit.inside || hit.disabled, `${path} ${target} 在骨架里还能点中`); } } finally { await opened.close(); } } }); it('暂停来源的标签使用主文字色;卡片末行的反馈面与外框同心', { timeout: 60_000 }, async () => { for (const viewport of [DESKTOP, MOBILE]) { const opened = await openFollowManage(browser, viewport, { extra: [{ ...followSource(4, 'paused', 'Kemono', 'paused · Kemono'), enabled: false }], }); try { const { page } = opened; for (const theme of ['light', 'dark']) { await page.evaluate((value) => { document.documentElement.dataset.theme = value; document.documentElement.classList.toggle('dark', value === 'dark'); }, theme); const card = page.getByRole('region', { name: 'paused 的关注来源', exact: true }); const badge = card.getByText('已暂停', { exact: true }); assert.equal(await badge.evaluate((node) => getComputedStyle(node).color), await tokenColor(page, '.peach-react', '--color-text-primary')); assert.notEqual(await badge.evaluate((node) => getComputedStyle(node).backgroundColor), await card.evaluate((node) => getComputedStyle(node).backgroundColor)); const row = card.locator('[data-follow-source-row]').last(); await row.hover(); const corners = await row.evaluate((node) => { const parent = node.closest('section')!, outer = getComputedStyle(parent), inner = getComputedStyle(node); return { outer: parseFloat(outer.borderBottomLeftRadius), inset: parseFloat(outer.paddingBottom), left: parseFloat(inner.borderBottomLeftRadius), right: parseFloat(inner.borderBottomRightRadius), top: parseFloat(inner.borderTopLeftRadius) }; }); assert.equal(corners.left + corners.inset, corners.outer); assert.equal(corners.right, corners.left); assert.equal(corners.top, 0); } assert.deepEqual(opened.problems, []); } finally { await opened.close(); } } }); it('勾中的来源在卡片和表格里都铺 BoardUI 数据表那一档选中底色', { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser); try { const page = opened.page; const selected = await tokenColor(page, '.peach-react', '--color-background-secondary-default'); await page.getByRole('checkbox', { name: '选择 kou · Kemono' }).check({ force: true, timeout: 5_000 }); const cardRow = page.locator('[data-source-divider] > [data-selected]'); assert.equal(await cardRow.count(), 1); assert.equal(await cardRow.evaluate((row) => getComputedStyle(row).backgroundColor), selected, '卡片里的选中行没有铺选中底色'); await page.locator('button[aria-label="表格视图"]').click({ timeout: 5_000 }); const tableRow = page.locator('[data-follow-selected]'); await tableRow.waitFor({ timeout: 5_000 }); assert.equal(await tableRow.evaluate((row) => getComputedStyle(row).backgroundColor), selected, '表格里的选中行和卡片里的不是同一档'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('别名的组数是次要字色的读数,不借提醒或主按钮的颜色', { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser); try { await followTab(opened, '添加关注'); const count = opened.page.getByText('1 组', { exact: true }); const look = await count.evaluate((node) => ({ ink: getComputedStyle(node).color, face: getComputedStyle(node).backgroundColor, })); assert.equal(look.ink, await tokenColor(opened.page, '.peach-react', '--color-text-secondary')); assert.equal(look.face, 'rgba(0, 0, 0, 0)', '组数画成了带底色的徽章'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); /* 建议由这里给,两组共 30 条,两个视口下字段下方都装不下。桌面取 1280×1000, 字段下缘在视口中段偏下,正是只能往下开、又放不全的那种处境。 */ for (const viewport of [{ ...DESKTOP, height: 1000 }, MOBILE]) { it(`添加关注的建议下拉压在字段下方剩下的空间里,装不下在菜单内滚,选中行滚进可见区(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser, viewport); try { const { page } = opened; await page.route(/\/api\/follow\/suggest\?/, (route) => route.fulfill({ json: { q: 'bulging', groups: [['归档站', 18], ['站方分类', 12]].map(([label, count]) => ({ kind: String(label), label, items: Array.from({ length: Number(count) }, (_, at) => ({ value: `bulging ${label} ${at + 1}`, matched: 'Kemono', n: 120 - at, })), })), } })); await followTab(opened, '添加关注'); const before = await page.evaluate(() => document.documentElement.scrollHeight); const input = page.getByRole('textbox', { name: '来源链接、名字或 id' }); await input.click({ timeout: 5_000 }); await input.fill('bulging'); const menu = page.locator('[aria-label="来源建议"]'); await menu.locator('button').nth(29).waitFor({ state: 'attached', timeout: 10_000 }); const measure = () => menu.evaluate((element) => { for (const animation of element.getAnimations({ subtree: true })) animation.finish(); const box = element.getBoundingClientRect(); const field = element.ownerDocument.querySelector('input[aria-label="来源链接、名字或 id"]')! .getBoundingClientRect(); const row = element.querySelector('[aria-current="true"]')?.getBoundingClientRect(); return { top: box.top, bottom: box.bottom, fieldBottom: field.bottom, viewport: innerHeight, overflowing: element.scrollHeight > element.clientHeight, overscroll: getComputedStyle(element).overscrollBehaviorY, page: document.documentElement.scrollHeight, pageWidth: document.documentElement.scrollWidth, width: innerWidth, y: scrollY, row: row ? { top: row.top, bottom: row.bottom } : null, }; }); const open = await measure(); assert.ok(open.bottom <= open.viewport + .5, `下拉底边 ${open.bottom} 越出视口 ${open.viewport}`); assert.ok(open.top >= open.fieldBottom, `下拉上沿 ${open.top} 盖住了字段(下缘 ${open.fieldBottom})`); assert.ok(open.overflowing, '30 条建议没有让下拉溢出,这条用例没练到内滚'); assert.equal(open.overscroll, 'contain', '下拉滚到头会带着页面一起滚'); assert.ok(open.page <= before, `下拉把页面从 ${before} 撑到 ${open.page}`); assert.ok(open.pageWidth <= open.width, `页面被撑出横向滚动:${open.pageWidth} > ${open.width}`); // 走到最后一条:它在初始可见区外面,得被滚进来,页面本身不动。 for (let step = 0; step < 30; step++) await input.press('ArrowDown'); const last = await measure(); assert.ok(last.row, '上下键没有选中任何一行'); assert.ok(last.row.top >= last.top - .5 && last.row.bottom <= last.bottom + .5, `选中行 ${last.row.top}–${last.row.bottom} 不在下拉可见区 ${last.top}–${last.bottom} 内`); assert.equal(last.y, open.y, '选中行滚进可见区时把页面也滚了'); await input.press('ArrowDown'); const first = await measure(); assert.ok(first.row && first.row.top >= first.top - .5, '绕回第一条后它没有滚回可见区'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } for (const viewport of [DESKTOP, { ...MOBILE, name: 'narrow', width: 320 }]) { it(`七位数的读数和单位留在卡里同一行;长名字与长报错的来源行不越出行,桌面上操作键和名字同一行(${viewport.name})`, { timeout: 60_000 }, async () => { const long = 'ThisIsAVeryLongUsernameThatPatreonAllowsWithoutAnySpaces2024'; const opened = await openFollowManage(browser, viewport, { counts: { new: 1284000, seen: 22000, saved: 0, ignored: 0 }, extra: [ followSource(4, long, 'Patreon', `${long} · Patreon`), { ...followSource(5, 'broken', 'Kemono', 'broken · Kemono', 'error'), last_error: `peach.follow_providers.ProviderError: HTTP 403: ${'
'.repeat(40)}` }, ], }); try { await opened.page.waitForFunction(() => { const card = document.querySelector('[data-measured-reading="updates"]'); const value = card?.querySelector('[skullet-pane]:not([skullet-exiting]) [data-follow-reading]'); return !!card && !card.querySelector('[aria-busy="true"]') && value?.textContent?.includes('1,284,000'); }); const spill = await opened.page.evaluate(() => { const out = (node: Element, frame: Element) => node.getBoundingClientRect().right > frame.getBoundingClientRect().right + 1; const values = [...document.querySelectorAll('[data-measured-reading] [data-follow-reading]')] .filter(node => !node.closest('[aria-hidden="true"], [inert], [skullet-exiting]')); const readings = values .filter((node) => [...node.children].some((part) => out(part, node.parentElement!))) .map((node) => node.textContent); const wrapped = values.filter((node) => { const [figure, unit] = [...node.children].map((part) => part.getBoundingClientRect()); return unit!.top >= figure!.bottom - 1; }).map((node) => node.textContent); const rows = [...document.querySelectorAll('[data-follow-source-row]')]; const spilled = rows .filter((row) => row.scrollWidth > row.clientWidth + 1 || [...row.querySelectorAll('button, [role="switch"], input')] .some((control) => out(control, row))) .map((row) => row.textContent); // 末一枚操作键的竖直中线落在名字列的上下沿之间,就是同一行。 const split = rows.filter((row) => { const name = row.querySelector('a')!.parentElement!.getBoundingClientRect(); const last = [...row.querySelectorAll('button')].at(-1)!.getBoundingClientRect(); const middle = (last.top + last.bottom) / 2; return middle < name.top - 1 || middle > name.bottom + 1; }).map((row) => row.textContent); return { readings, wrapped, spilled, split, rows: rows.length, values: values.length, text: document.querySelector('[data-measured-reading="updates"] [skullet-pane]:not([skullet-exiting]) [data-follow-reading]')?.textContent }; }); assert.ok(spill.text?.includes('1,284,000'), `读数没有用上七位数:${spill.text}`); assert.equal(spill.values, 6, '应测量六个已显示的真实读数,不包含隐藏样本'); assert.equal(spill.rows, 5, '造的五条来源没有全部画出来'); assert.deepEqual(spill.readings, [], '读数越出了卡片'); assert.deepEqual(spill.wrapped, [], '七位数的读数把单位挤到了下一行'); assert.deepEqual(spill.spilled, [], '来源行的控件越出了行'); if (!viewport.mobile) assert.deepEqual(spill.split, [], '桌面上来源行的操作键掉到了名字下面'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } it('凭据的四种处境四副底色:待办和完成一眼分得开', { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser); try { await opened.page.getByRole('link', { name: /^来源与订阅设置/ }).click(); await opened.page.getByRole('tab', { name: '来源与订阅', exact: true, selected: true }).waitFor(); await opened.page.getByRole('region', { name: '可配置凭据', exact: true }).waitFor(); await settle(opened.page); const faces: Record = {}; for (const state of ['需要', '已配置', '暂时无法连接', '不需要']) { faces[state] = await opened.page.getByText(state, { exact: true }).first() .evaluate((chip) => getComputedStyle(chip).backgroundColor); } assert.equal(new Set(Object.values(faces)).size, 4, `有两种处境同色:${JSON.stringify(faces)}`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('浅色下看片那两枚标识悬停时有底,资料卡上带订阅新作开关', { timeout: 60_000 }, async () => { // 演示库里没有人物实体:资料由这里给,入口与开关字段照服务端拼好下发的形状写。 const name = '七沢みあ'; const opened = await visit(browser, '/', DESKTOP); try { await opened.page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: { id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [], user_aliases: [], asset_count: 0, tags: [], related_performers: [], links: [], metadata: {}, has_image: false, has_avatar: false, avatar_focus: null, representative_asset_id: null, entry_links: [{ site: 'javdb', label: 'JavDB', ordinal: '', slot: 'mark', mark: 'mark-javdb', url: 'https://javdb.com/actors/NPD3' }], feed: { following: false }, } })); await opened.page.goto(new URL(`/performers/${encodeURIComponent(name)}`, opened.page.url()).href, { waitUntil: 'load' }); const mark = opened.page.locator('[data-entry-marks] a[data-entry-mark]').first(); await mark.waitFor({ timeout: 15_000 }); await settle(opened.page); // 浅色下 `--hover` 与资料卡的 `--ground` 同是 #f7f7f7,垫上去等于没垫。 await opened.page.evaluate(() => { document.documentElement.dataset.theme = 'light'; document.documentElement.classList.remove('dark'); }); await mark.hover(); const faces = await mark.evaluate((element) => ({ mark: getComputedStyle(element).backgroundColor, card: getComputedStyle(element.closest('[data-entity-card]')!).backgroundColor, })); assert.notEqual(faces.mark, 'rgba(0, 0, 0, 0)', '悬停没有垫底'); assert.notEqual(faces.mark, faces.card, '悬停底色和资料卡同色,看不出来'); const toggle = opened.page.getByRole('switch', { name: '订阅新作' }); assert.equal(await toggle.count(), 1, '资料卡上没有订阅新作开关'); assert.equal(await toggle.isChecked(), false); const feed = opened.page.locator('[data-entry-feed]'); await opened.page.emulateMedia({ reducedMotion: 'no-preference' }); assert.equal(await feed.evaluate((element) => getComputedStyle(element).animationName), 'entryfeed-breathe', '没订的那枚图标不呼吸,一枚墨色小图标没人注意到'); const tip = opened.page.locator('#entityFeedTip'); assert.equal(await tip.isVisible(), false); await feed.hover(); assert.equal(await tip.isVisible(), true, '悬停没有说明这枚图标是干嘛的'); assert.equal(await tip.innerText(), '订阅新作'); const placed = await tip.evaluate((element) => { const box = element.getBoundingClientRect(); return box.left >= 0 && box.right <= innerWidth && box.bottom <= innerHeight; }); assert.ok(placed, '说明浮层越出了视口'); await toggle.focus(); await opened.page.keyboard.press('Escape'); assert.equal(await tip.isVisible(), false, 'Escape 收不起说明浮层'); } finally { await opened.close(); } }); for (const viewport of [DESKTOP, MOBILE]) { it(`订阅新作的说明浮层整块露在外面:不被资料卡裁掉,也不被同台艺人那条盖住(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openPerformer(browser, viewport); try { const page = opened.page; const feed = page.locator('[data-entry-feed]'); await feed.hover(); const tip = page.locator('#entityFeedTip'); await tip.waitFor({ state: 'visible', timeout: 5_000 }); const placement = await tip.evaluate((element) => { const box = element.getBoundingClientRect(); const clippedBy: string[] = []; // 顶层里的元素不受祖先 overflow 裁切,只有写在文档流里的浮层才要逐层量。 const topLayer = element.matches(':popover-open'); for (let node = topLayer ? null : element.parentElement; node; node = node.parentElement) { const style = getComputedStyle(node); if (style.overflowX === 'visible' && style.overflowY === 'visible') continue; const clip = node.getBoundingClientRect(); if (box.left < clip.left - 0.5 || box.right > clip.right + 0.5 || box.top < clip.top - 0.5 || box.bottom > clip.bottom + 0.5) clippedBy.push(node.className || node.tagName); } // 浮层本身不接指针;临时放开再问四条边和正中最上面是谁,盖在它上面的东西就现形了。 // 取样点离角 16px:圆角外那一小块本来就不属于它。 element.style.pointerEvents = 'auto'; const [midX, midY] = [box.left + box.width / 2, box.top + box.height / 2]; const covered = [[box.left + 16, box.top + 2], [box.right - 16, box.top + 2], [box.left + 16, box.bottom - 2], [box.right - 16, box.bottom - 2], [box.left + 2, midY], [box.right - 2, midY], [midX, midY]] .map(([x, y]) => document.elementFromPoint(x!, y!)) .filter((hit) => !hit || !element.contains(hit)).map((hit) => hit?.className || 'null'); element.style.pointerEvents = ''; return { inView: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight, clippedBy, covered, below: box.top >= document.querySelector('[data-entry-feed]')!.getBoundingClientRect().bottom }; }); assert.ok(placement.inView, '说明浮层越出了视口'); assert.deepEqual(placement.clippedBy, [], '说明浮层被外层容器裁掉了一截'); assert.deepEqual(placement.covered, [], '说明浮层被别的东西盖住了'); assert.ok(placement.below, '视口下方放得下时说明浮层应该在图标下面'); const page_ = await layout(page); assert.ok(page_.scrollWidth <= page_.viewportWidth, '说明浮层把页面撑出了横向滚动'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } // 1000px 窗口侧栏展开时内容区只剩七百来像素:三栏的判据量的是内容区,这一档要排成两层, // 身份那一栏不能被资料表挤到只剩一个字宽。 for (const [viewport, stacked] of [[DESKTOP, false], [{ name: 'wide', width: 1440, height: 900, mobile: false }, false], [{ name: 'mid', width: 1000, height: 900, mobile: false }, true], [MOBILE, true]] as const) { it(`女优页头:宽屏资料表在身份信息右侧隔一道竖线,窄屏排到下面隔一道横线,整张卡不横向溢出(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openProfiledPerformer(browser, viewport); try { const geometry = await heroGeometry(opened.page); assert.deepEqual(geometry.labels, ['生日', '身材', '出道', '生涯', '标签'], '资料表不是那五项'); assert.ok(geometry.facts && geometry.identity && geometry.hero, '页头缺了资料表'); assert.ok(geometry.identity.right - geometry.identity.left >= 240, '身份那一栏被挤窄了'); if (stacked) { assert.ok(geometry.facts.top >= geometry.identity.bottom - 0.5, '窄屏下资料表没有排到身份信息下面'); assert.deepEqual(geometry.rule, { left: '0px', top: '1px' }, '窄屏下资料表该用横线和身份信息隔开'); } else { assert.ok(geometry.facts.left >= geometry.identity.right - 0.5, '宽屏下资料表没有排在身份信息右侧'); assert.deepEqual(geometry.rule, { left: '1px', top: '0px' }, '宽屏下资料表该用竖线和身份信息隔开'); } assert.ok(geometry.facts.right <= geometry.hero.right + 0.5, '资料表越出了资料卡'); assert.equal(geometry.heroScrolls, false, '资料卡里有东西被横向裁掉'); const debut = await opened.page.locator('[data-entity-facts] dd[title]').evaluate((dd) => ({ title: dd.getAttribute('title'), wrap: getComputedStyle(dd).whiteSpace, cut: getComputedStyle(dd).textOverflow, clipped: dd.scrollWidth > dd.clientWidth, lines: Math.round(dd.getBoundingClientRect().height / parseFloat(getComputedStyle(dd).lineHeight)), })); assert.equal(debut.title, PROFILED.profile.debut_title, '出道那一格的 title 没给全名'); assert.deepEqual([debut.wrap, debut.cut, debut.lines], ['nowrap', 'ellipsis', 1], '出道片名没有单行截断'); const page_ = await layout(opened.page); assert.ok(page_.scrollWidth <= page_.viewportWidth, `页头把页面撑出了横向滚动:${page_.offenders.join(',')}`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } for (const viewport of [DESKTOP, MOBILE]) { it(`公司资料页显示公司与品牌事实且长地址可折行(${viewport.name})`, { timeout: 60_000 }, async () => { const fact = (value: string) => ({ value, source_url: 'https://company.example/about' }); const opened = await openProfiledPerformer(browser, viewport, 'light', { kind: 'studio', canonical_name: 'Company', profile: undefined, agency: undefined, company_profile: { legal_name: fact('Example Company, LLC'), founded: fact('2002-05'), launched: fact('2016'), operator: fact('General Media Systems, LLC'), location: fact('LongOfficeAddressWithoutSpaces'.repeat(5)) }, }); try { await opened.page.goto(new URL('/studios/Company', opened.page.url()).href, { waitUntil: 'load' }); await opened.page.locator('[data-entity-facts]').waitFor(); await settle(opened.page); const geometry = await heroGeometry(opened.page); assert.deepEqual(geometry.labels, ['公司名称', '公司成立', '品牌启动', '所在地', '运营公司']); assert.equal(geometry.heroScrolls, false); assert.ok(geometry.hero && geometry.facts && geometry.facts.right <= geometry.hero.right + 1); assert.equal(await opened.page.locator('[data-entity-facts] [title]').count(), 0); const page_ = await layout(opened.page); assert.ok(page_.scrollWidth <= page_.viewportWidth); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } it('女优页头名字下面一行:视频数、事务所与读音加前三个别名,外链一律 36px 纯图标方块', { timeout: 60_000 }, async () => { const opened = await openProfiledPerformer(browser, DESKTOP); try { const page = opened.page; const line = await page.locator('[data-entity-alias]').evaluate((alias) => ({ glyphs: [...alias.querySelectorAll(':scope > [data-meta-item] > svg use')].map((use) => use.getAttribute('href')), names: [...alias.querySelectorAll('[data-alias-names] > span')].map((span) => span.textContent!.trim()), more: alias.querySelector('[data-hero-more="alias"]')?.textContent?.trim() ?? '', agency: alias.querySelector('a[data-agency]')?.textContent?.trim() ?? '', })); assert.deepEqual(line.glyphs, ['#i-film', '#i-briefcase', '#i-id-card'], '名字那一行的三项不是视频、事务所、别名'); assert.deepEqual(line.names, ['しのだゆう', '篠崎ゆう子', '高木早希', '橋本真紀'], '读音没有排在别名最前,或别名不是前三个'); assert.equal(line.more, '+4', '「+N」数的不是剩下那几个别名'); assert.equal(line.agency, 'New Actor eXperience'); const links = await page.locator('[data-entity-links] a').evaluateAll((anchors) => anchors.map((a) => { const box = a.getBoundingClientRect(); return { size: `${Math.round(box.width)}x${Math.round(box.height)}`, kind: a.getAttribute('data-link'), title: a.getAttribute('title'), name: a.getAttribute('aria-label') ?? '', visibleText: a.querySelector('[data-link-label]') !== null }; })); assert.equal(links.length, 3); for (const link of links) { assert.deepEqual([link.size, link.kind, link.visibleText], ['36x36', 'icon', false], `${link.title} 不是纯图标方块`); assert.equal(link.name, link.title, `${link.title} 给读屏的名字和悬停提示不一致`); } assert.deepEqual(links.map((link) => link.title), ['みんなのAV', 'New Actor eXperience 官方资料', 'X @shinoda_yu']); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); for (const viewport of [DESKTOP, MOBILE]) { it(`别名的「+N」浮层进顶层按名义分组:悬停、聚焦都出,不被资料卡裁掉,Escape 收起(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openProfiledPerformer(browser, viewport); try { const page = opened.page; const more = page.locator('[data-hero-more="alias"]'); const pop = page.locator('#entityAliasPop'); assert.equal(await pop.isVisible(), false); await more.hover(); await pop.waitFor({ state: 'visible', timeout: 5_000 }); assert.equal(await more.getAttribute('aria-expanded'), 'true'); const shown = await pop.evaluate((element) => { const box = element.getBoundingClientRect(); return { topLayer: element.matches(':popover-open'), inView: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight, title: element.querySelector('[data-hero-pop-head]')?.textContent?.trim(), groups: [...element.querySelectorAll('dt')].map((dt) => dt.textContent!.trim()), face: getComputedStyle(element).backgroundColor, shadow: getComputedStyle(element).boxShadow, }; }); assert.equal(shown.shadow, await popmenuShadow(page), '别名浮层的落影和 .popmenu 不是同一副'); assert.equal(shown.topLayer, true, '别名浮层没进顶层,会被资料卡的 overflow:hidden 裁掉'); assert.ok(shown.inView, '别名浮层越出了视口'); assert.equal(shown.title, '7 个别名'); assert.deepEqual(shown.groups, ['旧名义', '舞ワイフ', 'ラグジュTV', '其它']); assert.notEqual(shown.face, 'rgba(0, 0, 0, 0)', '别名浮层没有底色'); // 指针从按钮挪到浮层上读名字,浮层不能在半路收起。 const box = (await pop.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 }); await page.waitForTimeout(300); assert.equal(await pop.isVisible(), true, '指针移到浮层上它就收起了'); await page.mouse.move(1, 1); await pop.waitFor({ state: 'hidden', timeout: 5_000 }); await more.focus(); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await pop.waitFor({ state: 'visible', timeout: 5_000 }); await page.keyboard.press('Escape'); assert.equal(await pop.isVisible(), false, 'Escape 收不起别名浮层'); const page_ = await layout(page); assert.ok(page_.scrollWidth <= page_.viewportWidth, '别名浮层把页面撑出了横向滚动'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } // 名字右边那枚按钮往左开的话,260px 的菜单越过名字压在头像上(用户报过)。 for (const viewport of [DESKTOP, MOBILE]) { it(`名字与别名菜单从按钮左缘往右开,不盖住页头头像(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openProfiledPerformer(browser, viewport); try { const page = opened.page; await page.locator('[data-namepick-toggle]').click(); await page.locator('[data-namepick-menu]').waitFor({ state: 'visible', timeout: 5_000 }); const box = await page.evaluate(() => { const rect = (selector: string) => { const b = document.querySelector(selector)!.getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom }; }; return { menu: rect('[data-namepick-menu]'), portrait: rect('[data-entity-portrait]'), toggle: rect('[data-namepick-toggle]') }; }); const overlaps = box.menu.left < box.portrait.right && box.menu.right > box.portrait.left && box.menu.top < box.portrait.bottom && box.menu.bottom > box.portrait.top; assert.equal(overlaps, false, `名字菜单盖住了头像:${JSON.stringify(box)}`); assert.ok(box.menu.left >= 0 && box.menu.right <= viewport.width, '名字菜单越出了视口'); if (!viewport.mobile) assert.ok(Math.abs(box.menu.left - box.toggle.left) < 1, '宽屏下菜单没有对齐按钮左缘'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); } });