/* 设计决定:作品卡、管理页与复核。读 `getComputedStyle` 断言用户定过的外观;共用的桩与浏览器生命周期在 `design-fixture.ts`。 */ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Browser, Locator, Page } from 'playwright-core'; import { expectBody, settle, visit } from './harness.ts'; import { DESKTOP, MOBILE, tokenColor, openReview, REVIEW_ROW, lightness, WIDE, JUNK_COUNT, openJunk, junkItem, openJunkWith, openCatalog, openCatalogFixture, openFollowManage, stubFollowManage, holdApi, controlFaces, disabledTokens, waitingActionFaces, assertDisabledFace, installDesignBrowser, PIXEL, } from './design-fixture.ts'; /** 每个视口从未阅读的进度开始;账本跨设备持久化由 API 回归验证。 */ async function freshTutorial(page: Page) { const state = { version: 1, campaign: 'browser-tutorial', invited: false, completed: false, chapters: {} as Record }; await page.route('**/api/tutorial-progress', async route => { if (route.request().method() === 'POST') { const action = route.request().postDataJSON(); if (action.action === 'dismiss') state.invited = true; else if (action.action === 'complete' || action.action === 'reopen') { state.invited = true; state.completed = action.action === 'complete'; } else { const row = state.chapters[action.chapter] ??= { next: 0, read: [], skipped: [] }; row.next = action.next; if (action.action === 'step') { row.read = row.read.filter(step => step !== action.step); row.skipped = row.skipped.filter(step => step !== action.step); row[action.skipped ? 'skipped' : 'read'].push(action.step); } } } await route.fulfill({ json: state }); }); } // 监视器只保留框架异步收尾异常的调用栈,错误仍由 Node 判为失败。 process.on('uncaughtExceptionMonitor', error => console.error(error.stack)); describe('设计决定:作品卡、管理页与复核', () => { let browser: Browser; installDesignBrowser((next) => { browser = next; }); /* 骨架是列表回来之前那一屏的形状预告:JAV 默认大图,卡片是 3:4 的正封,骨架照 16:9 铺的话,内容一到整屏卡片都被拉高一截。把列表请求扣住,只看骨架本身。 */ it('作品骨架的封面比例跟当前版式走:JAV 大图 3:4,首页小图 16:9', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/', DESKTOP); const { page } = opened; await page.route(/\/api\/items\?/, () => {}); /* 骨架挂上的那一帧可能还没排版(宽高都是 0),量出来是 NaN。等它有了尺寸、且连续两帧 比例不变再读:量的是这一屏落定后的形状,不是插进 DOM 的那一瞬。 */ const skeletonRatio = async (path: string) => { await page.goto(new URL(path, page.url()).href, { waitUntil: 'load' }); const handle = await page.waitForFunction(() => { const cover = document.querySelector('#grid .catalog-skeleton .skeletoncard i'); const box = cover?.getBoundingClientRect(); if (!box || !box.width || !box.height) return false; const ratio = box.width / box.height; const holder = window as unknown as { skeletonRatioSeen?: number }; const settled = holder.skeletonRatioSeen === ratio; holder.skeletonRatioSeen = ratio; return settled && ratio; }, undefined, { polling: 'raf', timeout: 15_000 }); return Number(await handle.jsonValue()); }; try { assert.ok(Math.abs(await skeletonRatio('/?jav=1') - 0.75) < 0.05, 'JAV 大图下骨架封面不是正封比例'); assert.ok(Math.abs(await skeletonRatio('/') - 16 / 9) < 0.05, '首页小图下骨架封面不是 16:9'); } finally { await opened.close(); } }); it('卡片悬停反馈不在封面像素上描边', { timeout: 60_000 }, async () => { const opened = await openCatalog(browser); try { const card = opened.page.locator('#grid [data-media-card]').first(); const picture = card.locator('[data-media-pic]'); await card.hover(); const overlay = await picture.evaluate((element) => { const style = getComputedStyle(element, '::after'); return { content: style.content, borderWidth: style.borderTopWidth }; }); assert.equal(overlay.content, 'none', '悬停伪元素仍覆盖在封面像素上'); assert.equal(overlay.borderWidth, '0px', '悬停边线仍污染圆角边缘像素'); assert.match(await card.evaluate((element) => getComputedStyle(element).boxShadow), /0px 0px 0px 8px/, '悬停底色没有在卡片四周向外多铺 8px'); assert.equal(await card.locator('[data-media-later]').evaluate( (element) => getComputedStyle(element).opacity), '1', '移除描边后没有保留悬停反馈'); } finally { await opened.close(); } }); it('长悬浮主控在窄卡同步缩小,两颗角落按钮保持同档且不覆盖主控', { timeout: 60_000 }, async () => { const opened = await openCatalog(browser); try { const card = opened.page.locator('#grid [data-media-card]:has([data-media-seek])').first(); for (const [width, main, corner] of [[470, 58, 36], [200, 36, 30]]) { await card.evaluate((node, size) => { const element = node as HTMLElement; element.style.width = `${size}px`; element.style.minWidth = `${size}px`; element.style.maxWidth = `${size}px`; element.dataset.longhover = ''; }, width!); await opened.page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); const geometry = await card.evaluate(node => { const primary = [...node.querySelectorAll('[data-media-seek] button')]; const corners = [...node.querySelectorAll('[data-media-preview], [data-media-later] button')]; const sizes = (nodes: Element[]) => nodes.map(button => { const style = getComputedStyle(button); return [parseFloat(style.width), parseFloat(style.height)]; }); const overlaps = corners.some(corner => primary.some(button => { const a = corner.getBoundingClientRect(), b = button.getBoundingClientRect(); return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; })); return { primary: sizes(primary), corners: sizes(corners), overlaps }; }); assert.deepEqual(geometry.primary, Array.from({ length: 3 }, () => [main, main])); assert.deepEqual(geometry.corners, [[corner, corner], [corner, corner]]); assert.equal(geometry.overlaps, false, JSON.stringify({ width, geometry })); } } finally { await opened.close() } }); it('馆藏卡片的字取页面正文那一档,角标、悬停控件、进度条、竖屏带、Mix 与回收站卡面按定下的样子画', { timeout: 90_000 }, async () => { /* 演示库凑不出回收站里的非视频资源:回收站那一页把第二条换成一个压缩包。 目录那一页给前三条挂半程观看进度,量进度条的颜色。 */ const opened = await openCatalogFixture(browser, (payload, url) => { if (url.searchParams.get('state') !== 'trash') { if (!url.searchParams.has('orient')) assert.ok(payload.items.length >= 8, 'Mix 外观夹具至少提供八张作品'); for (const item of payload.items.slice(0, 3)) Object.assign(item, { duration: 600, play_seconds: 300 }); return; } const item = payload.items[1]; if (!item) throw new Error('演示目录不够两条'); Object.assign(item, { medium: 'archive', name: '演示资料.zip', disposal: 'trash' }); Object.assign(payload.items[0], { disposal: 'trash', is_jav: false, has_cover: false, has_thumb: true }); }, { completeFirstPage: true }); const { page } = opened; await page.route('**/poster**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'), })); /* 岛根是 `line-height:1.5`,卡里没写行高的格子会继承它;旧卡读的是页面正文 14px/20px。 取值用同一个岛根下的空元素量 token,不在断言里抄像素。 */ const readStyles = (selectors: Record) => page.evaluate((wanted) => { const root = document.querySelector('#grid .peach-react')!; const pick = (element: Element | null) => { if (!element) return null; const style = getComputedStyle(element); return { fontSize: style.fontSize, lineHeight: style.lineHeight, fontFamily: style.fontFamily, letterSpacing: style.letterSpacing, minHeight: style.minHeight, padding: style.padding, display: style.display, place: `${style.alignItems} ${style.justifyItems}`, color: style.color, background: style.backgroundColor, borderWidth: style.borderTopWidth, borderColor: style.borderTopColor, leftRadius: style.borderTopLeftRadius, width: style.width, opacity: style.opacity }; }; const probe = (css: string) => { const element = document.createElement('div'); element.style.cssText = css; root.append(element); const value = pick(element)!; element.remove(); return value; }; return { body: probe('font: var(--board-body)'), caption: probe('font: var(--board-caption)'), md: probe('font-size: var(--fs-md)'), muted: probe('color: var(--muted)'), text: probe(''), ground: probe('background: var(--ground)'), tungsten: probe('background: var(--tungsten)'), overlay: probe('background: var(--overlay-5); border: 1px solid var(--border-10)'), ...Object.fromEntries(Object.entries(wanted).map(([key, selector]) => [key, pick(root.querySelector(selector))])), }; }, selectors); try { await page.locator('#grid [data-shorts-strip]').first().waitFor({ timeout: 10_000 }); await page.locator('#grid [data-media-grid] > [data-media-card] [data-media-progress]').first().waitFor({ timeout: 10_000 }); // 本例检查外观,夹具提供完整首页,Mix 按累计八张的真实规则插入。 await page.locator('#grid [data-mix-glyph]').first().waitFor({ state: 'attached' }); const catalog = await readStyles({ card: '[data-media-grid] > [data-media-card]', badge: '[data-media-grid] > [data-media-card] [data-media-badge]', shorts: '[data-shorts-strip] > h2', mix: '[data-media-grid] > [data-mix-card]', mixTitle: '[data-media-grid] > [data-mix-card] [data-mix-title]', mixGlyph: '[data-media-grid] > [data-mix-card] [data-mix-glyph]', strip: '[data-shorts-strip]', progress: '[data-media-grid] > [data-media-card] [data-media-progress] > i', seekGlyph: '[data-media-grid] > [data-media-card] [data-media-seek] svg', laterButton: '[data-media-grid] > [data-media-card] [data-media-later] button', laterGlyph: '[data-media-grid] > [data-media-card] [data-media-later] svg', }); const { body, caption, md, card, badge, shorts, mix, mixTitle } = catalog; /* 竖屏带靠底色和网格区分,不描线;宽屏上往左铺过侧栏轨道,贴视口那一侧不留圆角。 */ assert.deepEqual([catalog.strip?.background, catalog.strip?.borderWidth], [catalog.ground.background, '0px'], '竖屏带不是页面底色那一张面,或者描了一圈线'); assert.equal(catalog.strip?.leftRadius, '0px', '宽屏上竖屏带贴视口的那一侧还留着圆角'); const edges = await page.evaluate(() => ({ strip: document.querySelector('#grid [data-shorts-strip]')!.getBoundingClientRect().left, grid: document.querySelector('#grid [data-media-grid]')!.getBoundingClientRect().left, })); assert.ok(edges.strip < edges.grid, `竖屏带左缘 ${edges.strip} 没有越过网格左缘 ${edges.grid} 铺到侧栏底下`); /* 沉浸模式是一叠竖着翻的卡,入口用叠卡那一枚字形,不和别的动作共用。 */ assert.equal(await page.locator('#grid [data-shorts-enter] svg use').first().getAttribute('href'), '#i-gallery-vertical-end', '竖屏带的「进入沉浸模式」换了字形'); const immerse = page.locator('#grid [data-shorts-enter]').first(); await immerse.scrollIntoViewIfNeeded(); const measureImmerse = () => immerse.evaluate((node) => { const button = node.getBoundingClientRect(); const strip = node.closest('[data-shorts-strip]')!.getBoundingClientRect(); return { x: button.x - strip.x, y: button.y - strip.y, width: button.width, height: button.height, transform: getComputedStyle(node).transform }; }); const resting = await measureImmerse(); await immerse.hover(); await page.waitForTimeout(180); const hovering = await measureImmerse(); assert.deepEqual(hovering, resting, '沉浸入口悬停保持原位与尺寸'); const shortCard = page.locator('#grid [data-shorts-row] [data-media-card]').first(); await shortCard.hover(); await page.waitForTimeout(180); const feedback = await shortCard.evaluate(node => { const card = node.getBoundingClientRect(), row = node.closest('[data-shorts-row]')!.getBoundingClientRect(); const style = getComputedStyle(node); return { background: style.backgroundColor, shadow: style.boxShadow, top: card.top - row.top, bottom: row.bottom - card.bottom, strip: getComputedStyle(node.closest('[data-shorts-strip]')!).backgroundColor }; }); assert.notEqual(feedback.background, 'rgba(0, 0, 0, 0)'); assert.notEqual(feedback.shadow, 'none'); /* 浅色下全站悬停面与带子底色同为 #F7F7F7,铺出去等于没铺。 */ assert.notEqual(feedback.background, feedback.strip, '竖屏带里卡片的悬停面和带子底色同色'); assert.ok(feedback.top >= 8 && feedback.bottom >= 8, JSON.stringify(feedback)); /* 长悬停上移再放大,连同外铺的悬停面都要落在横滚行的裁切框里。 */ const lifted = await shortCard.evaluate(async node => { node.setAttribute('data-longhover', ''); await Promise.all(node.getAnimations().map(animation => { animation.finish(); return animation.finished })); const card = node.getBoundingClientRect(), row = node.closest('[data-shorts-row]')!.getBoundingClientRect(); const spread = parseFloat(getComputedStyle(node).boxShadow.split(' ').at(-1)!) * 1.065; node.removeAttribute('data-longhover'); return { top: card.top - spread - row.top, bottom: row.bottom - card.bottom - spread }; }); assert.ok(lifted.top >= 0 && lifted.bottom >= 0, `放大的竖屏卡被横滚行裁掉:${JSON.stringify(lifted)}`); await page.mouse.move(0, 0); assert.equal(catalog.progress?.background, catalog.tungsten.background, '观看进度条不是钨丝蓝'); assert.deepEqual([catalog.mixGlyph?.background, catalog.mixGlyph?.borderWidth, catalog.mixGlyph?.borderColor], [catalog.overlay.background, '1px', catalog.overlay.borderColor], 'Mix 署名位那枚字形不是半透明底加细边'); /* 主控保持 58px 配 34px 图标;角落动作取同一档 36px 配 18px 图标。 */ assert.deepEqual([catalog.seekGlyph?.width, catalog.laterButton?.width, catalog.laterGlyph?.width], ['34px', '36px', '18px'], '悬停控件的尺寸和图标比例变了'); const denseShort = await page.evaluate(() => { document.body.dataset.density = 'dense'; const width = getComputedStyle(document.querySelector('#grid [data-shorts-strip] [data-media-card]')!).width; delete document.body.dataset.density; return width; }); assert.equal(denseShort, '107px', '密集模式下竖屏带里的卡没有跟着缩到 214px 的一半'); assert.deepEqual([card?.fontSize, card?.lineHeight], ['14px', '20px'], '作品卡的字不是页面正文那一档,岛根的 1.5 行高漏了进来'); assert.deepEqual([badge?.fontSize, badge?.lineHeight], [caption.fontSize, caption.lineHeight], '来源角标的字不是 caption 那一档'); assert.equal(badge?.minHeight, '24px', '来源角标矮于 24px'); assert.deepEqual([shorts?.fontFamily, shorts?.lineHeight], [body.fontFamily, '20px'], '竖屏带标题的字体或行高不同页面正文'); assert.ok(mix && mixTitle, '夹具目录里没有插进 Mix'); assert.deepEqual([mix.fontSize, mix.lineHeight], ['14px', '20px'], '网格里的 Mix 卡没取页面正文那一档'); assert.equal(mixTitle.letterSpacing, 'normal', '网格里的 Mix 标题带着字距'); assert.ok(Math.abs(parseFloat(mixTitle.lineHeight) - parseFloat(md.fontSize) * 1.45) < .5, `网格里的 Mix 标题行高 ${mixTitle.lineHeight},不是 --fs-md 的 1.45 倍`); await page.goto(new URL('/trash', page.url()).href, { waitUntil: 'load' }); await page.locator('#grid [data-media-card][data-variant="resource"]').first().waitFor({ timeout: 15_000 }); await settle(page); const trash = await readStyles({ meta: '[data-media-grid] > [data-media-card] [data-media-meta]', glyph: '[data-variant="resource"] [data-media-glyph]', kind: '[data-variant="resource"] [data-media-kind-glyph]', }); /* 回收站卡的标题统一两行,头像对齐标题顶部,归入原因与边框保留间距。 */ const inset = await page.locator('#grid [data-media-card][data-variant="resource"]').first().evaluate((card) => { const box = (selector: string) => card.querySelector(selector)!.getBoundingClientRect(); const frame = card.getBoundingClientRect(); const [pic, avatar, title, reason] = ['[data-media-pic]', '[data-media-avatar]', '[data-media-title]', '[data-media-disposal-reason]'].map(box); return { left: avatar.left - frame.left, top: title.top - pic.bottom, bottom: frame.bottom - reason.bottom, title: title.height, lineHeight: parseFloat(getComputedStyle(card.querySelector('[data-media-title]')!).lineHeight), skew: Math.abs(avatar.top - title.top) }; }); assert.ok(inset.left >= 10 && inset.top >= 8 && inset.bottom >= 10, `回收站卡的元信息区贴着边框或封面:左 ${inset.left}、上 ${inset.top}、下 ${inset.bottom}`); assert.ok(Math.abs(inset.title - inset.lineHeight * 2) < 1, `回收站标题未预留两行:${JSON.stringify(inset)}`); assert.ok(inset.skew <= 3, `头像没有位于标题区顶部,偏 ${inset.skew}px`); assert.deepEqual([trash.glyph?.display, trash.glyph?.place, trash.glyph?.color], ['grid', 'center center', trash.muted.color], '回收站资源卡封面格里的字形没有居中或不是次要文字色'); assert.deepEqual([trash.kind?.display, trash.kind?.place, trash.kind?.color], ['grid', 'center center', trash.text.color], '回收站资源卡头像位的字形没有居中或不是正文色'); /* 悬停扫视层是壳插进封面格的 `img.ui-hvframes`:待删卡的灰化要连它一起,否则悬停时整卡「复活」成正常色。 */ const scan = await page.locator('#grid [data-media-card][data-pending-delete]:not([data-variant="resource"]) [data-media-pic]') .first().evaluate((pic) => { const layer = document.createElement('img'); layer.className = 'ui-hvframes'; pic.append(layer); const filter = getComputedStyle(layer).filter; layer.remove(); return filter; }); assert.match(scan, /grayscale\(0\.9\)/, '待删卡的悬停扫视层没有跟着灰化'); /* 待删卡的封面、补底和元信息一起压暗。 */ const pending = await page.locator('#grid [data-media-card][data-pending-delete]:not([data-variant="resource"])') .first().evaluate((card) => ({ meta: getComputedStyle(card.querySelector('[data-media-meta]')!).opacity, backdrop: getComputedStyle(card.querySelector('[data-media-pic]')!, '::before').filter, image: getComputedStyle(card.querySelector('[data-media-pic] img')!).filter, })); assert.equal(pending.meta, '0.58', '待删卡的元信息区没有压暗'); assert.match(pending.backdrop, /blur\(26px\) grayscale\(0\.9\) brightness\(0\.27\)/, '待删卡的模糊补底跟随封面灰化'); assert.match(pending.image, /grayscale\(0\.9\) brightness\(0\.54\)/, '待删卡封面没有跟着灰化'); } finally { await opened.close(); } }); it('回收站的骨架和落地同一副几何,计数栏在两种主题下都和页面底色分得开', { timeout: 90_000 }, async () => { /* 回收站多是图片与压缩包,骨架照资源卡排:夹具把回收站那一页全换成图片。 */ const opened = await openCatalogFixture(browser, (payload, url) => { if (url.searchParams.get('state') !== 'trash') return; for (const [at, item] of payload.items.entries()) { Object.assign(item, { medium: 'image', name: `演示图片-${at}.jpg`, disposal: 'trash' }); } }); const { page } = opened; const measure = () => page.evaluate(() => { const box = (element: Element | null) => { if (!element) return null; const rect = element.getBoundingClientRect(); return [Math.round(rect.top), Math.round(rect.height)]; }; const lede = document.querySelector('[data-manage-lede]'); const card = [...document.querySelectorAll( '#grid .catalog-skeleton .skeletoncard, #grid [data-media-card][data-variant="resource"]', )].find(node => !node.closest('[data-content-skeleton][aria-hidden="true"], [data-content-current][aria-hidden="true"]') && getComputedStyle(node).visibility === 'visible') ?? null; /* 读数与「清空回收站」各自的竖直中线:两者说的是同一批文件,同在说明行一行里。 */ const middle = (element: Element | null | undefined) => { if (!element) return null; const rect = element.getBoundingClientRect(); return Math.round(rect.top + rect.height / 2); }; return { lede: box(lede), card: box(card), cells: [...lede?.querySelectorAll('[data-trash-lede-skeleton]') ?? []].map((cell) => cell.dataset.trashLedeSkeleton), text: lede?.querySelector('[data-lede-text]')?.textContent ?? null, row: [middle(lede?.querySelector('[data-lede-text]')), middle(lede?.querySelector('[data-empty-trash]'))] }; }); try { const release = await holdApi(page); await page.goto(new URL('/trash', page.url()).href, { waitUntil: 'load' }); await page.locator('#grid .catalog-skeleton .skeletoncard').first().waitFor({ timeout: 15_000 }); const waiting = await measure(); release(); await page.locator('#grid [data-media-card][data-variant="resource"]').first().waitFor({ timeout: 15_000 }); await settle(page); const landed = await measure(); assert.ok(waiting.lede, '回收站骨架期间没有计数栏,读数到了才冒出来把网格往下推'); assert.deepEqual([waiting.cells, waiting.text], [['text', 'action'], null], `骨架期间说明行不是两格占位:${JSON.stringify(waiting)}`); assert.match(landed.text ?? '', /个符合 · 显示 \d+/, `读数没有落进说明行:${JSON.stringify(landed)}`); assert.deepEqual(landed.cells, [], '读数落地后占位还在'); const [textMiddle, buttonMiddle] = landed.row; assert.ok(textMiddle != null && buttonMiddle != null && Math.abs(textMiddle - buttonMiddle) <= 1, `读数和清空键没并在说明行同一行:${JSON.stringify(landed.row)}`); assert.deepEqual(waiting.lede, landed.lede, `计数栏落地时位置或高度跳了:${JSON.stringify({ waiting, landed })}`); assert.deepEqual(waiting.card, landed.card, `回收站首张卡落地时位置或高度跳了:${JSON.stringify({ waiting, landed })}`); /* 同一历史条目保留读数和卡片,不重取也不重新铺占位。 */ await page.evaluate(() => { const flags = window as unknown as { trashLedeFlashed?: boolean }; flags.trashLedeFlashed = false; new MutationObserver(() => { if (document.querySelector('[data-trash-lede-skeleton]')) flags.trashLedeFlashed = true; }).observe(document.querySelector('[data-manage-header]')!, { childList: true, subtree: true }); }); const rereads: string[] = []; page.on('request', request => { const url = new URL(request.url()); if (url.pathname === '/api/items' && url.searchParams.get('state') === 'trash') rereads.push(url.href); }); await page.evaluate(() => dispatchEvent(new PopStateEvent('popstate'))); await page.locator('#grid [data-media-card][data-variant="resource"]').first().waitFor({ timeout: 15_000 }); await settle(page); const holding = await measure(); assert.deepEqual(rereads, [], '同一历史条目重复取数'); assert.deepEqual([holding.cells, holding.text], [[], landed.text], `同页重画时说明行回到了占位:${JSON.stringify(holding)}`); assert.equal(await page.evaluate(() => (window as unknown as { trashLedeFlashed?: boolean }).trashLedeFlashed), false, '同页重画期间说明行闪回了占位'); const surfaces = await page.evaluate(() => { const root = document.documentElement; const before = root.dataset.theme; const read = (theme: string) => { root.dataset.theme = theme; return [getComputedStyle(document.querySelector('[data-manage-lede]')!).backgroundColor, getComputedStyle(document.body).backgroundColor]; }; const result = { light: read('light'), dark: read('dark') }; if (before === undefined) delete root.dataset.theme; else root.dataset.theme = before; return result; }); for (const [theme, [lede, ground]] of Object.entries(surfaces)) { assert.notEqual(lede, ground, `${theme} 主题下回收站计数栏和页面同一个底色:${lede}`); } } finally { await opened.close(); } }); it('批量条的键是行内字形、计数是 --ink-2;批量条与清空回收站两颗危险键读同一组红', { timeout: 90_000 }, async () => { const opened = await openCatalogFixture(browser, (payload, url) => { if (url.searchParams.get('state') !== 'trash') return; for (const item of payload.items) Object.assign(item, { disposal: 'trash' }); }); const { page } = opened; try { await page.goto(new URL('/trash', page.url()).href, { waitUntil: 'load' }); const cards = page.locator('#grid [data-media-card][data-id]'); await cards.nth(1).waitFor({ timeout: 15_000 }); await page.locator('[data-manage-lede] [data-empty-trash]').waitFor({ timeout: 15_000 }); for (const at of [0, 1]) await cards.nth(at).click({ modifiers: ['Control'], position: { x: 20, y: 20 } }); await page.locator('[data-batch-dock] [data-selection-dock]').waitFor({ timeout: 10_000 }); await settle(page); const read = await page.evaluate(() => { const dock = document.querySelector('[data-batch-dock] [data-selection-dock]')!; /* 两颗危险键的静止面与悬停层各自读出来,再与直接解析 token 的探针比:同一份定义才会三者一致。 */ const probe = (token: string) => { const node = document.createElement('div'); node.style.background = `var(${token})`; document.body.append(node); const value = getComputedStyle(node).backgroundImage; node.remove(); return value; }; const paint = (key: Element) => [getComputedStyle(key).backgroundImage, getComputedStyle(key, '::before').backgroundImage]; return { // 与文字同行的字形取 `--icon-inline`(1.2em),按所在文字字号算出应有的边长。 glyphs: [...dock.querySelectorAll('button svg')].map((svg) => { const box = svg.getBoundingClientRect(); const inline = parseFloat(getComputedStyle(svg).fontSize) * 1.2; return [Math.abs(box.width - inline) < 0.5, Math.abs(box.height - inline) < 0.5]; }), count: getComputedStyle(dock.querySelector('[role="status"]')!).color, batch: paint(dock.querySelector('[data-batch-action="delete"]')!), empty: paint(document.querySelector('[data-manage-lede] [data-empty-trash]')!), tokens: [probe('--board-red'), probe('--board-red-hover')], }; }); assert.deepEqual(read.glyphs, [[true, true], [true, true], [true, true]], `批量条的字形没按行内字号缩放:${JSON.stringify(read.glyphs)}`); assert.equal(read.count, await tokenColor(page, 'body', '--ink-2'), '批量条的计数不是次级墨色'); assert.match(read.tokens[0], /^linear-gradient/, `--board-red 没解析成渐变:${read.tokens[0]}`); assert.deepEqual(read.batch, read.tokens, `批量条「彻底删除」的红不是 --board-red 那一组:${JSON.stringify(read)}`); assert.deepEqual(read.empty, read.tokens, `「清空回收站」的红不是 --board-red 那一组:${JSON.stringify(read)}`); } finally { await opened.close(); } }); it('管理导航、面包屑与标题按层级排列,导航选中态使用中性填充', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/duplicates', DESKTOP); try { const { page } = opened; await page.locator('[data-manage-crumb] [aria-current="true"]').waitFor({ timeout: 15_000 }); await settle(page); const [ink, muted, fill] = await Promise.all(['--ink', '--muted', '--hover'].map((token) => tokenColor(page, 'body', token))); const read = await page.evaluate(() => { const items = [...document.querySelectorAll('[data-manage-crumb] li')]; const pressed = document.querySelector('[data-manage][aria-pressed="true"]')!; const thumb = pressed.parentElement!.querySelector('[data-pill-thumb]')!; const selectedBox = pressed.getBoundingClientRect(), thumbBox = thumb.getBoundingClientRect(); return { colors: items.map((item) => getComputedStyle(item).color), separators: items.map((item) => item.querySelector('svg') ? getComputedStyle(item.querySelector('svg')!).stroke : null), gap: getComputedStyle(document.querySelector('[data-manage-crumb] ol')!).columnGap, tab: getComputedStyle(pressed).color, fill: getComputedStyle(thumb).backgroundColor, buttonFill: getComputedStyle(pressed).backgroundColor, selectionDelta: [thumbBox.left - selectedBox.left, thumbBox.top - selectedBox.top, thumbBox.width - selectedBox.width, thumbBox.height - selectedBox.height], navBeforeCrumb: document.querySelector('[data-manage-bar]')!.getBoundingClientRect().bottom <= document.querySelector('[data-manage-crumb]')!.getBoundingClientRect().top, crumbBeforeTitle: document.querySelector('[data-manage-crumb]')!.getBoundingClientRect().bottom <= document.querySelector('[data-manage-title]')!.getBoundingClientRect().top, titleLeft: Math.round(document.querySelector('[data-manage-title]')!.getBoundingClientRect().left - document.querySelector('#grid')!.getBoundingClientRect().left), }; }); assert.deepEqual(read.colors, [muted, ink], '上一级不是 --muted,或当前项没升到 --ink'); assert.deepEqual(read.separators, [muted, null], '分隔符跟着当前项提亮了,或最后一项也带了分隔符'); assert.equal(read.gap, '6px'); assert.deepEqual([read.tab, read.fill], [ink, fill]); assert.equal(read.buttonFill, 'rgba(0, 0, 0, 0)'); assert.ok(read.selectionDelta.every(value => Math.abs(value) < 1), '选中底板没有对齐当前导航项'); assert.equal(read.navBeforeCrumb, true); assert.equal(read.crumbBeforeTitle, true); assert.equal(read.titleLeft, 0); assert.deepEqual(opened.problems.filter((line) => !line.includes('/api/links/check')), []); } finally { await opened.close(); } }); it('分卷卡不翻卡、悬停走分段预览,叠层纸边和封面同一档圆角', { timeout: 60_000 }, async () => { /* 各卷共用同一个番号的封套,翻过去还是那张图。演示库没有分卷,给首张卡挂一个。 */ const opened = await openCatalogFixture(browser, (payload) => { const [first, second] = payload.items; payload.items[0] = { ...first, part_group: { key: 'DEMO-PART', title: 'DEMO-PART', count: 2, seed_id: first.id, item_ids: [first.id, second.id], total_duration: 120, total_size: 1 } }; }); try { const shapeOf = (selector: string) => opened.page.locator(selector).first().evaluate((element) => { const stack = element.querySelector('[data-media-stack],[data-mix-stack]')!; const cover = element.querySelector('[data-media-pic],[data-mix-cover]')!; return { // 封面格的圆角由 clip-path 裁出来(media-card.css),格子本身不写 border-radius。 cover: getComputedStyle(cover).clipPath.match(/round (\S+)\)$/)?.[1] ?? '0px', ground: getComputedStyle(cover).backgroundColor, layers: ['::before', '::after'].map((pseudo) => getComputedStyle(stack, pseudo).borderTopLeftRadius), faces: element.querySelectorAll('[data-mix-faces]').length, preview: Boolean(element.querySelector('[data-media-preview]')), }; }); await opened.page.locator('[data-media-card][data-part-seed]').first().waitFor({ timeout: 10_000 }); const part = await shapeOf('[data-media-card][data-part-seed]'); assert.notEqual(part.cover, '0px', '封面没有圆角,比对失去意义'); assert.deepEqual(part.layers, [part.cover, part.cover], '分卷卡的叠层纸边和封面不是同一档圆角'); // 封面格背后压着纸边:格子是空的,纸边线条就从封面没盖住的地方透出来。 assert.notEqual(part.ground, 'rgba(0, 0, 0, 0)', '叠层卡的封面格是透明的,纸边会透进封面'); assert.equal(part.faces, 0, '分卷卡仍挂着翻卡面板'); assert.ok(part.preview, '分卷卡悬停没有分段预览入口'); if (await opened.page.locator('#grid [data-mix-card]').count()) { const mix = await shapeOf('#grid [data-mix-card]'); assert.deepEqual(mix.layers, [mix.cover, mix.cover], 'Mix 卡的叠层纸边和封面不是同一档圆角'); } assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('分卷计数在悬停与键盘聚焦时淡出,右上角让给倒计时圈', { timeout: 60_000 }, async () => { const opened = await openCatalogFixture(browser, (payload) => { const [first, second] = payload.items; payload.items[0] = { ...first, part_group: { key: 'DEMO-PART', title: 'DEMO-PART', count: 2, seed_id: first.id, item_ids: [first.id, second.id], total_duration: 120, total_size: 1 } }; }); try { const page = opened.page; const card = page.locator('[data-media-card][data-part-seed]').first(); await card.waitFor({ timeout: 10_000 }); const read = () => card.evaluate(async (element) => { await new Promise((resolve) => setTimeout(resolve, 300)); const group = element.querySelector('[data-media-group]')!; const box = group.getBoundingClientRect(); const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); return { group: [getComputedStyle(group).opacity, getComputedStyle(group).visibility], later: getComputedStyle(element.querySelector('[data-media-later]')!).opacity, hitGroup: hit?.closest('[data-media-group]') === group, }; }); await page.mouse.move(0, 0); assert.deepEqual(await read(), { group: ['1', 'visible'], later: '0', hitGroup: true }, '不悬停时分卷计数没有照常显示'); await card.locator('[data-media-pic]').hover(); assert.deepEqual(await read(), { group: ['0', 'hidden'], later: '1', hitGroup: false }, '悬停时分卷计数没有淡出,或仍压在右上角的悬停工具上'); await page.mouse.move(0, 0); const opener = card.locator('[data-media-open]'); await opener.focus(); assert.equal(await opener.evaluate((element) => element.matches(':focus-visible')), true); assert.deepEqual(await read(), { group: ['0', 'hidden'], later: '1', hitGroup: false }, '键盘聚焦时分卷计数没有淡出'); assert.equal(await card.locator('[data-media-preview]').evaluate((element) => getComputedStyle(element).opacity), '1', '键盘聚焦时右上角没有打开预览的键'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('抬起与推开只属于作品卡的共演头像', { timeout: 60_000 }, async () => { const opened = await openCatalog(browser); try { await opened.page.emulateMedia({ reducedMotion: 'no-preference' }); for (const selector of ['[data-tier-performer]', '[data-tier-studio]']) { const entry = opened.page.locator(selector).first(); if (!await entry.count()) continue; await entry.hover(); await opened.page.waitForTimeout(320); assert.equal(await entry.evaluate((element) => getComputedStyle(element).transform), 'none', `${selector} 仍套用了作品共演头像的抬起效果`); } } finally { await opened.close(); } }); it('视频卡整块是入口且内部链接保持独立', { timeout: 60_000 }, async () => { const opened = await openCatalogFixture(browser, (payload) => { payload.items[0] = { ...payload.items[0], tags: ['演示标签'] }; }); try { const card = opened.page.locator('#grid [data-media-card][data-id]').first(); const opener = card.locator('[data-media-open]'); const boxes = await Promise.all([card.boundingBox(), opener.boundingBox()]); assert.deepEqual(boxes[1], boxes[0], '全卡入口没有覆盖图片、文字与卡内空白'); await card.hover(); assert.notEqual(await card.evaluate((element) => getComputedStyle(element).backgroundColor), 'rgba(0, 0, 0, 0)', '悬停整卡没有灰色反馈'); await opened.page.setViewportSize({ width: 390, height: 844 }); const narrow = await Promise.all([card.boundingBox(), opener.boundingBox()]); assert.deepEqual(narrow[1], narrow[0], '390px 下全卡入口没有覆盖完整卡片'); assert.ok((narrow[0]?.x || 0) >= 0 && (narrow[0]?.x || 0) + (narrow[0]?.width || 0) <= 390, '390px 下视频卡越出视口'); const nested = card.locator('button[data-media-tag]').first(); const tag = await nested.getAttribute('data-tag'); assert.ok(tag, '演示卡没有可操作的内部标签'); await nested.focus(); assert.equal(await nested.evaluate((element) => element.matches(':focus-visible')), true, '卡内链接不能用键盘聚焦'); await nested.press('Enter'); await opened.page.waitForURL((url) => url.searchParams.get('tag') === tag, { timeout: 10_000 }); assert.doesNotMatch(opened.page.url(), /\/item\//, '卡内链接冒泡打开了视频'); } finally { await opened.close(); } }); for (const [label, viewport] of [['390px', MOBILE], ['宽屏', DESKTOP]] as const) { it(`${label} 下教程中心可阅读、关闭与重开,不提交业务写入`, { timeout: 60_000 }, async () => { const opened = await visit(browser, '/', viewport, freshTutorial); try { const page = opened.page; await expectBody(page, '/', [page.locator('#grid')]); await settle(page); const writes: string[] = []; const streams: string[] = []; page.on('request', request => { if (/^\/(?:stream|api\/stream-plan)/.test(new URL(request.url()).pathname)) streams.push(request.url()); if (new URL(request.url()).pathname === '/api/diagnostics/client-error' && request.postDataJSON()?.kind === 'context') return; if (new URL(request.url()).pathname.startsWith('/api/') && new URL(request.url()).pathname !== '/api/tutorial-progress' && !['GET', 'HEAD'].includes(request.method())) { writes.push(`${request.method()} ${new URL(request.url()).pathname}`); } }); const opener = page.locator('#tutorialBtn'); await page.locator('.peach-guide-popover').getByRole('heading', { name: '从这里打开新手教程' }).waitFor(); assert.ok(await opener.evaluate(node => node.classList.contains('driver-active-element'))); await opener.click(); const dialog = page.getByRole('dialog', { name: '新手教程', exact: true }); await dialog.waitFor(); for (const name of ['开始使用', '界面导览', '设置导览']) { await dialog.locator('label').filter({ has: page.getByRole('radio', { name, exact: true }) }).click(); assert.equal(await dialog.locator('[data-guide-banner]').count(), 1, `${name} 缺少共用引导区`); assert.equal(await dialog.locator('[data-guide-banner] .peach-guide-topic-icon svg').count(), 1, `${name} 缺少图标`); } const box = await dialog.boundingBox(); assert.ok(box && box.x >= 0 && box.y >= 0 && box.x + box.width <= viewport.width && box.y + box.height <= viewport.height, '教程中心越出视口'); await dialog.locator('label').filter({ has: page.getByRole('radio', { name: '设置导览', exact: true }) }).click(); const segments = await dialog.getByRole('radiogroup', { name: '教程路径' }).evaluate(node => { const surface = node.querySelector('[data-moving-surface]')!; const first = node.querySelector('label')!; return { position: getComputedStyle(surface).position, inset: first.getBoundingClientRect().left - node.getBoundingClientRect().left }; }); assert.equal(segments.position, 'absolute', '选中底板不应占据分段器的排列空间'); assert.ok(segments.inset <= 8, `首个选项前出现 ${segments.inset}px 空白`); const scroller = dialog.locator('.peach-guide-body'); assert.equal(await scroller.evaluate(node => getComputedStyle(node).scrollbarWidth), 'none', '正文应使用共享覆盖式滚动条'); const headerTop = (await dialog.locator('header').boundingBox())!.y; await scroller.evaluate(node => { node.scrollTop = node.scrollHeight }); assert.equal((await dialog.locator('header').boundingBox())!.y, headerTop, '标题栏随正文滚走了'); await dialog.getByRole('searchbox', { name: '搜索教程' }).fill('不存在的功能xyz'); await dialog.getByRole('heading', { name: '没有匹配的教程', exact: true }).waitFor(); await dialog.getByRole('button', { name: '关闭新手教程', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement === document.getElementById('tutorialBtn')); await opener.click(); await dialog.waitFor(); const inside = (await dialog.locator('header').boundingBox())!; await page.mouse.move(inside.x + 40, inside.y + 20); await page.mouse.down(); await page.mouse.move(2, 2); await page.mouse.up(); assert.ok(await dialog.isVisible(), '从正文拖到遮罩不应关闭教程'); await page.mouse.click(2, 2); await dialog.waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement === document.getElementById('tutorialBtn')); await opener.click(); await dialog.waitFor(); await dialog.locator('label').filter({ has: page.getByRole('radio', { name: '界面导览', exact: true }) }).click(); assert.equal(await dialog.locator('[data-guide-topic]').count(), 4); await dialog.locator('[data-guide-topic="browse"] > details > summary').click(); await dialog.getByRole('button', { name: /找到并播放/ }).click(); await page.locator('.peach-guide-popover').waitFor(); assert.equal(await dialog.isVisible(), false, '章节点击后仍停留在中间介绍页'); const playbackGuide = page.locator('.peach-guide-popover'); for (let step = 0; step < 4; step += 1) await playbackGuide.getByRole('button', { name: '下一步', exact: true }).click(); await page.locator('#stage .vjs-control-bar.driver-active-element').waitFor(); assert.deepEqual(await page.locator('#stage video.vjs-tech').evaluate(video => ({ paused: (video as HTMLVideoElement).paused, src: (video as HTMLVideoElement).currentSrc })), { paused: true, src: '' }); await playbackGuide.getByRole('button', { name: '下一步', exact: true }).click(); await page.locator('#stage [data-player-subtitles].driver-active-element').waitFor(); await playbackGuide.locator('#driver-popover-description').click(); assert.equal(await page.locator('#stage .vjs-peach-settings-toggle').getAttribute('aria-expanded'), 'true', '点击说明关闭了正在讲解的菜单'); await page.waitForFunction(() => { const panel = document.querySelector('.peach-guide-popover')?.getBoundingClientRect(); return document.querySelector('.driver-overlay')?.parentElement?.id === 'stage' && panel && panel.left >= 0 && panel.right <= innerWidth && panel.top >= 0 && panel.bottom <= innerHeight; }); await playbackGuide.getByRole('button', { name: '下一步', exact: true }).click(); await page.locator('#stage [data-local-playback-row].driver-active-element').waitFor(); await playbackGuide.getByRole('button', { name: '完成本章', exact: true }).click(); await page.locator('#stage[open]').waitFor({ state: 'hidden' }); assert.deepEqual(streams, [], '导览请求了视频正片'); await dialog.waitFor(); await dialog.getByRole('button', { name: '开始快速导览', exact: true }).click(); const guide = page.locator('.peach-guide-popover'); await guide.waitFor(); await settle(page, '.peach-guide-popover'); const guideBox = (await guide.boundingBox())!; assert.ok(guideBox.x >= 8 && guideBox.x + guideBox.width <= viewport.width - 8, '导览说明卡贴住了视口边缘'); assert.ok(await guide.evaluate(node => node.contains(document.activeElement)), '导览焦点未停留在说明卡内'); const resized = viewport.width === 390 ? { width: 1280, height: 800 } : { width: 390, height: 844 }; await page.setViewportSize(resized); await page.waitForFunction(() => { const box = document.querySelector('.peach-guide-popover')?.getBoundingClientRect(); const target = document.querySelector('.driver-active-element')?.getBoundingClientRect(); return box && target && box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight && target.width > 0 && target.right > 0 && target.left < innerWidth; }); await page.setViewportSize({ width: viewport.width, height: viewport.height }); await page.mouse.click(2, viewport.height - 2); await guide.waitFor({ state: 'hidden' }); await dialog.waitFor(); await dialog.getByRole('button', { name: '继续快速导览', exact: true }).click(); await guide.waitFor(); await page.keyboard.press('Escape'); await guide.waitFor({ state: 'hidden' }); await dialog.waitFor(); await dialog.getByRole('button', { name: '继续快速导览', exact: true }).click(); await guide.getByRole('button', { name: '跳过本章', exact: true }).click(); await dialog.waitFor(); assert.equal(await guide.isVisible(), false, '跳过本章后仍停在步骤中'); assert.equal(await dialog.locator('[data-guide-banner][data-guide-completed]').count(), 0, '跳过误记为完成'); await dialog.locator('label').filter({ has: page.getByRole('radio', { name: '设置导览', exact: true }) }).click(); await dialog.getByRole('button', { name: '了解常用设置', exact: true }).click(); await guide.waitFor(); for (let step = 0; step < 4; step += 1) await guide.getByRole('button', { name: '下一步', exact: true }).click(); await guide.getByRole('button', { name: '完成本章', exact: true }).click(); await dialog.waitFor(); await page.locator('#settingsPanel').waitFor({ state: 'hidden' }); assert.equal(new URL(page.url()).pathname, '/', '设置导览完成后未返回开始前的页面'); assert.ok(await dialog.getByRole('radio', { name: '设置导览', exact: true }).isChecked()); await dialog.getByRole('searchbox', { name: '搜索教程' }).fill('媒体库'); await dialog.getByRole('button', { name: /媒体库与入库 媒体库/ }).click(); await guide.waitFor(); for (let step = 0; step < 8; step += 1) await guide.getByRole('button', { name: '下一步', exact: true }).click(); await guide.getByRole('button', { name: '完成本章', exact: true }).click(); await dialog.waitFor(); assert.equal(new URL(page.url()).pathname, '/', '页面导览完成后未返回开始前的页面'); assert.ok(await dialog.getByRole('searchbox', { name: '搜索教程' }).isVisible()); await dialog.locator('label').filter({ has: page.getByRole('radio', { name: '界面导览', exact: true }) }).click(); await dialog.getByRole('button', { name: '继续快速导览', exact: true }).click(); for (let step = 0; step < 3; step += 1) await guide.getByRole('button', { name: '下一步', exact: true }).click(); await guide.getByRole('button', { name: '完成本章', exact: true }).click(); await dialog.waitFor(); await opener.waitFor({ state: 'hidden' }); assert.ok(await dialog.getByRole('button', { name: '再看一遍', exact: true }).isVisible()); assert.equal(await dialog.locator('[data-guide-banner][data-guide-completed]').count(), 1); await page.reload(); await page.locator('#grid').waitFor(); assert.equal(await opener.isVisible(), false, '完成快速导览后刷新又显示了教程按钮'); assert.deepEqual(writes, [], '只读教程触发了业务写入'); await page.goto(new URL('/settings/maintenance', page.url()).href); await page.getByRole('region', { name: '新手教程', exact: true }).getByRole('button', { name: '重新打开', exact: true }).click(); await dialog.waitFor(); assert.equal(await opener.evaluate(node => (node as HTMLElement).hidden), false, '设置重新打开后没有恢复右上角入口'); assert.ok(writes.every(write => write === 'POST /api/configuration/storage/measure'), `正常进入维护页只允许自动统计占用:${JSON.stringify(writes)}`); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); } it('复核筛选条上的下拉和按钮一样高', { timeout: 60_000 }, async () => { const opened = await openReview(browser); try { /* BoardUI 的 Button 和 Input 都写死 h-8/h-9,Select 的 trigger 只有内边距, 自己撑到 28px/38px。三颗并排时那 4px 一眼就看得见。量的是整条筛选条上每一颗 控件,而不是点名某一颗:这一排以后加什么,都得落在同一档上。 */ const heights = await opened.page.locator('[data-review-filter]') .evaluate((bar) => [...bar.querySelectorAll('button')] .map((node) => Math.round(node.getBoundingClientRect().height))); assert.ok(heights.length >= 2, `筛选条上只量到 ${heights.length} 颗控件`); assert.deepEqual([...new Set(heights)], [heights[0]], `筛选条上的控件高度不齐:${heights.join(' / ')}`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('复核卡的勾选框和标题共用一条中线', { timeout: 60_000 }, async () => { const opened = await openReview(browser); try { /* 勾选框 16px、标题那行 24px(字段名是一枚 caption Chip)。两个高度不同的东西 顶对顶排在一起,读的人看到的是勾选框比标题高出一截,而它们说的是同一张卡。 */ const offset = await opened.page.locator('section[data-review-key] header').first() .evaluate((element) => { const middle = (node: Element) => { const box = node.getBoundingClientRect(); return box.top + box.height / 2; }; return middle(element.querySelector('label > span[aria-hidden="true"]')!) - middle(element.querySelector('h4')!); }); assert.ok(Math.abs(offset) <= 1, `勾选框比标题偏了 ${offset.toFixed(1)}px,不在同一条中线上`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('来源依据使用无下划线的展开按钮,文字差异清晰可见', { timeout: 60_000 }, async () => { const row = { ...REVIEW_ROW, field: 'title', field_label: '标题', current_value: '共同旧标题。', candidates: ['dmm', 'javdb'].map(source => ({ candidate_key: `${source}:title`, source, display_value: '共同新标题。', catalog_evidence: { title: { display_value: '共同新标题。' } } })) }; const opened = await openReview(browser, [row]); try { const card = opened.page.locator('section[data-review-key]').first(); const toggle = card.getByRole('button', { name: '展开 dmm 的来源依据', exact: true }); await toggle.hover(); const observed = await toggle.evaluate(node => ({ tag: node.tagName, underline: [node, ...node.querySelectorAll('*')].some(element => getComputedStyle(element).textDecorationLine.includes('underline')) })); assert.deepEqual(observed, { tag: 'BUTTON', underline: false }); const mark = card.locator('label:has(input[type="radio"]) mark').first(); assert.equal(await mark.textContent(), '新'); const color = await mark.evaluate(node => ({ text: getComputedStyle(node).color, parent: getComputedStyle(node.parentElement!).color, background: getComputedStyle(node).backgroundColor })); assert.notEqual(color.text, color.parent); assert.notEqual(color.background, 'rgba(0, 0, 0, 0)'); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); for (const viewport of [DESKTOP, MOBILE]) { it(`复核长资料只在正文滚动,卡头和判断操作仍在卡内 ${viewport.width}`, { timeout: 60_000 }, async () => { const row = { ...REVIEW_ROW, current_value: '当前已登记的完整资料。'.repeat(20), candidates: ['dmm', 'javdb'].map(source => ({ candidate_key: `${source}:studio`, source, content_id: 'very-long-content-identifier'.repeat(8), display_value: '来源提供的长资料。'.repeat(30) })) }; const opened = await openReview(browser, [row], viewport); try { const geometry = await opened.page.locator('section[data-review-key]').first().evaluate(node => { const box = node.getBoundingClientRect(); const body = node.querySelector('[data-overlay-scrollbar]')!; const footer = node.querySelector('footer')!.getBoundingClientRect(); return { areas: node.querySelectorAll('[data-overlay-scrollbar]').length, scrolls: body.scrollHeight > body.clientHeight, footerInside: footer.top >= box.top && footer.bottom <= box.bottom + 1, overflow: node.scrollWidth > node.clientWidth, pageOverflow: document.documentElement.scrollWidth > innerWidth, currentInBody: body.contains(node.querySelector('[aria-label="当前信息"]')), sourceOverflow: [...node.querySelectorAll('label:has(input[type="radio"])')] .some(label => label.scrollWidth > label.clientWidth + 1) }; }); assert.deepEqual(geometry, { areas: 1, scrolls: true, footerInside: true, overflow: false, pageOverflow: false, currentInBody: true, sourceOverflow: false }); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); it(`未收录词条之间使用分隔线 ${viewport.width}`, { timeout: 60_000 }, async () => { const row = { ...REVIEW_ROW, candidates: [{ ...REVIEW_ROW.candidates[0]!, unmapped_genres: ['アクメ・オーガズム', '美少女電影'] }] }; const opened = await openReview(browser, [row], viewport); try { const group = opened.page.getByRole('group', { name: '未收录 genre', exact: true }); const rows = group.getByRole('listitem'); assert.equal(await rows.count(), 2); const dividers = await rows.evaluateAll(nodes => nodes.map(node => { const style = getComputedStyle(node); const parent = getComputedStyle(node.closest('[role="group"]')!); return { width: style.borderTopWidth, solid: style.borderTopStyle === 'solid', visible: style.borderTopColor !== parent.backgroundColor && style.borderTopColor !== 'rgba(0, 0, 0, 0)' }; })); assert.deepEqual(dividers.map(row => row.width), ['0px', '1px']); assert.ok(dividers[1]!.solid && dividers[1]!.visible, '词条分隔线不可见'); assert.equal(await rows.nth(0).getByRole('button', { name: '收录', exact: true }).count(), 1); assert.equal(await rows.nth(1).getByRole('button', { name: '不是内容', exact: true }).count(), 1); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); it(`复核来源切换只滚动证据正文,卡头与操作条固定 ${viewport.width}`, { timeout: 60_000 }, async () => { const row = { ...REVIEW_ROW, candidates: ['dmm', 'javdb', 'mgs'].map(source => ({ candidate_key: `${source}:studio`, source, display_value: 'プレステージ', catalog_evidence: { title: { display_value: '隣の人妻と過ごす休日' }, runtime: { display_value: '120 分钟' }, director: { display_value: '田中' }, label: { display_value: 'プレステージ' }, }, })) }; const opened = await openReview(browser, [row], viewport); try { const card = opened.page.locator('section[data-review-key]').first(); const scroller = card.locator('div[data-overlay-scrollbar]').first(); const choices = card.locator('label:has(input[type="radio"])'); await card.scrollIntoViewIfNeeded(); const geometry = () => card.evaluate(node => { const rect = (element: Element) => { const box = element.getBoundingClientRect(); return { top: box.top, height: box.height }; }; return { page: window.scrollY, scroll: node.scrollTop, card: rect(node), header: rect(node.querySelector('header')!), footer: rect(node.querySelector('footer')!) }; }); const before = await geometry(); const unanchored = await card.locator('input[type="radio"], input[type="checkbox"]') .evaluateAll(inputs => inputs.flatMap(input => { const native = input.getBoundingClientRect(); const label = input.closest('label')!.getBoundingClientRect(); const inside = native.top >= label.top - 1 && native.bottom <= label.bottom + 1 && native.left >= label.left - 1 && native.right <= label.right + 1; return inside ? [] : [{type:(input as HTMLInputElement).type,native:native.toJSON(),label:label.toJSON(),parent:input.parentElement?.outerHTML.slice(0,500)}]; })); assert.deepEqual(unanchored, [], '隐藏输入框未随所属单选或复选标签定位'); for (const at of [1, 2, 0]) { const choice = choices.nth(at); await choice.evaluate(node => { const body = node.closest('div[data-overlay-scrollbar]')!; body.scrollTop += node.getBoundingClientRect().top - body.getBoundingClientRect().top; }); await choice.click(); assert.equal(await choice.locator('input').isChecked(), true); assert.deepEqual(await geometry(), before, '点击来源移动了页面、卡片或操作条'); } await choices.first().locator('input').focus(); for (const key of ['ArrowDown', 'ArrowDown', 'ArrowUp']) { await opened.page.keyboard.press(key); assert.deepEqual(await geometry(), before, '方向键移动了页面、卡片或操作条'); const visible = await scroller.evaluate(node => { const input = node.querySelector('input:checked')!; const box = input.getBoundingClientRect(); const body = node.getBoundingClientRect(); return box.top >= body.top - 1 && box.bottom <= body.bottom + 1; }); assert.ok(visible, '键盘选中的来源没有进入正文可见区域'); } const selected = await choices.locator('input').evaluateAll(inputs => inputs.map(input => (input as HTMLInputElement).checked)); for (const source of ['javdb', 'mgs']) { await opened.page.keyboard.press('Tab'); const evidence = card.getByRole('button', { name: new RegExp(`^(展开|收起) ${source} 的来源依据$`) }); assert.equal(await evidence.evaluate(node => node === document.activeElement), true, 'Tab 应按文档顺序访问独立的来源依据'); await opened.page.keyboard.press('Space'); assert.equal(await evidence.getAttribute('aria-expanded'), 'true'); await opened.page.keyboard.press('Space'); assert.equal(await evidence.getAttribute('aria-expanded'), 'false'); assert.deepEqual(await choices.locator('input').evaluateAll(inputs => inputs.map(input => (input as HTMLInputElement).checked)), selected, '展开依据不得改变已选来源'); } await opened.page.keyboard.press('Tab'); assert.equal(await card.locator('footer').evaluate(node => node.contains(document.activeElement)), true, '访问依据后 Tab 应离开正文并进入判断操作'); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); } it('复核滚动边缘按方向出现,当前信息与候选共用正文滚动', { timeout: 60_000 }, async () => { const opened = await openReview(browser); try { const card = opened.page.locator('section[data-review-key]').first(); const scroller = card.locator('div[data-overlay-scrollbar]').first(); // 增加候选内容,覆盖图片加载或队列更新后的溢出重算。 await scroller.evaluate((node) => { const content = document.createElement('div'); content.style.cssText = 'height:1000px;flex-shrink:0'; content.dataset.scrollTest = ''; node.append(content); }); const edges = scroller.locator('..').locator('.ui-ov-edges'); await opened.page.waitForFunction(() => !!document.querySelector('[data-review-key] .ui-can-scroll-bottom')); assert.equal(await edges.evaluate((node) => node.classList.contains('ui-can-scroll-top')), false); await scroller.evaluate((node) => { node.scrollTop = 100 }); await opened.page.waitForFunction(() => !!document.querySelector('[data-review-key] .ui-can-scroll-top.ui-can-scroll-bottom')); assert.equal(await edges.evaluate((node) => getComputedStyle(node).pointerEvents), 'none'); assert.equal(await edges.locator('.ui-ov-edge-top').evaluate((node) => getComputedStyle(node).backdropFilter), 'blur(2px)'); const track = scroller.locator('..').locator('.ovtrack.ov-y'); for (const viewport of [DESKTOP, MOBILE]) { await opened.page.setViewportSize({ width: viewport.width, height: viewport.height }); const bounds = await track.evaluate((node) => { const scroll = node.parentElement!.querySelector('[data-overlay-scrollbar]')!.getBoundingClientRect(); const rail = node.getBoundingClientRect(); const card = node.closest('section')!.getBoundingClientRect(); return { gap: rail.left - scroll.right, inset: card.right - rail.right }; }); assert.ok(bounds.gap >= -1, `滚动条命中区覆盖正文:${JSON.stringify(bounds)}`); assert.ok(bounds.inset >= 1, `滚动条越出卡片:${JSON.stringify(bounds)}`); } await opened.page.setViewportSize({ width: DESKTOP.width, height: DESKTOP.height }); const thumb = await track.locator('.ovthumb').boundingBox(); assert.ok(thumb); await opened.page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2); await opened.page.mouse.down(); await opened.page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2 + 30); await opened.page.mouse.up(); assert.ok(await scroller.evaluate((node) => node.scrollTop > 100), '右侧轨道拖动没有滚动正文'); await scroller.evaluate((node) => { node.scrollTop = node.scrollHeight }); await opened.page.waitForFunction(() => !document.querySelector('[data-review-key] .ui-can-scroll-bottom')); await scroller.locator('[data-scroll-test]').evaluate((node) => node.remove()); await opened.page.waitForFunction(() => !document.querySelector('[data-review-key] [data-scroll-edges]')); const current = await card.getByRole('region', { name: '当前信息' }).evaluate((node) => { const style = getComputedStyle(node); return { inBody: !!node.closest('[data-overlay-scrollbar]'), overflow: style.overflowY, areas: node.closest('section[data-review-key]')!.querySelectorAll('[data-overlay-scrollbar]').length }; }); assert.equal(current.inBody, true, '当前资料不在候选共用的正文里'); assert.equal(current.areas, 1, '一张卡片出现多个独立滚动区'); assert.notEqual(current.overflow, 'auto', '当前资料不应再次单独滚动'); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); it('暗色下没选中的勾选框边线不比浅色下更弱', { timeout: 60_000 }, async () => { const opened = await openReview(browser); try { /* 这颗框的底和卡面同色,所以整个形状全靠那一圈 1px 的边说话。浅色下它是白底上的 浅灰线,暗色下必须至少同样清楚——否则卡上看着就是「没有框」。 */ /* 计算值是 `oklch()` 原样,解析不出通道;画进 1×1 的画布再读回来就是 RGBA。 */ const edge = () => opened.page.locator('section[data-review-key] header label > span').first() .evaluate((node) => { const context = document.createElement('canvas').getContext('2d')!; const paint = (color: string) => { context.clearRect(0, 0, 1, 1); context.fillStyle = color; context.fillRect(0, 0, 1, 1); return [...context.getImageData(0, 0, 1, 1).data]; }; const style = getComputedStyle(node); return [paint(style.borderTopColor), paint(style.backgroundColor)] as const; }); const [lightEdge, lightFace] = await edge(); // `web/app.js` 的 `applyTheme('dark')` 就是这两句;这里只借它换一次配色。 await opened.page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; document.documentElement.classList.add('dark'); }); const [darkEdge, darkFace] = await edge(); const light = Math.abs(lightness(lightEdge) - lightness(lightFace)); const dark = Math.abs(lightness(darkEdge) - lightness(darkFace)); assert.ok(dark >= light, `暗色下边线与框内只差 ${dark.toFixed(1)} 个明度,浅色下有 ${light.toFixed(1)}`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('暗色下分隔线在两种卡面上都不比浅色更弱', { timeout: 60_000 }, async () => { const opened = await openReview(browser); try { /* 量的是 token 对而不是某一条线:`separator-border` 画在哪种面上由各处自己决定, 复核卡页头与候选块之间那条线落在 `primary`,脚注带那条落在 `secondary`。逐条去点名, 新加一处就得记得再补一条用例,而漏补和「这处本来就没线」在屏幕上看不出区别。 末尾再核一次复核卡里的分隔线确实取的就是这个 token,免得两档都合格却根本没落到现场。 */ const read = () => opened.page.locator('section[data-review-key]').first() .evaluate((node) => { const context = document.createElement('canvas').getContext('2d')!; const paint = (color: string) => { context.clearRect(0, 0, 1, 1); context.fillStyle = color; context.fillRect(0, 0, 1, 1); return [...context.getImageData(0, 0, 1, 1).data]; }; const style = getComputedStyle(node); const token = (name: string) => paint(style.getPropertyValue(name).trim()); return { line: token('--color-separator-border'), card: token('--color-background-primary-default'), filled: token('--color-background-secondary-default'), edge: paint(getComputedStyle(node.querySelector('.border-separator-border')!).borderBottomColor), }; }); const before = await read(); // `web/app.js` 的 `applyTheme('dark')` 就是这两句;这里只借它换一次配色。 await opened.page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; document.documentElement.classList.add('dark'); }); const after = await read(); for (const [face, label] of [['card', '描边卡面'], ['filled', '填充卡面']] as const) { const light = Math.abs(lightness(before.line) - lightness(before[face])); const dark = Math.abs(lightness(after.line) - lightness(after[face])); assert.ok(dark >= light, `暗色下分隔线压在${label}上只差 ${dark.toFixed(1)} 个明度,浅色下有 ${light.toFixed(1)}`); } assert.deepEqual(after.edge, after.line, '复核卡里的分隔线没走 separator-border'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('垃圾文件的计数行与正文同宽,标题位于内容左上角', { timeout: 60_000 }, async () => { const opened = await openJunk(browser); try { /* 标题对齐内容左边缘;摘要、分类切换与网格共用正文宽度。 */ const box = await opened.page.evaluate(() => { // 浅色是两块面最容易消失的那一档:深色下 primary 本来就比页面亮一级。 document.documentElement.dataset.theme = 'light'; document.documentElement.classList.remove('dark'); const span = (selector: string) => { const rect = document.querySelector(selector)!.getBoundingClientRect(); return { left: Math.round(rect.left), width: Math.round(rect.width) }; }; const face = (selector: string) => getComputedStyle(document.querySelector(selector)!).backgroundColor; return { title: span('[data-manage-title]'), main: span('#main'), summary: span('#count [data-collection-summary]'), filters: span('#count [data-junk-filters-frame]'), grid: span('#grid'), summaryFace: face('#count [data-collection-summary]'), filtersFace: face('#count [data-junk-filters-frame]'), page: getComputedStyle(document.body).backgroundColor, }; }); assert.ok(box.filters.width > 0, '分类切换被摘要挤成 0 宽,整条在宽屏上看不见'); assert.deepEqual(box.title, box.grid); assert.equal(box.title.width, box.grid.width); for (const [label, measured] of [['摘要', box.summary], ['分类切换', box.filters]] as const) { assert.deepEqual(measured, box.grid, `${label}和正文网格不同宽:${measured.left}+${measured.width} 对 ${box.grid.left}+${box.grid.width}`); } assert.notEqual(box.summaryFace, box.page, '摘要那块面和页面底色同色,整块看不见'); assert.notEqual(box.filtersFace, box.page, '分类切换那块面和页面底色同色,整块看不见'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('垃圾文件等数据时画的仍是它自己那条计数行', { timeout: 60_000 }, async () => { const opened = await visit(browser, '/junk-files', WIDE); try { /* 把等待态停在屏幕上:让这一页唯一那次取数挂着不回。等的只有读数,分类切换由 URL 决定、此刻就画得出最终样子;画成目录那条的话,等待期间摆着一排这一页根本 没有的换批与排序键,数据到货整行再换成另一种东西。 */ await opened.page.route('**/api/ads?**', () => {}); await opened.page.reload({ waitUntil: 'load' }); // 等 island 接管:壳铺的骨架和它等数据时那一版相同,接管之后仍是这一版才算数。 await opened.page.locator(`${JUNK_COUNT} [data-junk-filters]`).waitFor({ timeout: 15_000 }); const row = await opened.page.locator('#count').evaluate((node) => ({ busy: node.getAttribute('aria-busy'), filters: node.querySelectorAll('[data-junk-filters] a').length, placeholder: node.querySelectorAll('[data-collection-summary] [data-skeleton="count"]').length, sorts: node.querySelectorAll('.sorts').length, })); assert.equal(row.busy, 'true', '等待态没有对辅助技术公开'); assert.ok(row.filters > 0, '等待期间这一行没有分类切换'); assert.equal(row.placeholder, 1, '占位没有落在读数那一格'); assert.equal(row.sorts, 0, '等待期间摆着这一页没有的排序键'); } finally { await opened.close(); } }); it('垃圾卡处置发出一条批量请求、卡随之离开;换分类不整页跳转', { timeout: 90_000 }, async () => { const opened = await openJunkWith(browser, [ junkItem(9101, 'archive', '推广合集.zip'), junkItem(9102, 'url', '官网.url'), junkItem(9103, 'audio', '广告.mp3'), ]); try { const page = opened.page; const cards = page.locator('#grid [data-junk-card]'); assert.equal(await cards.count(), 3); await cards.first().locator('[data-junk-action="dispose"]').click(); await page.waitForFunction(() => document.querySelectorAll('#grid [data-junk-card]').length === 2, null, { timeout: 10_000 }); assert.deepEqual(opened.state.batches, [{ ids: [9101], operation: 'dispose', disposal_reason: 'junk' }]); await page.getByText('已移入回收站', { exact: true }).first().waitFor({ timeout: 5_000 }); // 分类是 ``,普通左键由壳改地址重读:整页重载的话,页面上记的这个标记会丢。 await page.evaluate(() => { (window as { junkStay?: boolean }).junkStay = true }); await page.locator(`${JUNK_COUNT} [data-junk-kind-link="url"]`).click(); await page.waitForFunction(() => location.search === '?type=url' && document.querySelectorAll('#grid [data-junk-card]').length === 1, null, { timeout: 10_000 }); assert.equal(await page.evaluate(() => (window as { junkStay?: boolean }).junkStay), true, '换分类整页重载了'); assert.match(opened.state.reads.at(-1)!, /kind=url/); assert.equal(await page.locator(`${JUNK_COUNT} [data-junk-kind-link="url"]`).getAttribute('aria-current'), 'page'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('垃圾卡是一块有边的面:三颗键等宽居中,移入回收站静止就是实底红', { timeout: 90_000 }, async () => { const opened = await openJunkWith(browser, [junkItem(9201, 'archive', '推广.zip'), junkItem(9202, 'url', '官网.url')]); try { const page = opened.page; const read = () => page.evaluate(() => { const card = document.querySelector('#grid [data-junk-card]')!; const keys = [...card.querySelectorAll('[data-junk-action]')].map((button) => { const style = getComputedStyle(button); const rect = button.getBoundingClientRect(); return { action: (button as HTMLElement).dataset.junkAction, width: Math.round(rect.width), height: Math.round(rect.height), justify: style.justifyContent, border: style.borderTopWidth, face: style.backgroundColor, ink: style.color, label: getComputedStyle(button.querySelector('[data-junk-label]')!).display, }; }); const rect = (node: Element | null) => { if (!node) return null; const { left, top, width, height } = node.getBoundingClientRect(); return [left, top, width, height].map(Math.round); }; const links = [...document.querySelectorAll('#count [data-junk-filters] a')].map((link) => ({ current: link.getAttribute('aria-current') === 'page', face: getComputedStyle(link).backgroundColor, height: Math.round(link.getBoundingClientRect().height), box: rect(link), })); // 计数徽标到货时玻璃带动画重新落位:跑完再量,量的是它停下的地方。 document.querySelector('#count [data-view-glide]')?.getAnimations().forEach((one) => one.finish()); const glide = rect(document.querySelector('#count [data-view-glide]:not([hidden])')); // 卡在 island 里取 oklch,页面底是 rgb:各画一个像素再比,字面不同不等于颜色不同。 const pixel = (color: string) => { const context = document.createElement('canvas').getContext('2d')!; context.fillStyle = color; context.fillRect(0, 0, 1, 1); return [...context.getImageData(0, 0, 1, 1).data].join(','); }; const style = getComputedStyle(card); const ground = [document.body, document.documentElement].map((el) => pixel(getComputedStyle(el).backgroundColor)) .find((value) => !value.endsWith(',0')) ?? '255,255,255,255'; return { edge: style.borderTopWidth, line: pixel(style.borderTopColor), page: ground, keys, links, glide }; }); const red = await tokenColor(page, 'body', '--board-red'); const wide = await read(); // 浅色下卡底与页面同为白,卡的轮廓全靠这圈边:边宽 1px、颜色不能跟页面底一样。 assert.equal(wide.edge, '1px', '垃圾卡没有描边'); assert.notEqual(wide.line, wide.page, '垃圾卡的边和页面底色同色'); assert.equal(new Set(wide.keys.map((key) => key.width)).size, 1, `三颗键不等宽:${wide.keys.map((key) => key.width)}`); for (const key of wide.keys) { assert.equal(key.justify, 'center', `${key.action} 的图标与标签没有居中`); assert.equal(key.height, 36, `${key.action} 高 ${key.height}`); } const dispose = wide.keys.find((key) => key.action === 'dispose')!; assert.equal(dispose.face, red, '移入回收站静止态不是实底红'); assert.equal(dispose.ink, 'rgb(255, 255, 255)'); for (const key of wide.keys.filter((one) => one.action !== 'dispose')) assert.equal(key.border, '1px', `${key.action} 没有描边`); const current = wide.links.filter((link) => link.current); assert.equal(current.length, 1); // 分类条是首页筛选条那一副:键自己不铺底,当前那一类底下垫的是那块滑动玻璃。 assert.ok(wide.links.every((link) => link.face === 'rgba(0, 0, 0, 0)'), '分类键自己铺了底,不是首页那副滑动玻璃'); assert.deepEqual(wide.glide, current[0]!.box, '滑动玻璃没有落在当前那一类上'); // 紧凑密度下键上只剩图标。 await page.evaluate(() => { document.body.dataset.density = 'dense' }); assert.ok((await read()).keys.every((key) => key.label === 'none'), '紧凑密度下键上还有字'); await page.evaluate(() => { delete document.body.dataset.density }); // 手机上够手指点:分类与三颗键都到 44px。 await page.setViewportSize({ width: MOBILE.width, height: MOBILE.height }); const narrow = await read(); assert.ok(narrow.keys.every((key) => key.height >= 44), `手机上键高 ${narrow.keys.map((key) => key.height)}`); assert.ok(narrow.links.every((link) => link.height >= 44), `手机上分类高 ${narrow.links.map((link) => link.height)}`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('关注列表工具行里的主动作、版式开关、排序框和方向键同高', { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser); try { const page = opened.page; const controls = { 检查全部: page.locator('button[aria-label="检查全部"]'), // 两枚图标键拼成一组,对齐的是整组的外框,不是组里单个键。 版式开关: page.locator('[aria-label="关注列表版式"]'), 排序框: page.locator('button[aria-label="关注列表排序"]'), 方向键: page.locator('button[aria-label^="按检查时间"]'), }; const heights: Record = {}; for (const [name, control] of Object.entries(controls)) { heights[name] = (await control.boundingBox())!.height; } assert.equal(new Set(Object.values(heights)).size, 1, `同一排控件高度不一:${JSON.stringify(heights)}`); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); for (const viewport of [DESKTOP, MOBILE]) { it(`关注列表骨架的偏好控件和接管后同一副长相,等数据的操作键是禁用态(${viewport.name})`, { timeout: 60_000 }, async () => { /* 骨架与真页面各量一遍。版式、排序和方向是这台浏览器的偏好,骨架里就是最终那一档; 检查全部、全部收起和行尾的移除键要等名单,骨架里是禁用态,只有尺寸与接管后一致。 窄屏上工具行收成纯图标,两边按同一条线收,尺寸也要对得上。 */ const opened = await visit(browser, '/subscriptions', viewport); try { const page = opened.page; await stubFollowManage(page); const release = await holdApi(page); await page.reload({ waitUntil: 'load' }); await page.locator('[data-skeleton="board/subscriptions"] .ui-follow-skeleton-toolbar').waitFor({ timeout: 15_000 }); const toolbar = '[data-skeleton] .ui-follow-skeleton-toolbar'; const skeleton = await controlFaces(page, { 检查全部: `${toolbar} [data-follow-toolbar-actions] > button:nth-of-type(1)`, 默认视图: `${toolbar} [data-button-group] > button:first-child`, 表格视图: `${toolbar} [data-button-group] > button:last-child`, 排序框: `${toolbar} button[aria-haspopup="listbox"]`, 方向键: `${toolbar} [data-follow-toolbar-controls] > span:last-of-type > button:last-child`, 全部收起: `${toolbar} [data-follow-toolbar-controls] > button`, 移除来源: '[data-skeleton] .follow-skeleton-source [data-follow-source-controls] > span:last-child > button:last-child', }); const expected = await disabledTokens(page); const waiting = await waitingActionFaces(page, '[data-skeleton] [data-skeleton-action]'); assert.ok(waiting.length >= 3, '关注列表骨架里没有标出等数据的操作键'); for (const face of waiting) assertDisabledFace(face, expected); release(); await page.locator('section[aria-label="kou 的关注来源"]').waitFor({ timeout: 15_000 }); await settle(page); const final = await controlFaces(page, { 检查全部: 'button[aria-label="检查全部"]', 默认视图: '[aria-label="关注列表版式"] > button:first-child', 表格视图: '[aria-label="关注列表版式"] > button:last-child', 排序框: 'button[aria-label="关注列表排序"]', 方向键: 'button[aria-label^="按检查时间"]', 全部收起: 'button[aria-label="全部收起"]', 移除来源: '[data-source-divider] [data-follow-source-controls] > span:last-child > button:last-child', }); const waitsForData = new Set(['检查全部', '全部收起', '移除来源']); for (const name of Object.keys(final)) { assert.ok(final[name], `接管后找不到 ${name}`); if (waitsForData.has(name)) { assert.equal(skeleton[name]!.size, final[name]!.size, `${name} 接管时尺寸跳了`); continue; } assert.deepEqual(skeleton[name], final[name], `${name} 在骨架里和接管后长得不一样`); } assert.equal(final['默认视图']!.pressed, 'true', '默认版式下选中的不是网格那颗'); } finally { await opened.close(); } }); } /* 整行都能点选之后,指着哪一行得看得出来。卡片视图的来源行和表格行走同一条规则;选中行 有自己的底色,指着它时不换。 */ it('关注列表指着哪一行哪一行换底色,两种视图都是,选中行的底色不被 hover 盖掉', { timeout: 60_000 }, async () => { const opened = await openFollowManage(browser); try { const page = opened.page; const background = (locator: Locator) => locator.evaluate((element) => { for (const animation of element.getAnimations()) animation.finish(); return getComputedStyle(element).backgroundColor; }); /* 勾选框的 input 视觉隐藏在画出来的方框底下,点它要 force,和上面选中底色那条用例一样。 */ const select = () => page.getByRole('checkbox', { name: '选择 kou · Kemono' }) .check({ force: true, timeout: 5_000 }); const card = page.locator('[data-source-divider] > div').first(); const cardRested = await background(card); await card.hover(); assert.notEqual(await background(card), cardRested, '指着卡片视图的来源行时底色没换'); await select(); const cardSelectedHovered = await background(card); await page.mouse.move(0, 0); const cardSelectedRested = await background(card); assert.equal(cardSelectedHovered, cardSelectedRested, '卡片视图选中行指着时换了底色,盖掉了选中态'); assert.notEqual(cardSelectedRested, cardRested, '卡片视图选中行没有自己的底色'); await page.locator('button[aria-label="表格视图"]').click({ timeout: 5_000 }); const row = page.locator('[data-board-data-table] tbody tr').first(); await row.waitFor({ timeout: 15_000 }); await page.mouse.move(0, 0); const selectedRested = await background(row); await row.hover(); assert.equal(await background(row), selectedRested, '表格视图选中行指着时换了底色,盖掉了选中态'); await page.getByRole('checkbox', { name: '选择 kou · Kemono' }).uncheck({ force: true, timeout: 5_000 }); await page.mouse.move(0, 0); const rested = await background(row); assert.notEqual(selectedRested, rested, '表格视图选中行没有自己的底色'); await row.hover(); assert.notEqual(await background(row), rested, '指着表格行时底色没换'); } finally { await opened.close(); } }); });