/* 作品详情岛(`item-detail`)在真浏览器里的行为:从目录卡进出、深链、后退回到详情与刷新后关掉回哪一页、 * 播放列表换条后关掉回列表页、舞台在媒体框里挂 Video.js、 * 脱盘说明、共演收起、标题折叠、评分与标签的撤销、反馈键态、四种队列、播放列表排序与移出、保存 Mix、接着看、 * 实体页入口与手机布局。 * * 桩数据见 `item-fixture.ts`;读写接口全部由桩接住,演示库数据库一次都不写。 */ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { Browser, Page } from 'playwright-core'; import { layout, launch, VIEWPORTS } from './harness.ts'; import { CATALOG, EDITIONS, ITEM, NAMES, PARTS, PLAYLIST, RELATED, card as itemCard, openItemPage } from './item-fixture.ts'; const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!; const MOBILE = VIEWPORTS.find((viewport) => viewport.mobile)!; const DETAIL_READY = '#stage[open] [data-item-side]'; /** 主网格卡点标题打开分卷;竖屏带中的同条目卡点标题进入沉浸。 */ const PART_CARD = `#grid [data-media-card][data-variant="grid"][data-id="${ITEM.part}"] [data-media-text] > button[data-media-title][data-open]`; /** 桩里的片源没有正片,播放器拿到空响应会报一条 VIDEOJS 错误;量的不是它。 */ const withoutPlayer = (problems: string[]) => problems.filter((line) => !line.includes('VIDEOJS')); const pathIs = (page: Page, path: string) => page.waitForFunction( (wanted) => (wanted.includes('?') ? location.pathname + location.search : location.pathname) === wanted, path, { timeout: 10_000 }); /** 回执栈在 body 下,舞台是模态 dialog,外面整页惰性,鼠标点不到撤销键;这里量的是撤销本身。 */ const undo = (page: Page) => page.locator('#toasts').getByRole('button', { name: '撤销' }).last().dispatchEvent('click'); const current = (page: Page) => page.locator('#stage [data-queue-item][aria-current="true"]').getAttribute('data-queue-item'); const ids = (page: Page, selector: string, attribute: string) => page.locator(selector).evaluateAll( (nodes, name) => nodes.map((node) => Number(node.getAttribute(name))), attribute); /** 换条前记下舞台节点并盯住骨架:原地换条时浮窗还是同一个节点,中途不回骨架,焦点留在浮窗里。 */ const watchStage = (page: Page) => page.evaluate(() => { const watch = { stage: document.getElementById('stage'), skeleton: false, heights: [] as number[] }; const sample = () => { const media = document.querySelector('#stage [data-stage-media]'); if (media) watch.heights.push(media.getBoundingClientRect().height); if (watch.heights.length < 120) requestAnimationFrame(sample); }; sample(); new MutationObserver(() => { if (document.querySelector('#stage [data-skeleton="detail"]')) watch.skeleton = 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; heights: number[] } }).stageWatch; if (Math.max(...watch.heights) - Math.min(...watch.heights) > 1) throw new Error(`作品切换画框高度不稳定:${JSON.stringify(watch.heights)}`); return { same: document.getElementById('stage') === watch.stage, skeleton: watch.skeleton, focused: !!watch.stage?.contains(document.activeElement) }; }); describe('作品详情岛', () => { let browser: Browser; before(async () => { browser = await launch(); }); after(async () => { await browser.close(); }); it('推荐加载保持详情高度稳定,理由与悬停圆角适配默认视口', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); let release = () => {}; try { const page = opened.page; let requested!: () => void; const received = new Promise(resolve => { requested = resolve }); const waiting = new Promise(resolve => { release = resolve }); await page.route(url => url.pathname === '/api/related', async route => { requested(); await waiting; await route.fulfill({ json: { items: RELATED.map(id => ({ ...itemCard(id), why: '标签接近' })) } }); }); await page.reload(); await received; const media = page.locator('#stage [data-item-media]'); const initialHeight = await media.evaluate(node => node.getBoundingClientRect().height); release(); const reasons = '#stage [data-item-related] [aria-label^="查看推荐理由"]'; await page.locator(reasons).first().waitFor(); const visible = () => page.waitForFunction(selector => { const scroll = document.querySelector('#stage [data-stage-scroll]')!; const bottom = scroll.getBoundingClientRect().bottom; return scroll.scrollTop === 0 && [...document.querySelectorAll(selector)] .every(reason => reason.getBoundingClientRect().bottom <= bottom + 1); }, reasons); await visible(); const finalHeight = await media.evaluate(node => node.getBoundingClientRect().height); assert.ok(Math.abs(initialHeight - finalHeight) <= 1, `${initialHeight} → ${finalHeight}`); const card = page.locator('#stage [data-item-related] [data-media-card]').first(); await card.hover(); const shape = await card.evaluate(node => ({ shadow:getComputedStyle(node).boxShadow, outer:parseFloat(getComputedStyle(node,'::before').borderTopLeftRadius), inner:parseFloat(getComputedStyle(node).getPropertyValue('--surface-radius')), height:node.getBoundingClientRect().height, })); assert.equal(shape.shadow, 'none'); assert.equal(shape.outer, shape.inner + 8); assert.ok(shape.height < 220, JSON.stringify(shape)); await page.locator('#stage [data-item-related] h3').evaluate(node => { node.textContent = '接着看 '.repeat(60); }); await visible(); await page.setViewportSize({ width: 1280, height: 720 }); await visible(); assert.deepEqual((await layout(page)).offenders, []); } finally { release(); await opened.close() } }); for (const viewport of VIEWPORTS) it(`本地标签选择器复用覆盖式滚动条(${viewport.name})`, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, viewport); try { const { page } = opened; await page.locator('#stage [data-tag-plus]').evaluate(node => node.scrollIntoView({ block:'center', behavior:'instant' })); await page.locator('#stage [data-tag-plus]').click(); const menu = page.locator('#stage [data-tag-picker]'); await menu.waitFor({ state:'visible' }); 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.deepEqual(scrollbar, { hidden:'none', track:true, positioned:'relative' }); } finally { await opened.close(); } }); for (const kind of ['parts', 'editions'] as const) for (const count of [1, 2, 3]) { it(`${kind} 队列的 ${count} 张卡按内容分配空间,推荐卡等宽对齐`, { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, '/', DESKTOP, { ready: '#grid [data-media-card]' }); try { const page = opened.page; const seed = kind === 'parts' ? ITEM.part : ITEM.edition; const group = kind === 'parts' ? PARTS : EDITIONS; await page.route(url => url.pathname === `/api/${kind}`, route => route.fulfill({ json: { ...group, items: Array.from({ length: count }, (_, index) => ({ ...group.items[index % group.items.length], id: seed + index })), } })); await page.goto(new URL(`/assets/${seed}?${kind}=${seed}`, page.url()).toString()); await page.locator('#stage [data-queue-row]').first().waitFor(); await page.locator('#stage [data-item-related] [data-media-card]').first().waitFor(); const measured = await page.locator('[data-detail-continuation]').evaluate(node => { const queue = node.querySelector('[data-mix-queue]')!.getBoundingClientRect(); const cards = [...node.querySelectorAll('[data-queue-row]')].map(card => card.getBoundingClientRect()); const related = node.querySelector('[data-item-related] [data-media-card]')!.getBoundingClientRect(); return { width: queue.width, full: node.getBoundingClientRect().width, cards: cards.map(card => ({ width: card.width, y: card.y })), related: { width: related.width, y: related.y } }; }); const width = measured.cards[0]!.width; if (count < 3) assert.ok(Math.abs(measured.width - (count * width + (count - 1) * 10 + 28)) < 1, JSON.stringify(measured)); else assert.ok(Math.abs(measured.width - measured.full / 2) < 1, JSON.stringify(measured)); assert.ok(measured.cards.every(card => Math.abs(card.width - measured.related.width) < 1 && Math.abs(card.y - measured.related.y) < 1), JSON.stringify(measured)); if (count === 2) await page.screenshot({ path: `../build/detail-two-${kind}.png` }); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close() } }); } for (const destination of ['/tasks', '/performers']) { it(`分卷等待响应时后退到 ${destination}:舞台撤回,晚到的响应不能改变地址或历史深度`, { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, '/', DESKTOP, { ready: PART_CARD, catalogIds: [...CATALOG, ITEM.part] }); let release = () => {}; try { const page = opened.page; await page.evaluate(async (path) => { const moduleUrl = '/dist/peach-app.js'; const { peachHistory } = await import(moduleUrl); peachHistory.push(path); }, destination); await pathIs(page, destination); await page.evaluate(async () => { const moduleUrl = '/dist/peach-app.js'; (await import(moduleUrl)).peachHistory.push('/') }); await page.locator(PART_CARD).waitFor(); const depth = await page.evaluate(() => history.length); let arrived = () => {}; const requested = new Promise(resolve => { arrived = resolve }); const gate = new Promise(resolve => { release = resolve }); let finished = () => {}; const responded = new Promise(resolve => { finished = resolve }); await page.route((url) => url.pathname === '/api/parts', async route => { arrived(); await gate; try { await route.fallback() } catch (error) { assert.match(route.request().failure()?.errorText || '', /ERR_ABORTED/, String(error)) } finally { finished() } }); await page.locator(PART_CARD).click(); await requested; await page.locator('#stage[open]').waitFor(); assert.equal(await page.evaluate(() => location.pathname), '/'); assert.equal(await page.evaluate(() => history.length), depth, '队列未取齐时没有临时历史条目'); await page.goBack(); await pathIs(page, destination); await page.waitForFunction(() => !document.querySelector('#stage[open]')); const seq = await page.evaluate(async () => { const moduleUrl = '/dist/peach-app.js'; return (await import(moduleUrl)).peachHistory.navigation.seq }); release(); await responded; await page.waitForTimeout(500); assert.equal(await page.locator('#stage[open]').count(), 0); assert.equal(await page.evaluate(() => location.pathname), destination); assert.equal(await page.evaluate(() => history.length), depth); assert.equal(await page.evaluate(async () => { const moduleUrl = '/dist/peach-app.js'; return (await import(moduleUrl)).peachHistory.navigation.seq }), seq); assert.deepEqual(opened.stub.writes, []); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { release(); await opened.close() } }); } it('点分卷的同一任务里重放源页 POP:队列意图不取数、不铺舞台、不写条目', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, '/', DESKTOP, { ready: PART_CARD, catalogIds: [...CATALOG, ITEM.part] }); try { const page = opened.page; const depth = await page.evaluate(() => history.length); await page.locator(PART_CARD).evaluate(node => { (node as HTMLElement).click(); window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); }); await page.waitForTimeout(500); assert.equal(opened.stub.reads.get('/api/parts') || 0, 0); assert.equal(await page.locator('#stage[open]').count(), 0); assert.deepEqual(await page.evaluate(() => [location.pathname, history.length]), ['/', depth]); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close() } }); it('首次舞台宿主接上时重放源页 POP:包已到货的旧队列请求也不能开舞台', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, '/', DESKTOP, { ready: PART_CARD, catalogIds: [...CATALOG, ITEM.part] }); try { const page = opened.page; assert.equal(await page.locator('[data-stage-host]').count(), 0); const depth = await page.evaluate(() => history.length); await page.evaluate(() => { const append = document.body.append.bind(document.body); document.body.append = (...nodes: (Node | string)[]) => { append(...nodes); if (nodes.some(node => node instanceof HTMLElement && node.hasAttribute('data-stage-host'))) { document.body.append = append; document.body.dataset.queueStagePop = '1'; window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); } }; }); await page.locator(PART_CARD).click(); await page.locator('body[data-queue-stage-pop]').waitFor({ state: 'attached' }); await page.waitForTimeout(500); assert.equal(opened.stub.reads.get('/api/parts') || 0, 0); assert.equal(await page.locator('#stage[open]').count(), 0); assert.deepEqual(await page.evaluate(() => [location.pathname, history.length]), ['/', depth]); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close() } }); it('目录点卡进详情:地址换成这一条;关掉回列表不重取', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, '/', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` }); try { const page = opened.page; await page.emulateMedia({reducedMotion:'no-preference'}); await page.route(/\/api\/item\?/, async route => { await new Promise(resolve => setTimeout(resolve, 400)); await route.fallback(); }); await page.evaluate(() => { const heights: number[] = []; Object.assign(window, { coldCatalogHeights: heights }); const sample = () => { const dialog = document.querySelector('#stage[open]'); if (dialog) heights.push(dialog.clientHeight); if (heights.length < 180) requestAnimationFrame(sample); }; requestAnimationFrame(sample); }); const listed = opened.stub.reads.get('/api/items') || 0; const backgroundPlayState=()=>page.locator('.top .search').first().evaluate( node=>getComputedStyle(node).animationPlayState); const initialPlayState=await backgroundPlayState(); await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().click(); await pathIs(page, `/assets/${ITEM.plain}`); await page.locator(DETAIL_READY).waitFor(); const heights = await page.evaluate(() => (window as unknown as { coldCatalogHeights: number[] }).coldCatalogHeights); assert.ok(heights.length > 5 && Math.max(...heights) - Math.min(...heights) < 1, JSON.stringify(heights)); assert.ok((await backgroundPlayState()).split(',').every(state=>state.trim()==='paused')); assert.equal(await page.evaluate(() => document.activeElement?.id), 'closeStage', '内容到了焦点不在关闭键上'); await page.locator('#closeStage').click(); await pathIs(page, '/'); await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {}); assert.equal(await page.locator('#stage[open]').count(), 0); assert.equal(await backgroundPlayState(),initialPlayState,'关闭详情恢复背景动画状态'); await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor(); assert.equal(opened.stub.reads.get('/api/items') || 0, listed, '关掉详情回列表重取了一遍'); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('深链进详情:单条取这一条;关掉之后照地址补画列表', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; assert.equal(opened.stub.reads.get('/api/item'), 1); await page.locator('#closeStage').click(); await pathIs(page, '/'); await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor({ timeout: 15_000 }); assert.ok((opened.stub.reads.get('/api/items') || 0) >= 1, '深链关掉之后没有补取列表'); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('后退回到详情再关掉:回到这一条打开时压着的那一页,地址与画面一致', { timeout: 90_000 }, async () => { const opened = await openItemPage(browser, '/trash', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` }); try { const page = opened.page; const openCard = async (id: number) => { await page.locator(`#grid [data-media-card][data-id="${id}"] [data-media-title]`).first().click(); await pathIs(page, `/assets/${id}`); await page.locator(DETAIL_READY).waitFor(); }; await openCard(ITEM.plain); await page.locator('#closeStage').click(); await pathIs(page, '/trash'); await page.locator('[data-nav=""]:visible').first().click(); await pathIs(page, '/'); await page.locator(`#grid [data-media-card][data-id="${ITEM.cast}"]`).first().waitFor({ timeout: 15_000 }); await openCard(ITEM.cast); for (const path of ['/', '/trash', `/assets/${ITEM.plain}`]) { await page.goBack(); await pathIs(page, path); } await page.locator(DETAIL_READY).waitFor(); await page.locator('#closeStage').click(); await pathIs(page, '/trash'); await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {}); assert.equal(await page.locator('#stage[open]').count(), 0); await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor(); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('刷新落在详情上不读条目记的来处:下面补画目录网格,关掉回首页', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, '/trash', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` }); try { const page = opened.page; await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"] [data-media-title]`).first().click(); await pathIs(page, `/assets/${ITEM.plain}`); await page.locator(DETAIL_READY).waitFor(); assert.deepEqual(await page.evaluate(() => history.state?.usr?.backgroundLocation), { pathname: '/trash', search: '' }); await page.reload({ waitUntil: 'load' }); await page.locator(DETAIL_READY).waitFor({ timeout: 15_000 }); await page.locator('#closeStage').click(); await pathIs(page, '/'); await page.locator(`#grid [data-media-card][data-id="${ITEM.plain}"]`).first().waitFor({ timeout: 15_000 }); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('播放列表里换条再关掉回到列表页并重读;后退回到其中一条再关掉也一样', { timeout: 90_000 }, async () => { const opened = await openItemPage(browser, '/', DESKTOP, { ready: `#grid [data-media-card][data-id="${ITEM.plain}"]` }); try { const page = opened.page; let listed = 0; // 列表摘要反映成功写入的续播点,关闭后卡片与再次打开都应使用它。 await page.route((url) => url.pathname === '/api/playlists', (route) => { listed += 1; const progress = opened.stub.writes.findLast(write => write.url === '/api/playlist' && write.body.action === 'progress' && write.body.id === PLAYLIST.id); const current = Number(progress?.body.asset_id || PLAYLIST.items[0]); return route.fulfill({ json: { items: [{ id: PLAYLIST.id, name: PLAYLIST.name, item_count: PLAYLIST.items.length, current_asset_id: current, preview_asset_id: current, }] } }); }); await page.goto(new URL('/playlists', page.url()).toString(), { waitUntil: 'load' }); const card = page.locator(`[data-playlist-card="${PLAYLIST.id}"]`); const step = async (id: number) => { await page.locator(`#stage [data-queue-item="${id}"]`).click(); await pathIs(page, `/assets/${id}?playlist=${PLAYLIST.id}`); await page.locator(`#stage [data-queue-item="${id}"][aria-current="true"]`).waitFor(); }; const enter = async (resume: number) => { await card.locator('[data-mix-open]').click(); await pathIs(page, `/assets/${resume}?playlist=${PLAYLIST.id}`); await page.locator(DETAIL_READY).waitFor(); await step(PLAYLIST.items[1]!); await step(PLAYLIST.items[2]!); }; const closeToList = async (resume: number) => { const before = listed; await page.locator('#closeStage').click(); await pathIs(page, '/playlists'); await page.locator('#stage[open]').waitFor({ state: 'detached', timeout: 10_000 }).catch(() => {}); await card.waitFor(); assert.equal(await page.locator('#stage[open]').count(), 0); for (const deadline = Date.now() + 10_000; listed <= before && Date.now() < deadline;) await page.waitForTimeout(50); assert.ok(listed > before, '关掉之后列表页没有重读'); await card.locator(`img[src*="id=${resume}&"]`).first().waitFor(); }; await card.waitFor({ timeout: 15_000 }); await enter(PLAYLIST.items[0]!); await closeToList(PLAYLIST.items[2]!); await enter(PLAYLIST.items[2]!); await page.goBack(); await pathIs(page, `/assets/${PLAYLIST.items[1]}?playlist=${PLAYLIST.id}`); await page.locator(`#stage [data-queue-item="${PLAYLIST.items[1]}"][aria-current="true"]`).waitFor(); await page.locator(DETAIL_READY).waitFor(); await closeToList(PLAYLIST.items[1]!); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('舞台在媒体框里挂上 Video.js,氛围光画布与统计角标跟它进同一块媒体区', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; await page.locator('#stage [data-item-media="video"] > .video-js video.vjs-tech').waitFor({ state: 'attached' }); const shape = await page.evaluate(() => { const media = document.querySelector('#stage [data-item-media="video"]')!; return { id: media.querySelector(':scope > .video-js')!.id, canvas: !!media.querySelector(':scope > [data-ambient-canvas]'), stats: !!media.querySelector('#playerStatsBtn'), videos: media.querySelectorAll('video').length }; }); assert.deepEqual(shape, { id: 'vid', canvas: true, stats: true, videos: 1 }); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('来源不可读取:说明块代替播放器,刷新状态后显示读取结果', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.offline}`, DESKTOP); try { const page = opened.page; const gate = page.locator('#stage [data-item-gate="offline"]'); assert.match(await gate.innerText(), /媒体目录无法访问/); assert.equal(await page.locator('#stage .vjs-tech').count(), 0, '脱盘的条目挂了播放器'); await page.locator('#offlineRetry').click(); await page.locator('#offlineRetry', { hasText: '仍无法读取 · 刷新' }).waitFor(); assert.deepEqual(opened.problems, []); } finally { await opened.close(); } }); for (const viewport of [DESKTOP, MOBILE]) { it(`${viewport.mobile ? '手机' : '桌面'}来源状态:权限原因可见,未取得状态不判脱盘`, { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.offline}`, viewport); try { const page = opened.page; let online: boolean | null = false; await page.route((url) => url.pathname === '/api/sources', (route) => route.fulfill({ json: { sources: [{ location: 'local', online: true }, { location: '115', online, state: online === false ? 'permission_denied' : 'timeout', message: online === false ? 'CloudDrive · 115:没有权限读取' : 'CloudDrive · 115:探测未返回' }], } })); await page.reload({ waitUntil: 'load' }); await page.locator(DETAIL_READY).waitFor(); assert.match(await page.locator('#stage [data-item-gate="offline"]').innerText(), /没有权限读取/); online = null; await page.reload({ waitUntil: 'load' }); await page.locator(DETAIL_READY).waitFor(); assert.equal(await page.locator('#stage [data-item-gate="offline"]').count(), 0); assert.equal(await page.locator('body.offline-source').count(), 0); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); } for (const viewport of [DESKTOP, MOBILE]) { it(`${viewport.name}:出演超过 8 位先收起,点「还有 N 位」全部展开`, { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.cast}`, viewport); try { const page = opened.page; const shown = () => page.locator('#stage [data-item-identity] [data-entity-kind="performer"]:visible').count(); assert.equal(await shown(), 8); assert.equal((await page.locator('#castMore').innerText()).trim(), `还有 ${NAMES.length - 8} 位`); const button = page.getByRole('button', { name: `还有 ${NAMES.length - 8} 位` }); const appearance = await button.evaluate((node) => { const style = getComputedStyle(node); return { border: style.borderTopStyle, width: parseFloat(style.borderTopWidth), background: style.backgroundColor, icon: !!node.querySelector('svg'), height: node.getBoundingClientRect().height }; }); assert.equal(appearance.border, 'solid'); assert.ok(appearance.width >= 1); assert.notEqual(appearance.background, 'rgba(0, 0, 0, 0)'); assert.equal(appearance.icon, true); assert.ok(appearance.height >= 32); const centers = await button.evaluate((node) => { const own = node.getBoundingClientRect(); const siblings = [...node.parentElement!.querySelectorAll('[data-id-cell]:not([hidden])')] .map((cell) => cell.getBoundingClientRect()).filter((cell) => cell.top < own.bottom && cell.bottom > own.top); return { margin: getComputedStyle(node).marginTop, delta: siblings.map((cell) => own.top + own.height / 2 - cell.top - cell.height / 2) }; }); assert.equal(centers.margin, '0px', '展开按钮应在出演名单行内垂直居中'); assert.ok(centers.delta.every((delta) => Math.abs(delta) < 1), JSON.stringify(centers)); await button.focus(); await page.keyboard.press('Enter'); assert.equal(await shown(), NAMES.length); assert.equal(await page.locator('#castMore').count(), 0); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); } it('标题折成两行,真溢出才给展开键;展开键与标题文字都能展开,再点收回', { timeout: 60_000 }, async () => { const short = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { assert.equal(await short.page.locator('#stage [data-title-fold]').isHidden(), true); } finally { await short.close(); } const opened = await openItemPage(browser, `/assets/${ITEM.long}`, DESKTOP); try { const page = opened.page; const title = page.locator('#stage [data-detail-title]'); const fold = page.locator('#stage [data-title-fold]'); const lines = () => title.evaluate((node) => Math.round(node.clientHeight / parseFloat(getComputedStyle(node).lineHeight))); assert.equal(await fold.isVisible(), true); assert.equal(await lines(), 2); await fold.click(); assert.equal(await fold.getAttribute('aria-label'), '收起标题'); assert.ok(await lines() > 2, '展开之后整段标题都在'); await title.click(); assert.equal(await fold.getAttribute('aria-label'), '展开标题'); assert.equal(await lines(), 2); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('评分:点当前那一颗是撤销评分;回执的撤销写回原分', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; const value = page.locator('#detailRating [data-rating-value]'); assert.equal((await value.innerText()).trim(), '3 星'); await page.locator('#detailRating [data-rate="3"]').click(); await page.locator('#detailRating [data-rating-value]', { hasText: '未评分' }).waitFor(); assert.equal(await page.locator('#detailRating [data-on="true"]').count(), 0); await undo(page); await page.locator('#detailRating [data-rating-value]', { hasText: '3 星' }).waitFor(); assert.deepEqual(opened.stub.writes.filter((write) => write.url === '/api/feedback').map((write) => write.body), [ { id: ITEM.plain, kind: 'rate', value: 0 }, { id: ITEM.plain, kind: 'rate', value: 60 }, ]); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('标签:删一枚再撤销回来;从选择器新建一枚', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; const tag = (name: string) => page.locator(`#detailTags [data-tag="${name}"]`); await page.locator('#detailTags [data-remove-tag="剧情"]').click(); await tag('剧情').waitFor({ state: 'detached' }); await undo(page); await tag('剧情').waitFor(); await page.locator('#tagPlus').click(); await page.locator('#tagPicker:not([hidden])').waitFor(); await page.locator('#tagPickSearch').fill('新建的标签'); await page.locator('#tagPicker [data-pick="新建的标签"]', { hasText: '新建“新建的标签”' }).waitFor(); await page.locator('#tagPickSearch').press('Enter'); await tag('新建的标签').waitFor(); assert.equal(await page.locator('#tagPicker').isHidden(), true, '加完标签选择器没有收起'); assert.deepEqual(opened.stub.writes.filter((write) => write.url === '/api/item-tag').map((write) => write.body), [ { id: ITEM.plain, operation: 'remove', tag: '剧情' }, { id: ITEM.plain, operation: 'add', tag: '剧情' }, { id: ITEM.plain, operation: 'add', tag: '新建的标签' }, ]); 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 openItemPage(browser, `/assets/${ITEM.plain}`, viewport); try { const page = opened.page; const actions = page.locator('#stage [data-item-feedback] button'); const rects = await actions.evaluateAll(nodes => nodes.map(node => { const r = node.getBoundingClientRect(); return { top: r.top, width: r.width }; })); assert.equal(rects.length, 8); assert.equal(await page.locator('#stage [data-fb="cloud-download"]').count(), 0); assert.ok(Math.max(...rects.map(r => r.top)) - Math.min(...rects.map(r => r.top)) <= 1); assert.ok(rects.every(r => r.width >= 30)); await actions.first().scrollIntoViewIfNeeded(); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); await actions.first().focus(); const tip = page.locator('#board-control-tooltip'); await tip.waitFor({state:'visible'}); assert.equal(await tip.evaluate(node => node.matches(':popover-open')), true); assert.equal(await tip.evaluate(node => node.closest('dialog')?.id), 'stage'); const r = await tip.boundingBox(); assert.ok(r && r.x >= 0 && r.y >= 0 && r.x + r.width <= viewport.width && r.y + r.height <= viewport.height); await actions.first().evaluate(node => (node as HTMLElement).blur()); assert.equal(await tip.isHidden(), true); } finally { await opened.close() } }); } it('稍后看与反馈键:写完键态跟着服务端的回话变', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; await page.locator('#stageLater').click(); await page.locator('#stageLater[aria-pressed="true"]').waitFor(); await page.locator('#stage [data-kind="dislike"]').click(); await page.locator('#stage [data-kind="dislike"][aria-pressed="true"]').waitFor(); await page.locator('#stage [data-kind="seen"]').click(); await page.locator('#stage [data-kind="seen"][aria-pressed="true"]').waitFor(); assert.equal(await page.locator('#stage [data-kind="dislike"]').getAttribute('aria-pressed'), 'false', '看过与不合口味同时亮着'); assert.deepEqual(opened.stub.writes.map((write) => [write.url, write.body.kind ?? null]), [ ['/api/watch-later', null], ['/api/feedback', 'dislike'], ['/api/feedback', 'seen'], ]); 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 openItemPage(browser, '/', viewport, { catalogIds: [ITEM.edition], ready: '#grid [data-media-card]' }); try { const page = opened.page; const full = await page.evaluate(async id => (await fetch(`/api/item?id=${id}`)).json(), ITEM.edition); const repair = await page.evaluate(async () => (await fetch('/api/item?id=32')).json()); const edition_group = { key: 'PCH-031', seed_id: 31, count: 2, editions: ['1080p', '4K 修复'] }; const part_group = { key: 'PCH-031:32', seed_id: 32, count: 2 }; const files = [ { ...full, edition_group, edition_label: '1080p', version_id: 31 }, { ...repair, edition_group, part_group, edition_label: '4K 修复', version_id: 32, part_label: '1' }, { ...repair, id: 33, edition_group, part_group, edition_label: '4K 修复', version_id: 32, part_label: '2' }, ]; await page.route(url => url.pathname === '/api/item', route => { const file = files.find(row => row.id === Number(new URL(route.request().url()).searchParams.get('id'))); return file ? route.fulfill({ json: file }) : route.fallback(); }); await page.route(url => url.pathname === '/api/editions', route => route.fulfill({ json: { title: 'PCH-031', count: 2, items: files } })); await page.route(url => url.pathname === '/api/items', route => route.fulfill({ json: { items: [files[2], files[0], files[1]].map(row => ({ ...row, tags: row.tags.map((tag: { k: string }) => tag.k) })), total: 3, work_total: 1, has_more: false, } })); await page.reload({ waitUntil: 'load' }); const card = page.locator('#grid [data-media-card][data-variant="grid"]'); await card.first().waitFor(); assert.equal(await card.count(), 1); assert.deepEqual(await card.locator('[data-media-group]').allTextContents(), ['2 个版本']); await card.locator('[data-media-title]').click(); await pathIs(page, '/assets/33?editions=31'); await page.locator('#stage [data-queue-item="33"][aria-current="true"]').waitFor(); assert.equal(await page.locator('#stage [data-queue-item]').count(), 3); assert.equal(await page.locator('#stage').getByText('2 个版本', { exact: true }).count(), 1); await page.locator('#stage [data-queue-item="32"]').click(); await page.locator('#stage [data-queue-item="32"][aria-current="true"]').waitFor(); await page.locator('#stage video.vjs-tech').dispatchEvent('ended'); await pathIs(page, '/assets/33?editions=31'); await page.locator('#stage [data-queue-item="33"][aria-current="true"]').waitFor(); await page.locator('#stage video.vjs-tech').dispatchEvent('ended'); assert.equal(new URL(page.url()).pathname + new URL(page.url()).search, '/assets/33?editions=31'); const bounds = await layout(page); assert.ok(bounds.scrollWidth <= bounds.viewportWidth + 1, JSON.stringify(bounds)); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); } for (const [kind, path, next, wanted] of [ ['Mix', `/assets/${ITEM.plain}?mix=${ITEM.plain}`, 14, `/assets/14?mix=${ITEM.plain}`], ['分卷', `/assets/22?parts=${ITEM.part}`, 23, `/assets/23?parts=${ITEM.part}`], ['版本', `/assets/${ITEM.edition}?editions=${ITEM.edition}`, 32, `/assets/32?editions=${ITEM.edition}`], ['播放列表', `/assets/11?playlist=${PLAYLIST.id}`, 15, `/assets/15?playlist=${PLAYLIST.id}`], ] as const) { it(`${kind}队列:深链停在给的那一条,点另一条原地换过去且不重取队列`, { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, path, DESKTOP); try { const page = opened.page; assert.equal(await current(page), new URL(path, page.url()).pathname.split('/').at(-1)); const reads = new Map(opened.stub.reads); await watchStage(page); await page.locator(`#stage [data-queue-item="${next}"]`).click(); await pathIs(page, wanted); await page.locator(`#stage [data-queue-item="${next}"][aria-current="true"]`).waitFor(); await page.locator(DETAIL_READY).waitFor(); assert.deepEqual(await stageKept(page), { same: true, skeleton: false, focused: true }, `${kind}队列换一条重开了浮窗`); if (kind === '分卷') assert.match(await page.locator('#stage [data-detail-title]').innerText(), /第 3 卷/); for (const url of ['/api/parts', '/api/editions', '/api/related']) { assert.equal(opened.stub.reads.get(url) || 0, reads.get(url) || 0, `同一个${kind}队列里换一条又取了 ${url}`); } if (kind === '播放列表') { assert.deepEqual(opened.stub.writes.filter((write) => write.body.action === 'progress').map((write) => write.body.asset_id), [11, next], '播放列表没有记下续播位置'); } assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); } it('播放列表:拖动换顺序、移出一条,两者都能撤销', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/11?playlist=${PLAYLIST.id}`, DESKTOP); try { const page = opened.page; const order = () => ids(page, '#stage [data-queue-row]', 'data-queue-row'); const wait = (wanted: number[]) => page.waitForFunction((want) => JSON.stringify([...document .querySelectorAll('#stage [data-queue-row]')].map((row) => Number(row.getAttribute('data-queue-row')))) === want, JSON.stringify(wanted), { timeout: 10_000 }); assert.deepEqual(await order(), PLAYLIST.items); await page.locator('#stage [data-queue-row="16"]').dragTo(page.locator('#stage [data-queue-row="14"]'), { targetPosition: { x: 4, y: 2 } }); await wait([11, 16, 14, 15]); await undo(page); await wait(PLAYLIST.items); await page.locator('#stage [data-queue-remove="15"]').click(); await page.locator('[data-modal-confirm]').click(); await wait([11, 14, 16]); await undo(page); await wait(PLAYLIST.items); assert.deepEqual(opened.stub.writes.filter((write) => write.body.action !== 'progress') .map((write) => [write.body.action, write.body.asset_ids ?? write.body.asset_id]), [ ['reorder', [11, 16, 14, 15]], ['reorder', PLAYLIST.items], ['remove', 15], ['add', [15]], ['reorder', PLAYLIST.items], ]); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('保存 Mix:确认之后转去新建的那份播放列表', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}?mix=${ITEM.plain}`, DESKTOP); try { const page = opened.page; await page.locator('#stage [data-save-mix]').click(); await page.locator('[name="name"]').fill('我的 Mix'); await page.locator('[data-modal-confirm]').click(); await page.waitForFunction(() => location.pathname.startsWith('/assets/') && new URLSearchParams(location.search).get('playlist') === '8', undefined, { timeout: 10_000 }); await page.locator('#stage [data-queue-row]').first().waitFor(); assert.deepEqual(await ids(page, '#stage [data-queue-row]', 'data-queue-row'), [ITEM.plain, ...RELATED]); const created = opened.stub.writes.find((write) => write.body.action === 'create')!; assert.deepEqual(created.body, { action: 'create', name: '我的 Mix', asset_ids: [ITEM.plain, ...RELATED], source_kind: 'mix', source_seed_asset_id: ITEM.plain, }); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('接着看:一排卡按相关作品的顺序;点一张原地换到那一条', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; await page.locator('#nrow [data-media-card]').first().waitFor(); assert.deepEqual(await ids(page, '#nrow [data-media-card]', 'data-id'), RELATED); const before = await page.locator('#stage [data-detail-title]').innerText(); await watchStage(page); await page.locator(`#nrow [data-media-card][data-id="${RELATED[0]}"] [data-media-title]`).click(); await pathIs(page, `/assets/${RELATED[0]}`); await page.waitForFunction((title) => { const now = document.querySelector('#stage [data-detail-title]'); return !!now && (now as HTMLElement).innerText !== title; }, before, { timeout: 10_000 }); await page.locator(DETAIL_READY).waitFor(); assert.deepEqual(await stageKept(page), { same: true, skeleton: false, focused: true }, '接着看换一条重开了浮窗'); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); it('点女优进她的实体页,舞台收起', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; await page.route(url => url.pathname === '/api/entity', route => { assert.equal(new URL(route.request().url()).searchParams.get('id'), '1'); return route.fulfill({ json: { id: 1, kind: 'performer', canonical_name: NAMES[0], aliases: [], display_aliases: [], user_aliases: [], asset_count: 1, tags: [], related_performers: [], links: [], metadata: {}, has_image: false, has_avatar: false, representative_asset_id: null, entry_links: [], } }); }); await page.locator(`#stage [data-item-identity] [data-entity-kind="performer"][data-entity-name="${NAMES[0]}"]`).click(); await pathIs(page, '/performers/1'); await page.locator('#index [data-entity-title] h2').filter({ hasText: NAMES[0] }).waitFor(); await page.waitForFunction(() => !document.querySelector('#stage[open]'), undefined, { timeout: 10_000 }); assert.equal(await page.evaluate(() => document.body.classList.contains('entity-open')), true); } finally { await opened.close(); } }); it('取不到这一条:播放器格照 16:9 撑开,重试提示居中,不塌成一条窄条', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const page = opened.page; await page.route((url) => url.pathname === '/api/item', (route) => route.fulfill({ status: 500, json: { error: 'database is locked' } })); await page.goto(new URL('/assets/987654', page.url()).toString(), { waitUntil: 'load' }); await page.locator('#stage [data-item-placeholder] [data-media-error]').waitFor({ timeout: 15_000 }); const box = await page.evaluate(() => { const frame = document.querySelector('#stage [data-item-placeholder]')!.getBoundingClientRect(); const note = document.querySelector('#stage [data-item-placeholder] [data-media-error]')!.getBoundingClientRect(); return { ratio: frame.width / frame.height, offset: Math.abs((note.top + note.bottom) / 2 - (frame.top + frame.bottom) / 2) }; }); assert.ok(Math.abs(box.ratio - 16 / 9) < 0.02, `播放器格不是 16:9:${JSON.stringify(box)}`); assert.ok(box.offset < 2, `提示没有竖直居中:${JSON.stringify(box)}`); } finally { await opened.close(); } }); it('长标签单行省略,高度和别的标签一样', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, MOBILE); try { const page = opened.page; const heights = await page.evaluate(() => { const tag = document.querySelector('#detailTags [data-tag]') as HTMLElement; const before = tag.getBoundingClientRect().height; tag.textContent = 'customer-feedback-from-enterprise-onboarding-customer-feedback-from-enterprise-onboarding'; return { before, after: tag.getBoundingClientRect().height, wide: tag.scrollWidth > tag.clientWidth }; }); assert.equal(heights.after, heights.before, `长标签折行了:${JSON.stringify(heights)}`); assert.equal(heights.wide, true, '长标签应当被省略'); assert.deepEqual((await layout(page)).offenders, [], '长标签撑出视口'); } finally { await opened.close(); } }); it('桩片源的连续解码错误不消耗真实诊断预算,其他错误继续下传', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, DESKTOP); try { const forwarded: unknown[] = []; await opened.page.context().route((url) => url.pathname === '/api/diagnostics/client-error', (route) => { forwarded.push(route.request().postDataJSON()); return route.fulfill({ json: { recorded: false } }); }); const replies = await opened.page.evaluate(async () => { const send = async (kind: string, source_route: string, message = '夹具错误') => (await fetch('/api/diagnostics/client-error', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, message, playback: { source_route } }), })).json(); const ambient = await send('media', '/stream', '并发播放器错误'); const media = await Promise.all(Array.from({ length: 21 }, () => send('media', '/stream'))); return { ambient, media, other: await send('error', '/stream'), external: await send('media', '/external') }; }); assert.deepEqual(replies.ambient, { recorded: true }); assert.deepEqual(replies.media, Array.from({ length: 21 }, () => ({ recorded: true }))); assert.equal(opened.stub.mediaErrors.filter((sent) => sent.message === '夹具错误').length, 21); assert.deepEqual(replies.other, { recorded: false }); assert.deepEqual(replies.external, { recorded: false }); assert.deepEqual(forwarded, [ { kind: 'error', message: '夹具错误', playback: { source_route: '/stream' } }, { kind: 'media', message: '夹具错误', playback: { source_route: '/external' } }, ]); } finally { await opened.close() } }); it('手机 390:详情不横向溢出,侧栏排在播放区下面', { timeout: 60_000 }, async () => { const opened = await openItemPage(browser, `/assets/${ITEM.plain}`, MOBILE); try { const page = opened.page; assert.deepEqual((await layout(page)).offenders, [], '详情里有元素越出视口右边'); const boxes = await page.evaluate(() => ({ media: document.querySelector('#stage [data-item-media]')!.getBoundingClientRect().bottom, side: document.querySelector('#stage [data-item-side]')!.getBoundingClientRect().top, })); assert.ok(boxes.side >= boxes.media - 1, `侧栏不在播放区下面:${JSON.stringify(boxes)}`); assert.deepEqual(withoutPlayer(opened.problems), []); } finally { await opened.close(); } }); });