frontend/e2e/follow-detail.test.ts
Rama master · réplica de lectura del repositorio original.
/* 关注详情岛(`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<HTMLElement>('#stage > [data-stage-scroll]')!;
return region.scrollHeight<=region.clientHeight+1 || !document.querySelector<HTMLElement>('#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<void>((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<HTMLElement>('#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();
}
});
}
});