import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { Browser } from 'playwright-core'; import { launch, layout, settle, VIEWPORTS } from './harness.ts'; import { COVER_ONLY_ITEM, openItemPage, SAMPLE_ITEM } from './item-fixture.ts'; describe('馆藏 JAV 官方预览', () => { let browser: Browser; before(async () => { browser = await launch() }); after(async () => { await browser.close() }); for (const viewport of VIEWPORTS) it(`${viewport.name}:样图切换在图片就绪前保留当前画面`, { timeout: 60000 }, async () => { const opened = await openItemPage(browser, '/', viewport, { ready: '[data-media-grid]' }); const { page } = opened; let release = () => {}; try { const waiting = new Promise(resolve => { release = resolve }); await page.route('**/api/jav/detail?*', route => route.fulfill({ json: { code: COVER_ONLY_ITEM.code, state: 'ready', preview_count: 3 } })); await page.route('**/jav-image?*', async route => { const image = new URL(route.request().url()).searchParams.get('image'); if (image === '1') await waiting; return route.fulfill({ contentType: 'image/svg+xml', body: `` }); }); await page.goto(new URL(`/assets/${COVER_ONLY_ITEM.id}`, page.url()).href); const frame = page.locator('[data-item-gallery]'); await frame.getByRole('button', { name: '预览 1', exact: true }).waitFor(); await frame.getByRole('button', { name: '预览 1', exact: true }).click(); const picture = frame.locator('img[data-jav-picture]:not([hidden])'); await picture.waitFor(); const first = await picture.getAttribute('src'); await frame.getByRole('button', { name: '预览 2', exact: true }).click(); assert.equal(await picture.getAttribute('src'), first); assert.equal(await frame.locator('[data-skeleton="image"]').count(), 0); await frame.getByRole('button', { name: '预览 3', exact: true }).click(); await page.waitForFunction(() => document.querySelector('img[data-jav-picture]:not([hidden])')?.getAttribute('src')?.includes('image=2')); release(); await frame.getByRole('button', { name: '预览 2', exact: true }).click(); await page.waitForFunction(() => document.querySelector('img[data-jav-picture]:not([hidden])')?.getAttribute('src')?.includes('image=1')); await frame.getByRole('button', { name: '预览 1', exact: true }).click(); await page.waitForFunction(() => document.querySelector('img[data-jav-picture]:not([hidden])')?.getAttribute('src')?.includes('image=0')); assert.equal(await frame.locator('[data-skeleton="image"]').count(), 0); assert.deepEqual(opened.problems, []); } finally { release(); await opened.close() } }); for (const viewport of VIEWPORTS) it(`${viewport.name}:只有封面时视频方向键控制进度,图片方向键切换媒体`, { timeout: 60000 }, async () => { const opened = await openItemPage(browser, '/', viewport, { ready: '[data-media-grid]', settings: { seekSeconds: 1 } }); const { page } = opened; try { await page.route('**/stream?*', route => route.continue({ url: new URL(`/stream?id=${process.env.PEACH_E2E_ITEM}`, page.url()).href })); await page.goto(new URL(`/assets/${COVER_ONLY_ITEM.id}`, page.url()).href); const frame = page.locator('[data-item-gallery]'); const video = frame.locator('video.vjs-tech'); await page.waitForFunction(() => (document.querySelector('[data-item-gallery] video')?.readyState || 0) >= 3); assert.equal(await frame.locator('[data-follow-image-dots] button').count(), 2); await video.evaluate(node => { const video = node as HTMLVideoElement; video.muted = true; video.pause(); video.currentTime = 0.5 }); await page.locator('#closeStage').focus(); await page.keyboard.press('ArrowRight'); assert.ok(await video.evaluate(node => Math.abs((node as HTMLVideoElement).currentTime - 1.5) < 0.1)); assert.ok(await frame.locator('.video-js').isVisible()); await page.keyboard.press('ArrowLeft'); assert.ok(await video.evaluate(node => Math.abs((node as HTMLVideoElement).currentTime - 0.5) < 0.1)); assert.ok(await frame.locator('.video-js').isVisible()); await frame.locator('[data-player-theater]').dispatchEvent('click'); await page.keyboard.press('ArrowRight'); assert.ok(await video.evaluate(node => Math.abs((node as HTMLVideoElement).currentTime - 1.5) < 0.1)); assert.ok(await frame.locator('.video-js').isVisible()); await frame.getByRole('button', { name: '查看图片', exact: true }).click(); await frame.locator('[data-catalog-picture]').waitFor(); await page.locator('#closeStage').focus(); await page.keyboard.press('ArrowRight'); assert.ok(await frame.locator('.video-js').isVisible()); assert.deepEqual(opened.problems, []); } finally { await opened.close() } }); for (const viewport of [...VIEWPORTS, { name: 'narrow', width: 320, height: 844, mobile: true }, { name: 'wide', width: 1920, height: 1080, mobile: false }]) it(`${viewport.name}:图片和正片共用圆点切换`, { timeout: 60000 }, async () => { const opened = await openItemPage(browser, '/', viewport, { ready: '[data-media-grid]' }); const { page } = opened; const samples: string[] = []; try { await page.route('**/stream?*', route => route.continue({ url: new URL(`/stream?id=${process.env.PEACH_E2E_ITEM}`, page.url()).href })); await page.route(url => ['/sample-image', '/sample-thumb'].includes(url.pathname), route => { samples.push(route.request().url()); return route.fulfill({ contentType: 'image/svg+xml', body: '' }); }); await page.goto(new URL(`/assets/${SAMPLE_ITEM.id}`, page.url()).href); const frame = page.locator('[data-item-gallery]'); await frame.waitFor({ state: 'visible' }); await page.waitForFunction(() => document.querySelector('[data-item-gallery] video')?.readyState === 4); await frame.locator('[data-media-thumbnail] img').first().waitFor({ state: 'visible' }); assert.ok(samples.some(url => new URL(url).pathname === '/sample-thumb'), '小图使用样图缩略图入口'); const stats = frame.getByRole('button', { name: '播放统计', exact: true }); await stats.waitFor({ state: 'visible' }); const statsBox = await stats.boundingBox(), toggleBox = await frame.getByRole('button', { name: '查看图片', exact: true }).boundingBox(); assert.ok(statsBox && toggleBox && toggleBox.x >= statsBox.x + statsBox.width, '图片入口与播放统计并列'); const toolbar = await frame.locator('[data-player-toolbar]').evaluate(node => { const badge = node.querySelector('#playerNet')!; badge.hidden = false; const gauge = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); badge.replaceChildren(gauge, document.createTextNode('88 KB/s')); const box = (element: Element) => { const { x, y, width, height, right } = element.getBoundingClientRect(); return { x, y, width, height, right }; }; return { area: box(node), gap: Number.parseFloat(getComputedStyle(node).columnGap), controls: ['#playerStatsBtn', '[data-jav-preview-toggle]', '#playerNet'].map(selector => box(node.querySelector(selector)!)), close: box(node.parentElement!.querySelector('[data-stage-close]')!) }; }); for (const control of toolbar.controls) { assert.equal(control.height, 40, '统计、图片入口与速率统一为 40px'); assert.ok(control.x >= toolbar.area.x - 1 && control.right <= toolbar.area.right + 1, '工具栏控件完整落在可用宽度内'); } assert.equal(toolbar.controls[0]!.y, toolbar.controls[1]!.y); assert.ok(toolbar.area.right + 6 <= toolbar.close.x, '工具栏给关闭按钮留出间隔'); const fits = toolbar.controls.reduce((sum, control) => sum + control.width, 2 * toolbar.gap) <= toolbar.area.width; assert.equal(toolbar.controls[2]!.y === toolbar.controls[0]!.y, fits, '宽度足够时同排,放不下时自然换行'); await stats.click(); await frame.locator('#playerStats').waitFor({ state: 'visible' }); await stats.click(); assert.ok(await frame.locator('#playerStats').isHidden()); const dots = frame.locator('[data-follow-image-dots]'); assert.equal(await dots.locator('button').count(), 4); const videoBox = await frame.locator('.video-js').boundingBox(), dotsBox = await dots.boundingBox(); assert.ok(videoBox && dotsBox && dotsBox.y >= videoBox.y + videoBox.height, '馆藏 indicator 位于播放器下方'); const player = frame.locator('.video-js'); const normalFrame = await frame.boundingBox(), dialog = await page.locator('#stage').boundingBox(); await player.evaluate(node => { (node as HTMLElement).dataset.theaterProbe = 'kept' }); await frame.locator('[data-player-theater]').dispatchEvent('click'); await page.locator('#stage[data-theater]').waitFor(); assert.ok(await frame.locator('[data-carousel-below]').isHidden()); const theaterBox = await player.boundingBox(), theaterFrame = await frame.boundingBox(); assert.ok(theaterBox && videoBox && theaterFrame && theaterBox.height > videoBox.height + 20); if (!viewport.mobile) assert.ok(theaterBox && videoBox && theaterBox.width > videoBox.width + 20); assert.ok(theaterBox && theaterFrame && Math.abs(theaterBox.height - theaterFrame.height) < 2); const theaterDialog = await page.locator('#stage').boundingBox(); assert.ok(theaterFrame && normalFrame && Math.abs(theaterFrame.height - normalFrame.height) < 2, '影院保留媒体区预留高度'); assert.ok(dialog && theaterDialog && Math.abs(theaterDialog.width - dialog.width) < 2 && Math.abs(theaterDialog.height - dialog.height) < 2, '影院保留详情框体尺寸'); assert.equal(await player.getAttribute('data-theater-probe'), 'kept'); await page.screenshot({ path: `../build/item-theater-${viewport.name}.png` }); await frame.locator('[data-player-theater]').dispatchEvent('click'); assert.ok(await frame.locator('[data-carousel-below]').isVisible()); const restoredBox = await player.boundingBox(); assert.ok(restoredBox && videoBox && Math.abs(restoredBox.width - videoBox.width) < 2 && Math.abs(restoredBox.height - videoBox.height) < 2); await frame.locator('video.vjs-tech').evaluate(node => { const video = node as HTMLVideoElement; video.muted = true; void video.play() }); await page.waitForFunction(() => (document.querySelector('[data-item-gallery] video')?.currentTime || 0) > 0); await frame.getByRole('button', { name: '预览 2', exact: true }).click(); await frame.locator('[data-catalog-picture]').waitFor({ state: 'visible' }); assert.match(await frame.locator('[data-catalog-picture]').getAttribute('src') || '', /n=3$/); assert.ok(await frame.locator('video.vjs-tech').evaluate(node => (node as HTMLVideoElement).paused), '查看图片暂停正片'); assert.ok(await frame.locator('.video-js').isHidden()); assert.ok(await stats.isHidden()); assert.ok(await frame.locator('#playerNet').isHidden()); assert.equal((await frame.getByRole('button', { name: '播放正片', exact: true }).boundingBox())?.height, 40); await page.keyboard.press('Tab'); await frame.getByRole('button', { name: '预览 1', exact: true }).focus(); assert.equal(await frame.locator('[data-media-thumbnails]').count(), 1); await frame.locator('[data-media-thumbnail="1"]').click(); await page.waitForFunction(() => document.querySelector('[data-catalog-picture]')?.getAttribute('src')?.endsWith('n=1')); const stripBox = await dots.boundingBox(), imageBox = await frame.locator('[data-catalog-picture]').boundingBox(); assert.ok(stripBox && imageBox && stripBox.y >= imageBox.y + imageBox.height); assert.equal(await page.locator('[data-media-preview-tip]').count(), 0); await frame.getByRole('button', { name: '预览 1', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[data-catalog-picture]')?.getAttribute('src')?.endsWith('n=1')); await frame.getByRole('button', { name: '播放正片', exact: true }).click(); await frame.locator('.video-js').waitFor({ state: 'visible' }); assert.ok(await frame.locator('video.vjs-tech').evaluate(node => (node as HTMLVideoElement).currentTime > 0), '切回正片保留位置'); await settle(page); const geometry = await layout(page); assert.ok(geometry.scrollWidth <= geometry.viewportWidth + 1); assert.deepEqual(opened.problems, []); await page.screenshot({ path: `../build/item-gallery-${viewport.name}.png` }); } finally { await opened.close() } }); for (const viewport of VIEWPORTS) it(`${viewport.name}:未登记样图时加载来源图片并保持正片实例和位置`, { timeout: 60000 }, async () => { const opened = await openItemPage(browser, '/', viewport, { ready: '[data-media-grid]' }); const { page } = opened; let release!: () => void; const pending = new Promise(resolve => { release = resolve }); let details = 0; try { await page.route('**/stream?*', route => route.continue({ url: new URL(`/stream?id=${process.env.PEACH_E2E_ITEM}`, page.url()).href })); await page.route('**/api/jav/detail?*', async route => { details++; await pending; await route.fulfill({ json: { code: COVER_ONLY_ITEM.code, state: 'ready', preview_count: 11 } }); }); await page.route('**/jav-image?*', route => { const svg = ''; return route.fulfill({ contentType: 'image/svg+xml', body: svg }); }); await page.goto(new URL(`/assets/${COVER_ONLY_ITEM.id}`, page.url()).href); const frame = page.locator('[data-item-gallery]'), player = frame.locator('.video-js'); assert.equal(await frame.getByRole('status', { name: '正在读取样图' }).count(), 0); await frame.getByRole('button', { name: '查看图片', exact: true }).click(); await frame.getByRole('status', { name: '正在读取样图' }).waitFor(); await frame.getByRole('button', { name: '播放正片', exact: true }).click(); await page.waitForFunction(() => (document.querySelector('[data-item-gallery] video')?.readyState || 0) >= 3); await player.evaluate(node => { (node as HTMLElement).dataset.galleryProbe = 'kept' }); await frame.locator('video.vjs-tech').evaluate(node => { const video = node as HTMLVideoElement; video.pause(); video.currentTime = 0.5 }); const normalFrame = await frame.boundingBox(); release(); await frame.getByRole('button', { name: '预览 11', exact: true }).waitFor(); assert.equal(await frame.locator('[data-follow-image-dots] button').count(), 13); assert.equal(await player.getAttribute('data-gallery-probe'), 'kept'); const loadedFrame = await frame.boundingBox(); assert.ok(normalFrame && loadedFrame && Math.abs(loadedFrame.height - normalFrame.height) < 2); assert.equal(await frame.locator('[data-item-samples-state]').count(), 0); await frame.getByRole('button', { name: '预览 1', exact: true }).click(); await frame.locator('[data-jav-picture]').waitFor(); assert.equal(await frame.locator('[data-jav-picture]').getAttribute('alt'), `${COVER_ONLY_ITEM.code} 预览 1`); assert.ok(await player.isHidden()); await frame.getByRole('button', { name: '播放正片', exact: true }).click(); assert.equal(await player.getAttribute('data-gallery-probe'), 'kept'); assert.ok(await frame.locator('video.vjs-tech').evaluate(node => Math.abs((node as HTMLVideoElement).currentTime - 0.5) < 0.1)); await frame.getByRole('button', { name: '预览 1', exact: true }).click(); await frame.locator('[data-jav-picture]').waitFor(); assert.equal(details, 1); assert.deepEqual(opened.problems, []); await page.screenshot({ path: `../build/item-source-gallery-${viewport.name}.png` }); } finally { release(); await opened.close() } }); it('来源样图加载失败时只在媒体区提供重试', { timeout: 60000 }, async () => { const opened = await openItemPage(browser, '/', VIEWPORTS[0]!, { ready: '[data-media-grid]' }); const { page } = opened; let attempts = 0; try { await page.route('**/api/jav/detail?*', route => { attempts++; if (attempts > 1) assert.equal(new URL(route.request().url()).searchParams.get('refresh'), '1'); return route.fulfill({ json: attempts === 1 ? { code: COVER_ONLY_ITEM.code, state: 'partial', preview_count: 0, message: '来源暂不可用' } : { code: COVER_ONLY_ITEM.code, state: 'ready', preview_count: 1 } }); }); await page.route('**/jav-image?*', route => route.fulfill({ status: 404, headers: { 'cache-control': 'no-store' } })); await page.goto(new URL(`/assets/${COVER_ONLY_ITEM.id}`, page.url()).href); const frame = page.locator('[data-item-gallery]'); const retry = frame.getByRole('button', { name: '立即重试样图', exact: true }); assert.equal(await retry.count(), 0); await frame.getByRole('button', { name: '查看图片', exact: true }).click(); await retry.waitFor(); assert.equal(await retry.getAttribute('title'), '来源暂不可用'); assert.ok(await page.locator('[data-item-side] [data-stage-side-content]').isVisible()); assert.ok(await frame.locator('.video-js').isHidden()); await frame.getByRole('button', { name: '播放正片', exact: true }).click(); assert.equal(await retry.count(), 0); await frame.locator('#playerNet').evaluate(node => { const badge = node as HTMLElement; badge.hidden = false; badge.textContent = '加载中…'; }); await frame.getByRole('button', { name: '查看图片', exact: true }).click(); await retry.click(); await frame.getByRole('button', { name: '预览 1', exact: true }).waitFor(); assert.equal(attempts, 2); } finally { await opened.close() } }); });