/* 视口外的卡片跳过渲染:封面和元信息区交给浏览器按视口取舍,卡片盒照常排版。 * * 这几条守的是跳过渲染的副作用:屏外卡确实被跳过、没渲染过的卡按估计高度排进来之后 * 整页高度和卡片位置不跳、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]`,头像和标签是 ``。 */ 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>; } /** `/api/items` 的首页换成 `CLONES` 条:每三条有一条带标签,最后一条带一枚独有的标签给查找用。 * 带标签的那几条同时归给一位女优:演示库的作品都未归属,头像是不可聚焦的 ``,归属之后 * 才是按钮,焦点环那条用例要聚焦它。 * 克隆源是去掉 `performer` 筛选之后的目录首页:演示库里没有人物实体,资料页按名字筛出来是空的。 */ async function serveLongItems(page: Page): Promise { const original = new Map(); 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 { 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 { 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 { 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 { 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(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(); } }); } /* 关注卡的头像和标签是 ``,不可聚焦,焦点环那一半只在作品卡上查。 */ 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(); } }); } });