Ctrl K
ComunidadGuías…

frontend/e2e/jav-recommendations.test.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { Browser } from 'playwright-core';
import { launch, layout, settle, visit, VIEWPORTS } from './harness.ts';
import { ITEM, openItemPage } 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: 60_000 }, async () => {
    const opened = await openItemPage(browser, '/', viewport, { ready: '[data-media-grid]' });
    const { page } = opened;
    const reads: string[] = [];
    try {
      await page.route('**/api/result-updates', route => route.fulfill({ json: { result_versions: {} } }));
      await page.route('**/api/taste?*', route => route.fulfill({ json: { summary: {}, rankings: {} } }));
      await page.route('**/api/jav/discoveries?*', route => {
        reads.push(route.request().url());
        return route.fulfill({ json: { items: Array.from({ length: 24 }, (_, index) => ({
          id: index + 100, asset_id: ITEM.plain, code: `PCH-${index + 100}`, title: '探索作品',
          performers: '来源原名', performer_refs: [{ id: 42, name: '规范姓名', has_image: false }],
          has_cover: false, wanted: false, tags: ['恋足'], taste_score: 0.8, release_date: '2024-06-03',
        })) } });
      });
      await page.goto(new URL('/jav?tab=discover', page.url()).href);
      const section = page.getByRole('region', { name: '为你推荐', exact: true });
      const search = page.getByRole('searchbox', { name: '筛选推荐', exact: true });
      await section.locator('[data-jav-card]').first().waitFor();
      await page.getByRole('button', { name: '馆藏探索', exact: true }).click();
      await page.getByRole('button', { name: '按发行日期排序', exact: true }).click();
      await section.locator('[data-catalog-tag="恋足"]').click();
      await search.fill('PCH');
      await section.evaluate(node => { (node as HTMLElement).dataset.returnProbe = 'kept' });
      const count = reads.length;
      const drawer = () => page.evaluate(() => document.body.classList.contains('drawer-open'));
      const drawerOpen = await drawer();
      for (const exit of ['close', 'escape', 'back']) {
        const link = section.locator('[data-jav-card]').nth(8).locator('[data-jav-cover-box]');
        await link.scrollIntoViewIfNeeded();
        const top = await page.evaluate(() => window.scrollY);
        await link.click();
        await page.locator('#stage[open] [data-item-side]').waitFor();
        assert.equal(await section.getAttribute('data-return-probe'), 'kept');
        if (exit === 'close') await page.locator('#closeStage').click();
        else if (exit === 'escape') await page.keyboard.press('Escape');
        else await page.goBack();
        await page.waitForFunction(() => location.pathname === '/jav' && !document.querySelector('#stage[open]'));
        assert.equal(await drawer(), drawerOpen, `${exit} 关闭详情时侧栏不能跟着收起`);
        assert.equal(await search.inputValue(), 'PCH');
        assert.equal(await section.locator('[data-entity-tag="library"]').getAttribute('aria-pressed'), 'true');
        assert.equal(await section.locator('[data-catalog-tag="恋足"]').getAttribute('aria-pressed'), 'true');
        assert.equal(await section.locator('[data-entity-sort="release_date"]').getAttribute('aria-pressed'), 'true');
        assert.equal(await section.getAttribute('data-return-probe'), 'kept');
        assert.equal(reads.length, count, JSON.stringify(reads));
        await page.waitForFunction(previous => Math.abs(window.scrollY - previous) < 2, top);
      }
      assert.deepEqual(opened.problems.filter(line => !line.includes('VIDEOJS')), []);
    } finally { await opened.close() }
  });
  for (const viewport of VIEWPORTS) it(`${viewport.name}:按实体身份推荐,封面骨架占据卡片尺寸`, { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/?jav=1', viewport);
    const { page } = opened;
    let release!: () => void;
    const responseReady = new Promise<void>(resolve => { release = resolve; });
    try {
      await settle(page);
      await page.context().addInitScript(() => localStorage.setItem('peach.settings.v1', JSON.stringify({ detailAutoplay: false, theme: 'dark', javLayout: 'big' })));
      await page.route('**/api/taste?*', async route => {
        await responseReady;
        await route.fulfill({ json: { summary: { history_visits: 20 }, rankings: {
          peach_performers: [{ entity_id: 42, name: '规范姓名', score: 8 }],
        } } });
      });
      await page.route('**/api/jav/discoveries?*', async route => {
        await responseReady;
        await route.fulfill({ json: { items: [
          { id: 777, code: 'DEMO-777', title: '演示新作', performers: '來源姓名' },
          { id: 778, code: 'DEMO-778', title: '很长的作品标题用于检查截断与卡片等高'.repeat(8), performers: '來源姓名' },
          { id: 779, code: 'DEMO-779', title: '短标题', performers: '來源姓名' },
        ].map((item, index) => ({ ...item, performer_refs: [{ id: 42, name: '规范姓名', has_image: false }],
          link: 'https://example.com/work', release_date: '2010-10-01', has_cover: false, wanted: false,
          score: 1, tags: ['恋足'], reasons: ['常看的女优:规范姓名'],
          ...(index === 0 ? { has_cover: true } : index === 1 ? { cover_url: 'https://example.com/sleeve.svg' } : {}),
          poster_box: { x0: 800, y0: 50, x1: 1460, y1: 950, px: [1480, 1000], method: 'manual' },
        })) } });
      });
      const sleeve = '<svg xmlns="http://www.w3.org/2000/svg" width="1480" height="1000"><rect width="1480" height="1000" fill="#135590"/><rect x="800" y="50" width="660" height="900" fill="#f4ad43"/><text x="1050" y="500" font-size="100">FRONT</text></svg>';
      await page.route('**/cover?*', route => route.fulfill({ contentType: 'image/svg+xml', body: sleeve }));
      await page.route('**/cover-image?code=*', route => route.fulfill({ contentType: 'image/svg+xml', body: sleeve }));
      await page.goto(new URL('/jav?tab=discover', page.url()).href, { waitUntil: 'load' });
      await page.locator('[data-jav-skeleton-cover]').first().waitFor();
      const cover = await page.locator('[data-jav-skeleton-cover]').first().boundingBox();
      assert.ok(cover && cover.width >= 100 && cover.height >= 60, JSON.stringify(cover));
      assert.ok(cover && Math.abs(cover.width / cover.height - 0.75) < 0.02, '大图封面占位应与馆藏正封同一比例');
      const skeletonWidth = await page.locator('[data-skeleton="jav-discover"]').first().boundingBox();
      const skeletonCard = await page.locator('[data-jav-skeleton-card]').first().boundingBox();
      const searchBox = await page.locator('[data-index-search]').boundingBox();
      const main = await page.locator('#main').boundingBox();
      assert.ok(skeletonWidth && main && skeletonWidth.width >= main.width - 40);
      const pendingFilter = await page.locator('[data-jav-filter]').boundingBox();
      const pendingBottom = await page.locator('[data-jav-filter] [data-filter-row="bottom"]').boundingBox();
      const pendingSize = await page.locator('[data-jav-filter] [data-entity-layout]').boundingBox();
      const pendingShortcut = await page.locator('[data-jav-filter] [data-shortcuts-trigger]').boundingBox();
      assert.ok(pendingShortcut && pendingShortcut.width === 32 && pendingShortcut.height === 32, JSON.stringify(pendingShortcut));
      const tagPlaceholders = page.locator('[data-jav-filter] [data-catalog-tags] [data-skeleton="tag"]');
      assert.equal(await tagPlaceholders.count(), 6);
      const pendingTags = await tagPlaceholders.evaluateAll(nodes => nodes.map(node => {
        const style = getComputedStyle(node), box = node.getBoundingClientRect();
        return { height: box.height, y: box.y, pointer: style.pointerEvents, position: style.position, overflow: style.overflow };
      }));
      assert.ok(pendingTags.every(chip => chip.height === 32 && chip.y === pendingTags[0].y && chip.pointer === 'none'), JSON.stringify(pendingTags));
      assert.ok(pendingTags.every(chip => chip.position === 'relative' && chip.overflow === 'hidden'), '标签光效在各自占位内定位和裁切');
      const avatarShape = await page.locator('[data-jav-skeleton-avatar]').first().evaluate(node => {
        const style = getComputedStyle(node); return { width: style.width, height: style.height, radius: style.borderRadius };
      });
      assert.deepEqual(avatarShape, { width: '38px', height: '38px', radius: '50%' });
      assert.deepEqual(await page.locator('[data-jav-skeleton-actions]').first().locator('.skeleton').evaluateAll(nodes => nodes.map(node => getComputedStyle(node).height)), ['36px', '36px', '36px']);
      assert.deepEqual(await page.locator('[data-jav-skeleton-card]').first().locator('[data-jav-skeleton-text]').evaluateAll(nodes => nodes.map(node => getComputedStyle(node).height)), ['20px', '20px', '18px', '18px']);
      assert.equal(await page.locator('[data-jav-skeleton-cover]').first().evaluate(node => getComputedStyle(node).borderRadius),
        await page.locator('#main').evaluate(node => getComputedStyle(node).getPropertyValue('--surface-radius').trim()));
      assert.equal(await page.locator('[data-jav-filter] [data-entity-sep]').count(), 1);
      assert.equal(await page.locator('[data-jav-filter] [data-entity-sort]').count(), 5);
      assert.equal(await page.locator('[data-jav-filter]').getByText('正在读取', { exact: true }).count(), 0);
      await page.screenshot({ path: `../build/jav-discover-pending-${viewport.name}.png` });
      release();
      await page.getByRole('heading', { name: 'DEMO-777 演示新作', exact: true }).waitFor();
      const search = page.getByRole('searchbox', { name: '筛选推荐', exact: true });
      const cards = page.getByRole('region', { name: '为你推荐', exact: true }).locator('article');
      assert.equal(await cards.count(), 3);
      assert.equal(await cards.first().locator('[data-media-who]').textContent(), '规范姓名');
      assert.equal(await cards.first().locator('[data-media-who]').getAttribute('title'), '规范姓名');
      assert.equal(await cards.first().locator('[data-media-release]').textContent(), '2010-10-01');
      const metadata = await cards.first().locator('[data-media-meta]').boundingBox();
      const title = await cards.first().locator('h3').boundingBox();
      const face = await cards.first().locator('[data-media-avatar]').boundingBox();
      const inset = await cards.first().locator('[data-media-meta]').evaluate(node => parseFloat(getComputedStyle(node).paddingTop));
      assert.ok(metadata && title && face && title.x >= face.x + face.width && Math.abs(title.y - metadata.y - inset) < 2, JSON.stringify({ metadata, title, face, inset }));
      await page.screenshot({ path: `../build/jav-layout-${viewport.name}.png` });
      const refresh = page.locator('[data-jav-filter] [data-jav-metadata-update]');
      assert.equal(await refresh.count(), 1);
      assert.equal(await refresh.textContent(), '');
      assert.equal(await cards.first().locator('[data-jav-taste-score]').textContent(), '强烈推荐');
      const tierBox = await cards.first().locator('[data-jav-taste-score]').boundingBox();
      const tagsBox = await cards.first().locator('[data-jav-tags]').boundingBox();
      const contentTag = await cards.first().locator('[data-media-tag]').first().boundingBox();
      assert.ok(tierBox && tagsBox && contentTag && Math.abs(tierBox.y - contentTag.y) <= 4
        && tierBox.x + tierBox.width <= contentTag.x, '推荐档位与内容标签同行,排在内容标签前');
      const recommendation = cards.first().getByRole('button', { name: '推荐分 100,强烈推荐', exact: true });
      await recommendation.focus();
      await page.getByRole('tooltip').filter({ hasText: '常看的女优:规范姓名' }).waitFor({ state: 'visible' });
      await recommendation.evaluate(node => (node as HTMLButtonElement).blur());
      for (const cover of await cards.locator('img.poster').all()) await cover.scrollIntoViewIfNeeded();
      await page.waitForFunction(() => [...document.querySelectorAll<HTMLImageElement>('[data-jav-card] img.poster')].every(img => img.complete && img.naturalWidth > 0));
      await page.evaluate(() => window.scrollTo(0, 0));
      await page.screenshot({ path: `../build/jav-discover-${viewport.name}.png` });
      const frames = await cards.evaluateAll(nodes => nodes.slice(0, 2).map(node => {
        const pic = node.querySelector('[data-jav-cover-box]')!.getBoundingClientRect();
        const img = node.querySelector<HTMLImageElement>('img.poster')!;
        const bounds = img.getBoundingClientRect();
        const scale = bounds.width / img.naturalWidth;
        return { center: bounds.left + (800 + 660 / 2) * scale, expected: pic.left + pic.width / 2,
          top: bounds.top + 50 * scale, y: pic.top, height: 900 * scale, expectedHeight: pic.height,
          position: getComputedStyle(img).position, slot: getComputedStyle(img.parentElement!).position };
      }));
      for (const frame of frames) {
        assert.equal(frame.position, 'absolute');
        assert.equal(frame.slot, 'absolute');
        assert.ok(Math.abs(frame.center - frame.expected) <= 1, JSON.stringify(frame));
        assert.ok(Math.abs(frame.top - frame.y) <= 1 && Math.abs(frame.height - frame.expectedHeight) <= 1, JSON.stringify(frame));
      }
      const liveFilter = await page.locator('[data-jav-filter]').boundingBox();
      const liveBottom = await page.locator('[data-jav-filter] [data-filter-row="bottom"]').boundingBox();
      const liveSize = await page.locator('[data-jav-filter] [data-entity-layout]').boundingBox();
      const liveShortcut = await page.locator('[data-jav-filter] [data-shortcuts-trigger]').boundingBox();
      for (const [pending, live] of [[pendingFilter, liveFilter], [pendingBottom, liveBottom], [pendingSize, liveSize], [pendingShortcut, liveShortcut]]) {
        assert.ok(pending && live && Math.abs(pending.y - live.y) <= 1 && Math.abs(pending.height - live.height) <= 1, JSON.stringify({ pending, live, pendingBottom, liveBottom, pendingSize, liveSize, pendingShortcut, liveShortcut }));
      }
      const controls = await page.locator('[data-jav-filter] [data-entity-sorts]').boundingBox();
      assert.ok(liveSize && controls && liveSize.x >= controls.x && liveSize.x + liveSize.width <= controls.x + controls.width);
      assert.equal(await page.locator('[data-jav-filter] [data-catalog-tag]').first().evaluate(node => getComputedStyle(node).borderStyle), 'dashed');
      assert.equal(await tagPlaceholders.count(), 0);
      assert.equal(await page.locator('[data-jav-filter] [data-catalog-tag]').first().evaluate(node => node.getBoundingClientRect().height), pendingTags[0].height);
      const heights = await cards.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().height));
      if (!viewport.mobile) assert.ok(Math.max(...heights) - Math.min(...heights) <= 1, JSON.stringify(heights));
      if (viewport.mobile) {
        assert.ok(skeletonCard && skeletonCard.height >= Math.min(...heights) - 2
          && skeletonCard.height <= Math.max(...heights) + 2, JSON.stringify({ skeletonCard, heights }));
      } else {
        assert.ok(skeletonCard && Math.abs(skeletonCard.height - heights[0]!) <= 2, JSON.stringify({ skeletonCard, heights }));
      }
      assert.deepEqual(await page.locator('[data-index-search]').boundingBox(), searchBox, '页头筛选框骨架与接管后同一位置');
      assert.equal(await cards.nth(1).locator('h3').evaluate(node => getComputedStyle(node).webkitLineClamp), '2');
      const lookup = page.getByRole('button', { name: '查找资源', exact: true }).first();
      assert.notEqual(await lookup.evaluate(node => getComputedStyle(node).backgroundColor),
        await cards.first().getByRole('button', { name: '查找资源', exact: true }).evaluate(node => getComputedStyle(node).backgroundColor));
      const avatar = cards.first().getByRole('link', { name: '打开女优页:规范姓名', exact: true });
      assert.equal(await avatar.getAttribute('href'), '/performers/42');
      assert.equal(await avatar.locator('.ini').textContent(), '规');
      const avatarBox = await avatar.boundingBox();
      assert.ok(avatarBox && avatarBox.width === 38 && avatarBox.height === 38, JSON.stringify(avatarBox));
      if (!viewport.mobile) {
        const before = await avatar.evaluate(node => getComputedStyle(node).boxShadow);
        await avatar.hover();
        await page.waitForFunction(() => {
          const face = document.querySelector('[data-jav-card] [data-media-avatar]');
          return face && getComputedStyle(face).boxShadow !== 'none';
        });
        assert.notEqual(await avatar.evaluate(node => getComputedStyle(node).boxShadow), before);
        await cards.first().hover();
        assert.notEqual(await cards.first().evaluate(node => getComputedStyle(node).boxShadow), 'none');
      }
      const source = cards.first().getByRole('link', { name: '作品来源', exact: true });
      const buttonShape = (node: Element) => {
        const style = getComputedStyle(node);
        return { height: style.height, border: style.borderWidth, radius: style.borderRadius,
          background: style.backgroundColor };
      };
      assert.deepEqual(await source.evaluate(buttonShape),
        await cards.first().getByRole('button', { name: '查找资源', exact: true }).evaluate(buttonShape));
      assert.equal(await source.getAttribute('target'), '_blank');
      assert.equal(await source.locator('svg').count(), 1);
      await search.fill('DEMO-777');
      await search.press('Enter');
      assert.equal(await cards.count(), 1);
      await search.fill('');
      await page.waitForFunction(() => document.querySelectorAll('[aria-label="为你推荐"] article').length === 3);
      await page.locator('label:has(input[aria-label="小图"])').click();
      const smallCover = await cards.first().locator('[data-jav-cover-box]').boundingBox();
      assert.ok(smallCover && Math.abs(smallCover.width / smallCover.height - 16 / 9) < 0.02);
      assert.equal(await cards.first().locator('img.poster').evaluate(node => getComputedStyle(node).objectFit), 'contain');
      await page.locator('label:has(input[aria-label="大图"])').click();
      await cards.first().getByRole('button', { name: '恋足', exact: true }).click();
      await page.getByRole('button', { name: '清除筛选', exact: true }).click();
      assert.equal(await cards.count(), 3);
      await cards.first().getByRole('button', { name: '查找资源', exact: true }).click();
      await page.getByRole('dialog').getByRole('button', { name: '关闭资源面板', exact: true }).click();
      await page.getByRole('dialog').waitFor({ state: 'hidden' });
      await settle(page);
      assert.equal(await page.locator('[data-jav-skeleton-cover]').count(), 0);
      await search.fill('规范姓名');
      await search.press('Enter');
      await page.getByRole('heading', { name: 'DEMO-777 演示新作', exact: true }).waitFor();
      const geometry = await layout(page);
      assert.ok(geometry.scrollWidth <= geometry.viewportWidth + 1);
      assert.deepEqual(geometry.offenders, []);
      assert.deepEqual(opened.problems.filter(line => !line.includes('/api/links/check')), []);
      await cards.first().locator('[data-media-meta]').scrollIntoViewIfNeeded();
      await page.screenshot({ path: `../build/jav-card-${viewport.name}.png` });
      await page.evaluate(() => { (document.activeElement as HTMLElement)?.blur(); window.scrollTo(0, 0) });
      await page.screenshot({ path: `../build/jav-discover-${viewport.name}.png` });
    } finally { release(); await opened.close(); }
  });
});