Ctrl K
ComunidadGuías…

frontend/e2e/follow-source-icons.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 关注里「这一条出自哪个站」一律用站点图标表示,站名只落在图标的 alt/title 上。
 *
 * 守两处:详情下方视频合集的队列卡(出处图标、标记与时间同一行居中),以及卡片上的
 * 「另见」徽章(连同版本字样的状态标记外观)。演示库没有关注来源,这里按 `_group_payload`(`src/peach/web_follow.py`)
 * 造一组跨站的同一条:主条目在 Fanbox,另两条在 Rule34.xxx。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

import type { Browser, Page } from 'playwright-core';

import { launch, settle, visit, VIEWPORTS, type Visit } from './harness.ts';

const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;

/** 1×1 的透明 PNG:站点图标的请求有图可落,量的是它排出来的几何。 */
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';

/** 一条可播的关注视频。字段以 `_item_payload` 为准。 */
const followVideo = (id: number, provider: string, label: string, publishedAt: string) => ({
  id, provider, provider_label: label, resource_provider: provider, source_id: 1,
  source_label: `演示 · ${label}`, external_id: String(id), title: `演示视频 ${id}`,
  author: null, summary: null, url: `https://example.com/${id}`, thumb_url: null,
  published_at: publishedAt, published_precision: 'exact', version: null, duration: 20,
  variant_kind: null, variant_label: null, status: 'new', asset_id: null,
  media_needs_credential: false, media_error: null, has_media: true, media_kind: 'video',
  width: null, height: null, playable: true, media_type: 'video/mp4', media_items: [],
  hidden_media: [], resource_urls: [], tags: [], detail_tags: [], tag_types: {},
});

const GROUP = {
  release_key: 'demo-release',
  primary: followVideo(1, 'fanbox', 'pixivFANBOX', '2026-09-16T00:20:00Z'),
  variants: [],
  duplicates: [
    followVideo(2, 'rule34xxx', 'Rule34.xxx', '2026-09-16T00:19:00Z'),
    followVideo(3, 'rule34xxx', 'Rule34.xxx', '2026-09-16T00:16:00Z'),
  ],
  providers: ['fanbox', 'rule34xxx'],
  has_wip: false,
  is_release: false,
  newest_at: '2026-09-16T00:20:00Z',
  stack: null,
};

/** 主条目在 Fanbox 却没有视频:卡面换成组里最新那条 Rule34.xxx,另见就该列 Fanbox 与 Pawchive。 */
const FRONTED = {
  ...GROUP,
  release_key: 'demo-fronted',
  primary: { ...followVideo(11, 'fanbox', 'pixivFANBOX', '2026-09-15T00:20:00Z'),
    has_media: false, media_kind: null, playable: false, duration: null },
  duplicates: [
    followVideo(12, 'rule34xxx', 'Rule34.xxx', '2026-09-15T00:19:00Z'),
    followVideo(13, 'pawchive', 'Pawchive', '2026-09-15T00:16:00Z'),
  ],
  providers: ['fanbox', 'pawchive', 'rule34xxx'],
  newest_at: '2026-09-15T00:20:00Z',
};

async function stubFollow(page: Page): Promise<void> {
  const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
  await page.route((url) => url.pathname === '/api/follow', (route) => route.fulfill(json({
    groups: [GROUP, FRONTED], sources: [], counts: { new: 2, seen: 0, saved: 0, ignored: 0 },
    facets: { authors: [], providers: ['fanbox', 'pawchive', 'rule34xxx'] }, has_more: false,
  })));
  await page.route('**/api/follow/credentials', (route) => route.fulfill(json({ providers: [] })));
  /* 服务端的定时检查可能正在跑,那一趟会让检查键挂着 aria-busy,settle 等不到头。 */
  await page.route('**/api/follow/check', (route) => (route.request().method() === 'GET'
    ? route.fulfill(json({ status: 'idle' })) : route.fallback()));
  await page.route('**/source-icon**', (route) => route.fulfill({
    status: 200, contentType: 'image/png', body: Buffer.from(PIXEL, 'base64'),
  }));
  // 造出来的条目没有正片;这一条量的是队列卡,播放器拿到空响应即可。
  await page.route('**/follow-stream**', (route) => route.fulfill({ status: 204, body: '' }));
  await page.route('**/follow-qualities**', (route) => route.fulfill(json({})));
}

async function openFollow(browser: Browser, path: string, ready: string): Promise<Visit> {
  const opened = await visit(browser, path, DESKTOP);
  await stubFollow(opened.page);
  await opened.page.reload({ waitUntil: 'load' });
  await opened.page.locator(ready).first().waitFor({ timeout: 15_000 });
  await settle(opened.page);
  return opened;
}

/** 一行里每个子盒的竖直中线与文字;站名文字是否混在行里看的是这一行自己的 textContent。 */
async function lineGeometry(page: Page, selector: string) {
  return page.locator(selector).evaluateAll((lines) => lines.map((line) => {
    const mid = (node: Element | null) => {
      if (!node) return null;
      const box = node.getBoundingClientRect();
      return box.top + box.height / 2;
    };
    const icon = line.querySelector('img');
    return {
      text: (line.textContent || '').trim(),
      alt: icon?.getAttribute('alt') ?? null,
      title: icon?.getAttribute('title') ?? null,
      icon: mid(icon),
      mark: mid(line.querySelector('[data-follow-variant]')),
      time: mid(line.querySelector('time')),
      line: mid(line),
    };
  }));
}

describe('关注的出处用站点图标', () => {
  let browser: Browser;

  before(async () => {
    browser = await launch();
  });

  after(async () => {
    await browser.close();
  });

  it('合集队列卡:出处是带站名的图标,与时间同一条中线', { timeout: 60_000 }, async () => {
    const opened = await openFollow(browser, '/posts/1', '[data-follow-queue-item]');
    try {
      const rows = await lineGeometry(opened.page, '[data-follow-queue-item] [data-follow-queue-meta]');
      assert.equal(rows.length, 3, `队列卡数不对:${JSON.stringify(rows)}`);
      const duplicates = rows.filter((row) => row.alt !== null);
      assert.equal(duplicates.length, 2, `另一站的两条没有出处图标:${JSON.stringify(rows)}`);
      for (const row of duplicates) {
        assert.equal(row.alt, 'Rule34.xxx', '出处图标没有站名作无障碍名称');
        assert.equal(row.title, 'Rule34.xxx', '出处图标悬停不报站名');
        assert.ok(!row.text.includes('Rule34'), `这一行还写着站名文字:${row.text}`);
        assert.ok(Math.abs(row.icon! - row.time!) <= 1, `图标与时间中线差 ${Math.abs(row.icon! - row.time!)}px`);
      }
      // 本站那一条仍是标记加时间,二者同样按盒子居中。
      const own = rows.find((row) => row.alt === null)!;
      assert.ok(own.mark !== null && Math.abs(own.mark - own.time!) <= 1,
        `标记与时间中线差 ${own.mark === null ? '未取得' : Math.abs(own.mark - own.time!)}px`);
    } finally {
      await opened.close();
    }
  });

  it('卡片的「另见」徽章:另一站按图标列出,站名不写成文字', { timeout: 60_000 }, async () => {
    const opened = await openFollow(browser, '/follow', '[data-follow-item] [data-follow-seealso]');
    try {
      const [badge] = await lineGeometry(opened.page, '[data-follow-item] [data-follow-seealso]');
      assert.ok(badge, '卡片上没有「另见」徽章');
      assert.equal(badge.text, '另见');
      assert.equal(badge.alt, 'Rule34.xxx');
      assert.ok(Math.abs(badge.icon! - badge.line!) <= 1, `图标偏离徽章中线 ${Math.abs(badge.icon! - badge.line!)}px`);
      await opened.page.locator('[data-follow-item]:has([data-follow-seealso])').first().hover();
      const source = await opened.page.locator('[data-follow-item]:has([data-follow-seealso]) [data-follow-sources]').first().evaluate(node => {
        const style = getComputedStyle(node);
        return { opacity: style.opacity, visibility: style.visibility, events: style.pointerEvents };
      });
      assert.deepEqual(source, { opacity: '0', visibility: 'hidden', events: 'none' });
    } finally {
      await opened.close();
    }
  });

  /* 设计决定,与谁挂哪枚字样(`test/react/follow-marks.test.ts`)分开量:另见与版本字样是状态标记,
     不是标签,圆角走 `--badge-radius`;「含 WIP」说的是同组另一条,比 WIP 本体弱一档;SFW 模式下
     站点图标和品牌标一样不糊。字样按 `follow-marks.ts` 的形状临时摆进卡片标题里量。 */
  it('另见与来源图标共用封面角标,含 WIP 弱于 WIP,SFW 模式不糊站点图标', { timeout: 60_000 }, async () => {
    const opened = await openFollow(browser, '/follow', '[data-follow-item] [data-follow-seealso]');
    try {
      const look = await opened.page.evaluate(() => {
        const badge = document.querySelector('[data-follow-item] [data-follow-seealso]')!;
        const probe = document.createElement('span');
        probe.style.cssText = 'border-radius:var(--badge-radius);color:var(--muted);background:var(--meter)';
        badge.parentElement!.append(probe);
        const want = getComputedStyle(probe);
        const expected = { radius: want.borderTopLeftRadius, muted: want.color, meter: want.backgroundColor };
        probe.remove();
        const title = badge.closest('[data-follow-item]')!.querySelector('[data-media-title]') ?? badge.parentElement!;
        title.insertAdjacentHTML('afterbegin', '<small class="javedition" data-follow-edition="wip">WIP</small>'
          + '<small class="javedition" data-follow-edition="has-wip">含 WIP</small>');
        const [wip, hasWip] = [...title.querySelectorAll('[data-follow-edition]')].map((node) => getComputedStyle(node));
        const shape = getComputedStyle(badge);
        const source = badge.closest('[data-follow-sources]')!.querySelector('[data-media-badge]')!;
        const sourceShape = getComputedStyle(source);
        const sameSource = { radius: sourceShape.borderTopLeftRadius, background: sourceShape.backgroundColor, shadow: sourceShape.boxShadow };
        document.body.classList.add('censor');
        const icon = getComputedStyle(badge.querySelector('[data-follow-site-icon]')!).filter;
        document.body.classList.remove('censor');
        return { expected, sameSource, radius: shape.borderTopLeftRadius, border: shape.borderTopStyle,
          background: shape.backgroundColor, shadow: shape.boxShadow,
          editionRadius: wip!.borderTopLeftRadius, wip: wip!.color, hasWip: hasWip!.color, icon };
      });
      assert.equal(look.radius, look.sameSource.radius, '另见与来源角标同圆角');
      assert.equal(look.background, look.sameSource.background);
      assert.equal(look.shadow, look.sameSource.shadow);
      assert.equal(look.editionRadius, look.expected.radius, '版本字样同是状态标记');
      assert.equal(look.border, 'none');
      assert.equal(look.wip, look.expected.meter, 'WIP 本体着 --meter');
      assert.equal(look.hasWip, look.expected.muted, '「含 WIP」弱化成 --muted');
      assert.equal(look.icon, 'none', 'SFW 模式下站点图标不糊');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('卡面换成别的站那条时,另见列主条目的站、不列卡面自己的站', { timeout: 60_000 }, async () => {
    const opened = await openFollow(browser, '/follow', '[data-follow-item="12"] [data-follow-seealso]');
    try {
      const card = opened.page.locator('[data-follow-item="12"]');
      const shown = await card.locator('[data-media-badge]').getAttribute('title');
      const alts = await card.locator('[data-follow-seealso] img').evaluateAll((icons) =>
        icons.map((icon) => icon.getAttribute('alt')));
      assert.equal(shown, 'Rule34.xxx', '卡面没有换成有视频的那条');
      assert.deepEqual(alts, ['pixivFANBOX', 'Pawchive'], `另见列的站不对:${JSON.stringify(alts)}`);
    } finally {
      await opened.close();
    }
  });
});