/* 关注详情岛(`follow-detail`)在真浏览器里的行为:从列表进出、后退回详情再关掉保住筛选、深链、舞台在媒体框里挂 * Video.js、多图轮播、合集与多媒体两种队列、写操作与撤销、隐藏与恢复、标签回列表、手机。 * 灯箱收到整组图那一条在 `photo-lightbox.test.ts`。 * * 桩数据见 `follow-fixture.ts`(`DETAIL` 那几种形态只经单条取数交回);关注来源的真实抓取一次都不发。 */ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { Browser, Page } from 'playwright-core'; import { DETAIL, openFollowFeed } from './follow-fixture.ts'; import { layout, launch, VIEWPORTS } from './harness.ts'; const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!; const MOBILE = VIEWPORTS.find((viewport) => viewport.mobile)!; const DETAIL_READY = '#stage[open] [data-follow-detail-side]'; /** 桩里的视频没有正片,播放器拿到空响应会报一条 VIDEOJS 错误;量的不是它。 */ const withoutPlayer = (problems: string[]) => problems.filter((line) => !line.includes('VIDEOJS')); const pathIs = (page: Page, path: string) => page.waitForFunction( (wanted) => location.pathname === wanted, path, { timeout: 10_000 }); /** 记下这一页之后发出的关注列表请求与单条取数,分开数。 */ function watchFollowRequests(page: Page) { const seen = { list: 0, item: [] as string[] }; page.on('request', (request) => { const url = new URL(request.url()); if (url.pathname !== '/api/follow') return; if (url.searchParams.has('item')) seen.item.push(url.searchParams.get('item')!); else seen.list += 1; }); return seen; } /** 换条前记下舞台节点并盯住骨架:原地换条时浮窗还是同一个节点,中途不回骨架,焦点留在浮窗里。 * 顺带盯新播放器:Peach 控件(提示圆是其中一枚)装上之前,Video.js 原装的控制条与大播放键不该露出来。 */ const watchStage = (page: Page) => page.evaluate(() => { const watch = { stage: document.getElementById('stage'), skeleton: false, rawControls: false }; const shown = (node: Element | null) => !!node && getComputedStyle(node).display !== 'none' && getComputedStyle(node).visibility !== 'hidden'; new MutationObserver(() => { if (document.querySelector('#stage [data-skeleton="detail"]')) watch.skeleton = true; for (const player of document.querySelectorAll('#stage div.video-js:not(:has(.vjs-peach-bezel))')) { if (shown(player.querySelector('.vjs-control-bar')) || shown(player.querySelector('.vjs-big-play-button'))) watch.rawControls = true; } }).observe(document.body, { childList: true, subtree: true }); Object.assign(window, { stageWatch: watch }); }); const stageKept = (page: Page) => page.evaluate(() => { const watch = (window as unknown as { stageWatch: { stage: Element | null; skeleton: boolean } }).stageWatch; return { same: document.getElementById('stage') === watch.stage, skeleton: watch.skeleton, focused: !!watch.stage?.contains(document.activeElement) }; }); const openDetail = (browser: Browser, id: number, viewport = DESKTOP) => openFollowFeed(browser, `/posts/${id}`, viewport, { ready: DETAIL_READY }); describe('关注详情岛', () => { let browser: Browser; before(async () => { browser = await launch(); }); after(async () => { await browser.close(); }); for (const viewport of VIEWPORTS) { it(`详情标签选择器进入顶层并在可用空间内滚动(${viewport.name})`, async () => { const opened = await openDetail(browser, 1002, viewport); try { const { page } = opened; await page.locator('#stage [data-tag-plus]').click(); const menu = page.locator('#stage [data-tag-picker]'); await menu.waitFor({ state: 'visible' }); const visible = await menu.evaluate(node => { const r = node.getBoundingClientRect(); return { topLayer: node.matches(':popover-open'), top:r.top, bottom:r.bottom, right:r.right, height:r.height, screen:[innerWidth,innerHeight], inside:node.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)) }; }); assert.equal(visible.topLayer, true); assert.ok(visible.inside && visible.height > 50 && visible.top >= 0 && visible.bottom <= visible.screen[1]); assert.ok(visible.right <= visible.screen[0]); const appearance = await menu.evaluate(node => { const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--surface)'; node.append(probe); const expected = getComputedStyle(probe).backgroundColor; probe.remove(); return { background:getComputedStyle(node).backgroundColor, expected, blur:getComputedStyle(node).backdropFilter, icons:[...node.querySelectorAll('svg')].map(svg => ({ fill:getComputedStyle(svg).fill, stroke:getComputedStyle(svg).stroke })) }; }); assert.equal(appearance.background, appearance.expected); assert.equal(appearance.blur, 'none'); assert.ok(appearance.icons.every(icon => icon.fill === 'none' && icon.stroke !== 'none')); const scrollbar = await menu.locator('[data-tag-body]').evaluate(node => ({ hidden:getComputedStyle(node).scrollbarWidth, track:!!node.parentElement?.querySelector('.ovtrack.ov-y'), positioned:getComputedStyle(node.parentElement!).position, })); assert.equal(scrollbar.hidden, 'none'); assert.equal(scrollbar.track, true); assert.equal(scrollbar.positioned, 'relative'); } finally { await opened.close(); } }); it(`在线影院模式保留可见播放器和同一媒体节点(${viewport.name})`, async () => { const opened = await openDetail(browser, 1002, viewport); try { const { page } = opened; const player = page.locator('#stage .video-js'); await player.locator('.vjs-peach-bezel').waitFor({ state: 'attached' }); const node = await player.elementHandle(); const normal = await player.boundingBox(); assert.ok(normal && normal.height > 100); await page.locator('#stage [data-player-theater]').dispatchEvent('click'); await page.locator('#stage[data-theater]').waitFor(); const scroll = page.locator('#stage > [data-stage-scroll]'); await page.waitForFunction(() => document.querySelector('#stage > [data-stage-scroll]')?.hasAttribute('data-overlay-scrollbar')); assert.equal(await scroll.evaluate(node => getComputedStyle(node).scrollbarWidth), 'none'); assert.equal(await page.locator('#stage > .ovtrack.ov-y').count(), 1); await page.waitForFunction(() => { const region=document.querySelector('#stage > [data-stage-scroll]')!; return region.scrollHeight<=region.clientHeight+1 || !document.querySelector('#stage > .ovtrack.ov-y')?.hidden; }); const cinema = await player.boundingBox(); assert.ok(cinema && cinema.height > 100, `影院画面不可见:${JSON.stringify(cinema)}`); assert.equal(await node!.evaluate(element => element === document.querySelector('#stage .video-js')), true); await page.locator('#stage [data-player-theater]').dispatchEvent('click'); const restored = await player.boundingBox(); assert.ok(restored && Math.abs(restored.height - normal.height) < 2); } finally { await opened.close(); } }); } it('列表点卡进详情:地址换成这一条,不再单条取数;关掉回列表不重取', { timeout: 60_000 }, async () => { const opened = await openFollowFeed(browser, '/follow', DESKTOP); try { const page = opened.page; const seen = watchFollowRequests(page); await page.locator('[data-follow-item="1002"] [data-follow-open]').click(); await pathIs(page, '/posts/1002'); await page.locator(DETAIL_READY).waitFor(); assert.equal((await page.locator('#stage [data-follow-detail-name]').innerText()).trim(), '演示更新 1002'); assert.deepEqual(seen.item, [], '列表缓存里有的这一条又单条取了一次'); await page.locator('[data-follow-related] [data-follow-item]').first().waitFor(); const listedBeforeClose = seen.list; await page.locator('#closeStage').click(); await pathIs(page, '/follow'); await page.locator('[data-follow-list] > [data-follow-item]').first().waitFor(); assert.equal(seen.list, listedBeforeClose, '关掉详情回列表重取了一遍'); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('筛过一档再进详情:后退回到详情再关掉,回到同一档筛选', { timeout: 60_000 }, async () => { const opened = await openFollowFeed(browser, '/follow?status=new', DESKTOP); try { const page = opened.page; const filtered = () => page.waitForFunction( () => location.pathname === '/follow' && location.search === '?status=new', undefined, { timeout: 10_000 }); await page.locator('[data-follow-item="1002"] [data-follow-open]').click(); await pathIs(page, '/posts/1002'); await page.locator(DETAIL_READY).waitFor(); await page.locator('#closeStage').click(); await filtered(); await page.goBack(); await pathIs(page, '/posts/1002'); await page.locator(DETAIL_READY).waitFor(); await page.locator('#closeStage').click(); await filtered(); await page.locator('[data-follow-list] > [data-follow-item]').first().waitFor(); assert.equal(await page.locator('#stage[open]').count(), 0); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('深链进详情只单条取这一条,不挂列表岛;内容到了焦点在关闭键上;刷新时只有浮窗里的骨架', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.collection); try { const page = opened.page; assert.equal(await page.locator('[data-follow-feed]').count(), 0, '深链进详情时挂了列表岛'); assert.equal((await page.locator('#stage [data-follow-detail-name]').innerText()).trim(), '合集主条目 5101'); assert.equal(await page.evaluate(() => document.activeElement?.id), 'closeStage'); assert.deepEqual(withoutPlayer(opened.problems), []); // 刷新这一页:等的那一下只有浮窗里的骨架,页面里不先铺一份不在浮窗里的。 await page.addInitScript(() => { const seen = { outside: false }; Object.assign(window, { detailSkeletonSeen: seen }); new MutationObserver(() => { for (const one of document.querySelectorAll('[data-skeleton="detail"]')) if (!one.closest('#stage')) seen.outside = true; }).observe(document, { childList: true, subtree: true }); }); let releaseItem = () => {}; const itemWait = new Promise((resolve) => { releaseItem = resolve; }); await page.route((url) => url.pathname === '/api/follow' && url.searchParams.has('item'), async (route) => { await itemWait; await route.fallback(); }); try { await page.reload({ waitUntil: 'load' }); const media = page.locator('#stage [data-skeleton="detail"] [data-stage-media]'); await media.waitFor(); const box = await media.boundingBox(); assert.ok(box && box.height > 120, `详情等待画面高度不足:${JSON.stringify(box)}`); assert.equal(await page.locator('#stage [data-skeleton="detail"] [data-stage-side-content]') .evaluate((node) => getComputedStyle(node).rowGap), '12px'); assert.equal(await page.locator('#stage [data-skeleton="detail"] [data-stage-side-content]') .evaluate((node) => getComputedStyle(node).scrollbarWidth), 'none'); } finally { releaseItem(); } await page.locator(DETAIL_READY).waitFor({ timeout: 15_000 }); assert.equal(await page.evaluate(() => (window as unknown as { detailSkeletonSeen: { outside: boolean } }) .detailSkeletonSeen.outside), false, '刷新时浮窗外先画了一份详情骨架'); } finally { await opened.close(); } }); it('视频:舞台在媒体框里挂上 Video.js,氛围光画布与统计角标插进同一个媒体框;换一条与后退都原地换、只剩一个播放器', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.collection); try { const page = opened.page; const frame = page.locator('#stage [data-follow-detail-media="video"]'); // 控制条在用户一段时间不动后会隐去(Video.js 的 userActive),只等它挂上,不等可见。 await frame.locator('.video-js .vjs-control-bar').waitFor({ state: 'attached' }); assert.equal(await frame.locator('[data-ambient-canvas]').count(), 1); assert.equal(await frame.locator('.video-js video.vjs-tech').count(), 1, '媒体框里的活动 video 不止一个'); await watchStage(page); const seen = watchFollowRequests(page); await page.locator('[data-follow-queue-item="5102"]').click(); await pathIs(page, '/posts/5102'); await page.locator('#stage [data-follow-queue-item="5102"][aria-current="true"]').waitFor(); await page.locator('#stage .video-js .vjs-control-bar').waitFor({ state: 'attached' }); assert.equal(await page.locator('#stage .video-js').count(), 1, '换一条之后旧播放器没拆'); assert.equal(await page.locator('#stage [data-ambient-canvas]').count(), 1); assert.deepEqual(await stageKept(page), { same: true, skeleton: false, focused: true }, '换一条重开了浮窗'); await page.goBack(); await pathIs(page, '/posts/5101'); await page.locator('#stage [data-follow-queue-item="5101"][aria-current="true"]').waitFor(); await page.locator('#stage .video-js .vjs-control-bar').waitFor({ state: 'attached' }); assert.equal(await page.locator('#stage .video-js').count(), 1, '后退之后旧播放器没拆'); assert.deepEqual(await stageKept(page), { same: true, skeleton: false, focused: true }, '后退到上一条重开了浮窗'); assert.deepEqual(seen.item, [], '组里的另一条和取过的那一条又单条取了一次,点下去要等一个来回才换'); assert.equal(await page.evaluate(() => (window as unknown as { stageWatch: { rawControls: boolean } }).stageWatch.rawControls), false, 'Peach 控件装上之前露出了 Video.js 原装的控制条或大播放键'); await page.locator('#closeStage').click(); await pathIs(page, '/follow'); await page.locator('[data-follow-list] > [data-follow-item]').first().waitFor({ timeout: 15_000 }); assert.equal(await page.locator('.video-js').count(), 0, '关掉详情后播放器还挂着'); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('多图轮播:圆点报当前那张,箭头与圆点换图不换地址', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.gallery); try { const page = opened.page; const current = () => page.locator('#stage [data-follow-image-dots] button').evaluateAll((dots) => dots.map((dot) => dot.getAttribute('aria-current'))); const poster = page.locator('#stage [data-follow-detail-poster]'); assert.deepEqual(await current(), ['true', 'false', 'false']); assert.equal(await poster.getAttribute('src'), '/follow-stream?id=5001&media=0'); await page.locator('#stage [data-follow-image-arrow="next"]').click(); await page.waitForFunction(() => document.querySelector('#stage [data-follow-detail-poster]') ?.getAttribute('src') === '/follow-stream?id=5001&media=1'); assert.deepEqual(await current(), ['false', 'true', 'false']); await page.locator('#stage [data-follow-image-arrow="prev"]').click(); await page.locator('#stage [data-follow-image-dots] button').nth(2).click(); await page.waitForFunction(() => document.querySelector('#stage [data-follow-detail-poster]') ?.getAttribute('src') === '/follow-stream?id=5001&media=2'); assert.deepEqual(await current(), ['false', 'false', 'true']); assert.equal(new URL(page.url()).pathname, '/posts/5001'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('合集队列:只列可播视频、新到旧,当前那条按下;另一站那条的出处是站点图标', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.collection); try { const page = opened.page; const queue = page.locator('#stage [data-follow-queue][data-queue-kind="collection"]'); const rows = await queue.locator('[data-follow-queue-item]').evaluateAll((buttons) => buttons.map((button) => ({ id: Number((button as HTMLElement).dataset.followQueueItem), current: button.getAttribute('aria-current'), icon: button.querySelector('[data-follow-queue-meta] img')?.getAttribute('alt') ?? null, }))); assert.deepEqual(rows, [ { id: 5101, current: 'true', icon: null }, { id: 5102, current: 'false', icon: null }, { id: 5103, current: 'false', icon: 'Rule34.xxx' }, ]); assert.match(await queue.locator('[data-mix-queue-head]').innerText(), /视频合集[\s\S]*3 个视频/); assert.equal(await queue.locator('[data-queue-close]').isVisible(), false, '并排布局的队列头不该有关闭键'); /* 桌面上媒体在左、详情在右,底部队列横向排开。 */ const grid = await page.evaluate(() => { const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); const media = box('#stage [data-stage-media]'), side = box('#stage [data-follow-detail-side]'); const queue = box('#stage [data-follow-queue]'); return { sideRight: side.left >= media.right - 1, queueBelow: queue.top >= Math.max(media.bottom, side.bottom) - 1, flow: getComputedStyle(document.querySelector('#stage [data-follow-queue] [data-mix-list]')!).gridAutoFlow }; }); assert.deepEqual(grid, { sideRight: true, queueBelow: true, flow: 'column' }); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('多媒体队列:一帖里的图与视频各占一行,点图换成图片不换地址', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.media); try { const page = opened.page; const queue = page.locator('#stage [data-follow-queue][data-queue-kind="media"]'); const kinds = await queue.locator('[data-follow-media-item]').evaluateAll((buttons) => buttons.map((button) => [(button as HTMLElement).dataset.mediaKind, button.getAttribute('aria-current')])); assert.deepEqual(kinds, [['video', 'true'], ['image', 'false'], ['video', 'false']]); await queue.locator('[data-follow-media-item="1"]').click(); await page.locator('#stage [data-follow-detail-media="image"] [data-follow-detail-poster]').waitFor(); assert.equal(await queue.locator('[data-follow-media-item="1"]').getAttribute('aria-current'), 'true'); assert.equal(await page.locator('#stage .video-js').count(), 0, '换成图片后播放器没拆'); assert.equal(new URL(page.url()).pathname, '/posts/5201'); // 当前是图,操作条里多一枚「隐藏这张图」。 assert.equal(await page.locator('#stage [data-follow-media-hide="1"]').count(), 1); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('保存与状态:写进桩、键态跟着变;标记已看的回执能撤销,撤销后键态回原样', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.gallery); try { const page = opened.page; const seen = page.locator('#stage [data-follow-detail-status="seen"]'); assert.equal(await seen.getAttribute('aria-pressed'), 'false'); await seen.click(); await page.locator('#stage [data-follow-detail-status="seen"][aria-pressed="true"]').waitFor(); assert.equal(await page.locator('#stage [data-follow-detail-status="new"]').count(), 1, '已看之后没有「恢复未看」'); // 回执栈在 body 下,舞台是模态 dialog,外面整页惰性,鼠标点不到撤销键;这里量的是撤销本身。 await page.locator('#toasts').getByRole('button', { name: '撤销' }).dispatchEvent('click'); await page.locator('#stage [data-follow-detail-status="seen"][aria-pressed="false"]').waitFor(); await page.locator('#stage [data-follow-detail-save]').click(); await page.locator('#stage [data-follow-detail-save][aria-label="已保存"]').waitFor(); assert.deepEqual(opened.stub.writes, [ { url: '/api/follow/status', body: { item: 5001, to: 'seen' } }, { url: '/api/follow/status', body: { item: 5001, to: 'new' } }, { url: '/api/follow/save', body: { item: 5001 } }, ]); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); for (const viewport of VIEWPORTS) it(`${viewport.name}:二十一张图的圆点可滚动并跟随当前图片,地址保持当前帖子`, { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.largeGallery, viewport); try { const page = opened.page; const poster = page.locator('#stage [data-follow-detail-poster]'); const dots = page.locator('#stage [data-follow-image-dots]'); const thumbnails = page.locator('#stage [data-media-thumbnails]'); assert.equal(await thumbnails.locator('button').count(), 21); await thumbnails.locator('img').first().waitFor({ state: 'visible' }); const thumbnailBox = await thumbnails.boundingBox(), posterBox = await poster.boundingBox(); assert.ok(thumbnailBox && posterBox && thumbnailBox.y >= posterBox.y + posterBox.height, '小图位于主图下方'); assert.equal(await dots.locator('button').count(), 21); assert.ok(await dots.evaluate(node => node.scrollWidth > node.clientWidth), '圆点超出时在条内滚动'); assert.ok(await dots.evaluate(node => { const frame = node.closest('[data-media-indicator]'); if (!frame) return false; const style = getComputedStyle(frame); return style.borderTopWidth === '0px' && style.backgroundColor === 'rgba(0, 0, 0, 0.5)' && style.borderRadius === getComputedStyle(document.documentElement).getPropertyValue('--pill-radius').trim(); }), 'indicator 使用关注详情的半透明胶囊底衬'); assert.equal(await dots.getAttribute('data-overflow-left'), 'false'); assert.equal(await dots.getAttribute('data-overflow-right'), 'true'); assert.ok(await dots.evaluate(node => getComputedStyle(node).maskImage.includes('linear-gradient')), '溢出端复用横向渐隐'); await dots.hover(); await page.mouse.wheel(0, 100); await page.waitForFunction(() => { const strip = document.querySelector('[data-follow-image-dots]'); return (strip?.scrollLeft || 0) > 1 && strip?.getAttribute('data-overflow-left') === 'true'; }); assert.equal(await dots.getAttribute('data-overflow-left'), 'true'); for (const [key, index] of [['ArrowRight', 1], ['ArrowLeft', 0], ['ArrowLeft', 20]] as const) { await page.keyboard.press(key); await page.waitForFunction((wanted) => document.querySelector('#stage [data-follow-detail-poster]') ?.getAttribute('src') === wanted, `/follow-stream?id=5002&media=${index}`); assert.equal(await poster.getAttribute('src'), `/follow-stream?id=5002&media=${index}`); const selected = dots.locator('[aria-current="true"]'); assert.equal(await selected.getAttribute('data-follow-image-item'), String(index)); assert.ok(await selected.evaluate(node => { const item = node.getBoundingClientRect(), strip = node.parentElement!.getBoundingClientRect(); return item.left >= strip.left && item.right <= strip.right; }), '当前圆点完整可见'); assert.ok(await thumbnails.locator('[aria-current="true"]').evaluate(node => { const item = node.getBoundingClientRect(), strip = node.parentElement!.getBoundingClientRect(); return item.left >= strip.left && item.right <= strip.right; }), '当前小图完整可见'); if (index === 20) assert.equal(await dots.getAttribute('data-overflow-right'), 'false'); } await page.keyboard.press('ArrowRight'); await dots.locator('[data-follow-image-item="0"][aria-current="true"]').waitFor(); assert.ok(await dots.evaluate(node => node.scrollLeft < 1), '循环回第一张时圆点回到起点'); const geometry = await layout(page); assert.ok(geometry.scrollWidth <= geometry.viewportWidth + 1); assert.equal(new URL(page.url()).pathname, '/posts/5002'); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('隐藏这张图退到恢复带,从恢复带点回来又回到轮播', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.hidden); try { const page = opened.page; const restores = () => page.locator('#stage [data-follow-media-restore]').evaluateAll((buttons) => buttons.map((button) => Number((button as HTMLElement).dataset.followMediaRestore))); const dots = () => page.locator('#stage [data-follow-image-dots] button').count(); assert.deepEqual(await restores(), [3, 4]); assert.equal(await dots(), 3); await page.locator('#stage [data-follow-media-hide="0"]').click(); await page.waitForFunction(() => document.querySelectorAll('#stage [data-follow-media-restore]').length === 3); assert.deepEqual(await restores(), [0, 3, 4]); assert.equal(await dots(), 2); assert.match(await page.locator('#stage [data-follow-hidden-label]').innerText(), /已隐藏 3 张/); await page.locator('#stage [data-follow-media-restore="0"]').click(); await page.waitForFunction(() => document.querySelectorAll('#stage [data-follow-media-restore]').length === 2); assert.equal(await page.locator('#stage [data-follow-detail-poster]').getAttribute('src'), '/follow-stream?id=5301&media=0', '恢复的那张没有回到舞台上'); assert.deepEqual(opened.stub.writes, [ { url: '/api/follow/media/hide', body: { item: 5301, media: 0, hidden: true } }, { url: '/api/follow/media/hide', body: { item: 5301, media: 0, hidden: false } }, ]); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('点详情里的标签回到带上这枚标签的列表', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.gallery); try { const page = opened.page; const tag = page.locator('#stage [data-follow-tag]').first(); const name = (await tag.getAttribute('data-follow-tag'))!; await tag.click(); await page.waitForFunction((wanted) => location.pathname === '/follow' && new URLSearchParams(location.search).get('tag') === wanted, name, { timeout: 10_000 }); await page.locator('[data-follow-list] > [data-follow-item]').first().waitFor({ timeout: 15_000 }); assert.equal(await page.locator('#stage[open]').count(), 0); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); it('手机 390:详情不横向溢出,操作条按内容定宽,合集队列排在侧栏下面', { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.collection, MOBILE); try { const page = opened.page; assert.deepEqual((await layout(page)).offenders, [], '详情里有元素越出视口右边'); const boxes = await page.evaluate(() => { const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); const actions = box('#stage [data-follow-detail-actions]'); const buttons = [...document.querySelectorAll('#stage [data-follow-detail-actions] > *')] .map((node) => node.getBoundingClientRect()); return { actions: actions.width, used: buttons.at(-1)!.right - buttons[0]!.left, side: box('#stage [data-follow-detail-side]').bottom, queue: box('#stage [data-follow-queue]').top }; }); assert.ok(Math.abs(boxes.actions - boxes.used) <= 1, `操作条被撑宽了:${JSON.stringify(boxes)}`); assert.ok(boxes.queue >= boxes.side - 1, `合集队列不在侧栏下面:${JSON.stringify(boxes)}`); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); for (const viewport of [DESKTOP, MOBILE]) { it(`没有预览的关注详情保留完整画框(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.bare, viewport); try { const { page } = opened; const placeholder = page.locator('#stage [data-follow-detail-placeholder]'); await placeholder.waitFor(); const box = await placeholder.evaluate(node => { const frame = node.closest('[data-stage-media]')!.getBoundingClientRect(); const rect = node.getBoundingClientRect(); return { width: rect.width, height: rect.height, frameWidth: frame.width, frameHeight: frame.height }; }); assert.ok(Math.abs(box.width - box.frameWidth) < 1, `空态占满媒体宽度:${JSON.stringify(box)}`); if (viewport.mobile) assert.ok(Math.abs(box.width / box.height - 16 / 9) < .02, `空态按画框比例留高:${JSON.stringify(box)}`); else assert.ok(box.height >= 180, `桌面空态完整显示图标与说明:${JSON.stringify(box)}`); assert.ok(box.frameHeight >= box.height - 1, '媒体框容纳完整空态'); assert.equal(await page.locator('#closeStage').isVisible(), true); assert.deepEqual((await layout(page)).offenders, []); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it(`长摘要收在八行内、不被侧栏压扁,长串标题与媒体报错不撑宽侧栏(${viewport.name})`, { timeout: 60_000 }, async () => { const opened = await openDetail(browser, DETAIL.long, viewport); try { const page = opened.page; const measure = () => page.evaluate(() => { const side = document.querySelector('#stage [data-stage-side-content]')!; const summary = document.querySelector('#stage [data-follow-detail-summary]')!; const lineHeight = parseFloat(getComputedStyle(summary).lineHeight); const right = side.getBoundingClientRect().right; const inside = ['[data-follow-detail-name]', '[data-follow-detail-summary]', '[data-follow-media-issue]'] .map((selector) => document.querySelector(`#stage ${selector}`)!.getBoundingClientRect().right) .every((edge) => edge <= right + 1); return { lines: summary.clientHeight / lineHeight, inside, scroll: side.scrollWidth, width: side.clientWidth }; }); const folded = await measure(); assert.ok(folded.lines >= 7.5 && folded.lines <= 8.5, `收起的摘要不是八行:${JSON.stringify(folded)}`); assert.ok(folded.inside && folded.scroll <= folded.width, `侧栏被撑宽:${JSON.stringify(folded)}`); const toggle = page.locator('#stage [data-follow-summary-toggle]'); assert.equal((await toggle.innerText()).trim(), '展开'); await toggle.click(); const open = await measure(); assert.ok(open.lines > 20, `展开后摘要没有全部露出:${JSON.stringify(open)}`); assert.ok(open.inside && open.scroll <= open.width, `展开后侧栏被撑宽:${JSON.stringify(open)}`); assert.deepEqual((await layout(page)).offenders, [], '详情里有元素越出视口右边'); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); } });