frontend/e2e/offscreen-cards.test.ts
Rama master · réplica de lectura del repositorio original.
/* 视口外的卡片跳过渲染:封面和元信息区交给浏览器按视口取舍,卡片盒照常排版。
*
* 这几条守的是跳过渲染的副作用:屏外卡确实被跳过、没渲染过的卡按估计高度排进来之后
* 整页高度和卡片位置不跳、Ctrl+F 仍找得到屏外卡里的字。演示库只有十来条,一屏就放完,
* 这里把目录和女优资料页的作品响应放大成一长列,编号改成互不相同,封面请求再改写回原来那条。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { Browser, Page } from 'playwright-core';
import { launch, layout, settle, visit, VIEWPORTS, type Visit } from './harness.ts';
const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;
const CLONES = 150;
const CLONE_BASE = 900_000;
const PROBE_TAG = '屏外探针';
/** 卡片里各块的选择器。作品卡归目录网格,关注卡归关注页,钩子都是
* `data-media-*`;关注卡的封面格多包一层 `[data-follow-visual]`,头像和标签是 `<span>`。 */
interface CardShape {
card: string; pic: string; meta: string; avatar: string; text: string; tags: string;
avatarButton: string; tagButton: string;
}
const MEDIA: CardShape = {
card: '[data-media-card]', pic: '[data-media-pic]', meta: '[data-media-meta]', avatar: '[data-media-avatar]',
text: '[data-media-text]', tags: '[data-media-tags]',
avatarButton: '[data-media-meta]>button[data-media-avatar]', tagButton: '[data-media-tags]>button[data-media-tag]',
};
const FOLLOW = MEDIA;
const CATALOG_CARDS = '#grid [data-media-grid]>[data-media-card]';
const ENTITY_CARDS = '#index [data-entity-grid] [data-media-grid]>[data-media-card]';
const FOLLOW_CARDS = '[data-follow-list]>[data-follow-item]';
interface CatalogPayload {
total: number;
items: Array<Record<string, unknown>>;
}
/** `/api/items` 的首页换成 `CLONES` 条:每三条有一条带标签,最后一条带一枚独有的标签给查找用。
* 带标签的那几条同时归给一位女优:演示库的作品都未归属,头像是不可聚焦的 `<span>`,归属之后
* 才是按钮,焦点环那条用例要聚焦它。
* 克隆源是去掉 `performer` 筛选之后的目录首页:演示库里没有人物实体,资料页按名字筛出来是空的。 */
async function serveLongItems(page: Page): Promise<void> {
const original = new Map<number, number>();
let baseline: CatalogPayload | undefined;
await page.route(/\/api\/items\?/, async (route) => {
const url = new URL(route.request().url());
/* 首页那条竖屏带是第一页落地之后另取、再插进网格中间的附加内容,何时到只看请求快慢。它落在
首次测量之后,整页就凭空多出一条带的高度,量的却不是估计误差。这里给它空列表,带子不插。 */
if (url.searchParams.has('orient')) {
await route.fulfill({ json: { items: [], total: 0, has_more: false } });
return;
}
if (url.searchParams.get('offset') !== '0') {
await route.continue();
return;
}
url.searchParams.delete('performer');
baseline ??= await (await route.fetch({ url: url.toString() })).json() as CatalogPayload;
const source = baseline.items.filter((item) => !item.part_group && !item.edition_group);
const items = Array.from({ length: CLONES }, (_, index) => {
const item = structuredClone(source[index % source.length]);
original.set(CLONE_BASE + index, Number(item.id));
item.id = CLONE_BASE + index;
item.tags = index === CLONES - 1 ? [PROBE_TAG] : index % 3 === 0 ? ['演示'] : [];
if (index % 3 === 0) {
Object.assign(item, { creator: '', performers: ['演示演员'], performer_total: 1,
performer_entities: [{ id: 90_100, name: '演示演员', has_image: false }] });
}
return item;
});
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ ...baseline, total: CLONES, has_more: false, items }) });
});
await page.route((url) => original.has(Number(url.searchParams.get('id'))), async (route) => {
const url = new URL(route.request().url());
url.searchParams.set('id', String(original.get(Number(url.searchParams.get('id')))));
await route.continue({ url: url.toString() });
});
}
async function openLongCatalog(browser: Browser, density = 'big'): Promise<Visit> {
const opened = await visit(browser, '/', DESKTOP);
const { page } = opened;
await serveLongItems(page);
await page.evaluate((value) => localStorage.setItem('density', value), density);
await page.reload({ waitUntil: 'load' });
await page.waitForFunction(([selector, count]) => document.querySelectorAll(selector).length >= count,
[CATALOG_CARDS, CLONES] as const, { timeout: 15_000 });
await settle(page);
return opened;
}
/** 女优资料页的作品区放大成 `CLONES` 部:资料由这里给,字段照 `/api/entity` 的形状写。 */
async function openLongEntity(browser: Browser): Promise<Visit> {
const name = '七沢みあ';
const opened = await visit(browser, '/', DESKTOP);
const { page } = opened;
await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
id: 90_001, kind: 'performer', canonical_name: name, aliases: [], display_aliases: [],
user_aliases: [], asset_count: CLONES, tags: [], related_performers: [], links: [],
metadata: {}, has_image: false, has_avatar: false, avatar_focus: null,
representative_asset_id: null, entry_links: [], feed: { following: false },
} }));
await serveLongItems(page);
await page.goto(new URL(`/performers/${encodeURIComponent(name)}`, page.url()).href, { waitUntil: 'load' });
await page.waitForFunction(([selector, count]) => document.querySelectorAll(selector).length >= count,
[ENTITY_CARDS, CLONES] as const, { timeout: 15_000 });
await settle(page);
return opened;
}
/** 关注页换成 `CLONES` 条合成的更新:演示库没有关注来源。字段照 `/api/follow` 的形状,
* 不带缩略图,不发外站请求;每三条有一条带标签,屏外卡因此有高矮两种元信息区。 */
async function openLongFollow(browser: Browser): Promise<Visit> {
const opened = await visit(browser, '/', DESKTOP);
const { page } = opened;
const source = { id: 1, provider: 'rule34xxx', provider_label: 'Rule34.xxx', ref: 'demo', label: '演示来源',
author_name: '演示作者', author_key: 'name:演示作者', official_avatar_url: null, avatar_url: null,
url: 'https://example.invalid/', semantics: 'work', enabled: true, entity_id: null, entity_name: null,
can_backfill: false, backfill_page: 0, created_at: '2026-09-01T00:00:00Z', last_checked_at: null,
last_status: 'ok', last_error: null, history_exhausted: true };
const groups = Array.from({ length: CLONES }, (_, index) => ({
release_key: `demo-${index}`, variants: [], duplicates: [], providers: ['rule34xxx'], has_wip: false,
is_release: false, newest_at: '2026-09-01T00:00:00Z',
primary: {
id: CLONE_BASE + index, provider: 'rule34xxx', provider_label: 'Rule34.xxx', resource_provider: 'rule34xxx',
source_id: 1, source_label: '演示来源', external_id: String(index), title: `演示更新 ${index}`,
author: '演示作者', summary: null, url: `https://example.invalid/${index}`, thumb_url: null,
published_at: '2026-09-01T00:00:00Z', published_precision: 'exact', version: null, duration: 60,
variant_kind: 'main', variant_label: null, status: 'new', asset_id: null, media_needs_credential: false,
media_error: null, has_media: false, media_kind: 'video', width: 1280, height: 720, media_type: null,
playable: false, media_items: [], hidden_media: [], resource_urls: [],
tags: index % 3 === 0 ? ['演示'] : [], detail_tags: [], tag_types: {},
},
}));
const body = { ok: true, sources: [source], author_aliases: [], alias_suggestions: [], suggestions: [], groups,
counts: { new: CLONES, seen: 0, saved: 0, ignored: 0 }, sort: 'new', dir: 'desc', seed: 1,
facets: { authors: [], providers: [], tags: [], works: [] }, offset: 0, limit: CLONES, has_more: false,
providers: ['rule34xxx'] };
await page.route(/\/api\/follow(\?|$)/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify(body) }));
await page.goto(new URL('/follow', page.url()).toString(), { waitUntil: 'load' });
await page.waitForFunction(([selector, count]) => document.querySelectorAll(selector).length >= count,
[FOLLOW_CARDS, CLONES] as const, { timeout: 15_000 });
await settle(page);
return opened;
}
/** 一屏一屏滚到底再滚回顶,每步等两帧,让每张卡都至少渲染过一次。 */
function roundTrip(page: Page): Promise<void> {
return page.evaluate(async () => {
const frame = () => new Promise(requestAnimationFrame);
for (const direction of [1, -1]) {
for (let step = 0; step < 500; step++) {
const y = scrollY;
scrollBy(0, direction * innerHeight * .9);
await frame();
await frame();
if (scrollY === y) break;
}
}
});
}
describe('视口外的卡片', () => {
let browser: Browser;
before(async () => {
browser = await launch();
});
after(async () => {
await browser.close();
});
it('手机关注卡片按文字和标签定高,空标签区不占行', { timeout: 60_000 }, async () => {
const opened = await openLongFollow(browser);
try {
await opened.page.setViewportSize({ width: 390, height: 844 });
for (const density of ['roomy', 'dense']) {
await opened.page.evaluate(value => { document.body.dataset.density = value }, density);
const cards = await opened.page.locator(FOLLOW_CARDS).evaluateAll(nodes => nodes.slice(0, 3).map(node => {
const meta = node.querySelector('[data-media-meta]')!;
const children = [...meta.children].filter(child => getComputedStyle(child).display !== 'none')
.map(child => child.getBoundingClientRect());
const content = Math.max(...children.map(child => child.bottom)) - Math.min(...children.map(child => child.top));
return { height: meta.getBoundingClientRect().height, content };
}));
for (const card of cards) assert.ok(card.height <= card.content + 17, JSON.stringify({ density, ...card }));
}
assert.deepEqual((await layout(opened.page)).offenders, []);
} finally { await opened.close() }
});
for (const [where, open, selector] of [
['馆藏', openLongCatalog, CATALOG_CARDS],
['女优资料页', openLongEntity, ENTITY_CARDS],
] as const) {
it(`${where}:跳过封面与元信息区的渲染,Ctrl+F 仍找得到屏外卡里的字`, { timeout: 60_000 }, async () => {
const opened = await open(browser);
try {
const state = await opened.page.evaluate(([tag, cardSelector, shape]) => {
const last = [...document.querySelectorAll<HTMLElement>(cardSelector)].at(-1)!;
const parts = [last.querySelector(shape.pic)!, last.querySelector(shape.meta)!];
// 跳过的是元素的内容,元素自己仍在排版里:查它的第一个子元素。
const skipped = parts.map((part) => !part.firstElementChild!.checkVisibility({ contentVisibilityAuto: true }));
const box = last.getBoundingClientRect();
/* 侧栏的标签列表里也有这枚标签,排在文档前面;往下接着找,直到落进那张卡。 */
const find = (window as unknown as { find(text: string): boolean }).find.bind(window);
let hits = 0;
while (hits < 5 && find(tag)) {
hits++;
const node = getSelection()?.anchorNode;
if (node && last.contains(node)) break;
}
const hit = getSelection()?.anchorNode;
return {
values: parts.map((part) => getComputedStyle(part).contentVisibility),
skipped, below: box.top - innerHeight, height: box.height,
hits, inLast: !!hit && last.contains(hit),
};
}, [PROBE_TAG, selector, MEDIA] as const);
assert.deepEqual(state.values, ['auto', 'auto'], '屏外卡的封面和元信息区没有交给浏览器按视口取舍');
assert.ok(state.below > 1000, `最后一张卡离视口只有 ${state.below}px,放大的列表不够长`);
assert.deepEqual(state.skipped, [true, true], '屏外卡的封面或元信息区仍在渲染');
assert.ok(state.height > 0, '屏外卡的 getBoundingClientRect 量出了零高');
assert.ok(state.inLast, `页内查找没有落到屏外那张卡的标签上(命中 ${state.hits} 次)`);
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
}
for (const [where, open, selector, shape] of [
['馆藏', openLongCatalog, CATALOG_CARDS, MEDIA],
['馆藏密集一档', (b: Browser) => openLongCatalog(b, 'dense'), CATALOG_CARDS, MEDIA],
['女优资料页', openLongEntity, ENTITY_CARDS, MEDIA],
['关注', openLongFollow, FOLLOW_CARDS, FOLLOW],
] as const) {
it(`${where}:长距离往返之后整页高度和深处那张卡的位置不跳,屏外量到的几何与渲染后一致`, { timeout: 90_000 }, async () => {
const opened = await open(browser);
try {
const { page } = opened;
const measure = () => page.evaluate(([cardSelector, metaSelector]) => {
const cards = [...document.querySelectorAll(cardSelector)];
const deep = cards[Math.floor(cards.length * .8)];
return { height: document.documentElement.scrollHeight,
deep: deep.getBoundingClientRect().top + scrollY, y: scrollY,
view: deep.getBoundingClientRect().top,
skipping: getComputedStyle(deep.querySelector(metaSelector)!).contentVisibility,
meta: deep.querySelector(metaSelector)!.getBoundingClientRect().height };
}, [selector, shape.meta] as const);
/* 首屏之外的卡此刻都没渲染过,高度全按估计值;往返一趟之后每张都记住了自己的实际
尺寸。两次量到的差就是估计值的误差,一行差一点,累积到深处就是滚动条在跳。 */
const fresh = await measure();
assert.equal(fresh.skipping, 'auto', `${where}的屏外卡不跳过渲染,量不到估计值的误差`);
await roundTrip(page);
const rendered = await measure();
assert.ok(Math.abs(rendered.height - fresh.height) <= 1,
`整页高度从 ${fresh.height} 变成 ${rendered.height}:元信息区的估计高度与实际不符`);
assert.ok(Math.abs(rendered.deep - fresh.deep) <= 1,
`深处那张卡从 ${fresh.deep} 挪到 ${rendered.deep}`);
/* 停在深处,滚到底再回来:同一个 scrollY 下那张卡应该还在原处。 */
await page.evaluate((y) => scrollTo(0, y), rendered.deep - 300);
await page.waitForTimeout(300);
const parked = await measure();
/* 跳过渲染时量到的几何要和渲染后一致:卡里有别的子元素抢空间时,内容不参与最小尺寸的
元信息区会被压扁,整张卡的高度不变,从外面看不出来,只有量它自己才露馅。 */
assert.ok(Math.abs(fresh.meta - parked.meta) <= 1,
`屏外卡的元信息区量出 ${fresh.meta}px,渲染后是 ${parked.meta}px`);
await roundTrip(page);
await page.evaluate((y) => scrollTo(0, y), parked.y);
await page.waitForTimeout(300);
const back = await measure();
assert.equal(back.y, parked.y, '回不到原来的滚动位置');
assert.ok(Math.abs(back.view - parked.view) <= 1,
`回到同一滚动位置后那张卡在视口里从 ${parked.view} 挪到 ${back.view}`);
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
}
/* 关注卡的头像和标签是 `<span>`,不可聚焦,焦点环那一半只在作品卡上查。 */
for (const [where, open, selector, shape, focusable] of [
['馆藏', openLongCatalog, CATALOG_CARDS, MEDIA, true],
['馆藏密集一档', (b: Browser) => openLongCatalog(b, 'dense'), CATALOG_CARDS, MEDIA, true],
['女优资料页', openLongEntity, ENTITY_CARDS, MEDIA, true],
['关注', openLongFollow, FOLLOW_CARDS, FOLLOW, false],
] as const) {
it(`${where}:元信息区垫出的裁切余量不改排版、不接指针,头像和标签的焦点环整圈可见`, { timeout: 60_000 }, async () => {
const opened = await open(browser);
try {
const { page } = opened;
await page.locator(selector).first().evaluate(async (card) => {
card.scrollIntoView({ block: 'center' });
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
});
/* 元信息区四周垫内边距再用负外边距抵消,裁切边(padding box)往外扩,内容盒原地不动。
内容盒要贴着卡片左右缘、封面下方隔一道卡片行距,外边距盒的下沿就是内容盒下沿;
同一行里最高的那张卡,内容盒下面到卡片下缘(关注卡是到 `[data-follow-state]`)不留空,卡高
于是仍是封面、行距与内容三项之和。头像和文字列贴着内容盒。垫出去的那一圈伸出
卡片盒,指针落在那里不能算进这张卡。 */
const frames = await page.evaluate(([cardSelector, parts]) => {
const inFlow = (element: Element | null, step: 'previousElementSibling' | 'nextElementSibling') => {
let node = element?.[step] ?? null;
while (node && getComputedStyle(node).position === 'absolute') node = node[step];
return node;
};
return [...document.querySelectorAll(cardSelector)].filter((card) => {
const box = card.getBoundingClientRect();
return card.querySelector(`:scope>${parts.meta}`) && box.top >= 0 && box.bottom <= innerHeight;
}).map((card) => {
const meta = card.querySelector(`:scope>${parts.meta}`)!;
const style = getComputedStyle(meta);
const border = meta.getBoundingClientRect();
const content = {
left: border.left + meta.clientLeft + parseFloat(style.paddingLeft),
top: border.top + meta.clientTop + parseFloat(style.paddingTop),
right: border.left + meta.clientLeft + meta.clientWidth - parseFloat(style.paddingRight),
bottom: border.top + meta.clientTop + meta.clientHeight - parseFloat(style.paddingBottom),
};
const cardBox = card.getBoundingClientRect();
const cardStyle = getComputedStyle(card);
const gap = parseFloat(cardStyle.rowGap);
const above = inFlow(meta, 'previousElementSibling')!.getBoundingClientRect();
const below = inFlow(meta, 'nextElementSibling');
const floor = below ? below.getBoundingClientRect().top - gap
: cardBox.bottom - parseFloat(cardStyle.paddingBottom);
const mav = meta.querySelector(`:scope>${parts.avatar}`)?.getBoundingClientRect();
const mtext = meta.querySelector(`:scope>${parts.text}`)!;
const textStyle = getComputedStyle(mtext);
const textBox = mtext.getBoundingClientRect();
/* 探的是垫出来的那几圈(元信息区 8px、标签行与密集一档的文字列 4px)里面的点。 */
const tags = meta.querySelector(parts.tags)?.getBoundingClientRect();
const probes = [...[content.top + 10, (content.top + content.bottom) / 2, content.bottom - 4]
.flatMap((y) => [[cardBox.left - 2, y], [cardBox.right + 2, y]]),
[(content.left + content.right) / 2, cardBox.bottom + 2],
...(tags ? [[tags.left + 10, cardBox.bottom + 2], [cardBox.right + 2, (tags.top + tags.bottom) / 2]] : [])];
const outside = probes.map(([x, y]) => document.elementFromPoint(x, y));
return {
row: Math.round(cardBox.top), slack: floor - content.bottom,
padded: [parseFloat(style.paddingLeft), parseFloat(style.paddingTop)],
offsets: [content.left - cardBox.left, cardBox.right - content.right,
content.top - (above.bottom + gap), border.bottom + parseFloat(style.marginBottom) - content.bottom,
mav ? mav.left - content.left : 0,
textBox.top - parseFloat(textStyle.marginTop) - content.top,
content.right - (textBox.right + parseFloat(textStyle.marginRight))].map((n) => Math.round(n * 100) / 100),
background: style.backgroundColor,
claimed: outside.filter((hit) => hit && card.contains(hit)).length,
};
});
}, [selector, shape] as const);
assert.ok(frames.length > 0, `${where}没有整张落在视口里的卡`);
for (const frame of frames) {
assert.ok(frame.padded.every((value) => value > 0), `${where}的元信息区没有垫裁切余量`);
assert.ok(frame.offsets.every((value) => Math.abs(value) <= .5),
`${where}的元信息区内容盒偏离了卡片与封面:${frame.offsets.join(', ')}`);
assert.equal(frame.background, 'rgba(0, 0, 0, 0)', `${where}的元信息区有背景,垫出来的一圈会画在卡片外面`);
assert.equal(frame.claimed, 0, `${where}卡片盒外 2px 处的指针落进了这张卡`);
assert.ok(frame.slack >= -.5, `${where}的元信息区内容盒越过了卡片下缘 ${-frame.slack}px`);
}
for (const row of new Set(frames.map((frame) => frame.row))) {
const least = Math.min(...frames.filter((frame) => frame.row === row).map((frame) => frame.slack));
assert.ok(least <= .5, `${where}同一行最高的卡在元信息区下面还空着 ${least}px,卡片被撑高了`);
}
/* 焦点环画在元素外面,逐层往上找会裁切的祖先(`overflow` 不是 visible,或跳过渲染带来的
paint containment),环的外沿必须落在每一层的 padding box 以内。 */
const room = (target: string) => page.locator(`${selector} ${target}`).first().evaluate((element, cardSelector) => {
const style = getComputedStyle(element);
const ring = style.outlineStyle === 'none' ? 0 : parseFloat(style.outlineWidth) + parseFloat(style.outlineOffset);
const box = element.getBoundingClientRect();
let least = Infinity, clips = 0;
for (let clip = element.parentElement; clip && !clip.matches(cardSelector); clip = clip.parentElement) {
const clipStyle = getComputedStyle(clip);
if (clipStyle.overflow === 'visible' && clipStyle.contentVisibility !== 'auto') continue;
clips++;
const edge = clip.getBoundingClientRect();
const left = edge.left + clip.clientLeft, top = edge.top + clip.clientTop;
least = Math.min(least, box.left - ring - left, box.top - ring - top,
left + clip.clientWidth - (box.right + ring), top + clip.clientHeight - (box.bottom + ring));
}
return { focused: element.matches(':focus-visible'), ring, least, clips };
}, shape.card);
for (const target of focusable ? [shape.avatarButton, shape.tagButton] : []) {
await page.keyboard.press('Tab');
await page.locator(`${selector} ${target}`).first().focus();
const edge = await room(target);
assert.ok(edge.focused && edge.ring > 0, `${where}键盘聚焦的 ${target} 没有焦点环`);
assert.ok(edge.clips > 0, `${where}的 ${target} 上面没有会裁切的祖先:这条用例守的前提变了,改用例`);
assert.ok(edge.least >= -.5, `${where}的 ${target} 焦点环越过裁切边 ${-edge.least}px,那一截会被裁掉`);
}
assert.deepEqual(opened.problems, []);
} finally {
await opened.close();
}
});
}
});