frontend/e2e/design-cards.test.ts
Rama master · réplica de lectura del repositorio original.
/* 设计决定:作品卡、管理页与复核。读 `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<string, { next: number; read: string[]; skipped: string[] }> };
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<void>(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<string, string>) => 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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-manage-crumb] li')];
const pressed = document.querySelector<HTMLElement>('[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<HTMLElement>('[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<HTMLInputElement>('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 });
// 分类是 `<a href>`,普通左键由壳改地址重读:整页重载的话,页面上记的这个标记会丢。
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<string, number> = {};
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();
}
});
});