Ctrl K
ComunidadGuías…

frontend/e2e/index-people.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, Page } from 'playwright-core';
import { launch, requiredEnv, settle, visit, VIEWPORTS } from './harness.ts';

/** 标题行里搜索框、放大镜与版式开关的竖向落位和尺寸,取整到像素;图标连同线宽一起比。 */
const headControls = (page: Page, selectors: { search: string; icon: string; layout: string }) =>
  page.evaluate((selectors) => {
    const box = (selector: string) => {
      const node = document.querySelector(selector);
      if (!node) return null;
      const { y, width, height } = node.getBoundingClientRect();
      return { y: Math.round(y), width: Math.round(width), height: Math.round(height) };
    };
    const icon = document.querySelector(selectors.icon);
    const search = box(selectors.search);
    return {
      search: search && { y: search.y, height: search.height },
      icon: icon && { ...box(selectors.icon)!, stroke: getComputedStyle(icon).strokeWidth },
      layout: box(selectors.layout),
    };
  }, selectors);

describe('本地与在线艺人共用名册布局', () => {
  let browser: Browser;
  before(async () => { browser = await launch() });
  after(async () => { await browser?.close() });
  for (const path of ['/tags', '/tags?scope=online', '/performers', '/performers?scope=online', '/creators', '/studios', '/agencies', '/series']) {
    it(`索引标题和操作同排,搜索框按可用空间收缩(${path})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, path, VIEWPORTS[0]);
      try {
        const page = opened.page;
        await settle(page);
        if (await page.locator('#drawer.open').count()) await page.locator('#filterBtn').click();
        for (const width of [1280, 761, 760, 640, 390, 320]) {
          await page.setViewportSize({ width, height: 844 });
          await settle(page);
          const read = () => page.locator('[data-index-head]').evaluate(head => {
            const title = head.querySelector('h2')!.getBoundingClientRect();
            const search = head.querySelector('[data-index-search]')!.getBoundingClientRect();
            const controls = [...head.querySelectorAll('[data-shortcuts-trigger],[data-tag-mode],[data-index-layout]')];
            return {
              searchWidth: search.width,
              centers: [search, ...controls.map(node => node.getBoundingClientRect())].map(box => (box.top + box.bottom - title.top - title.bottom) / 2),
              contained: controls.every(node => { const box = node.getBoundingClientRect(); return box.left >= 0 && box.right <= innerWidth }),
              overflow: document.documentElement.scrollWidth > innerWidth,
            };
          });
          for (const managing of path.startsWith('/tags') ? [false, true] : [false]) {
            if (managing) await page.locator('[data-tag-mode]').click();
            await settle(page);
            const shown = await read();
            assert.ok(shown.centers.every(delta => Math.abs(delta) < 1), `${width}px 操作或搜索框没有和标题同排:${JSON.stringify(shown)}`);
            assert.ok(shown.searchWidth >= 64 && shown.searchWidth <= 320, `${width}px 搜索框没有保持可输入宽度:${shown.searchWidth}`);
            assert.equal(shown.contained, true);assert.equal(shown.overflow, false);
            if (managing) await page.locator('[data-tag-mode]').click();
          }
        }
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close() }
    });
  }
  for (const viewport of VIEWPORTS) {
    it(`订阅标记在两种名册版式中可见(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/performers', viewport);
      try {
        const page = opened.page;
        let following = true;
        await page.route('**/api/index?**', route => route.fulfill({ json: {
          items: [
            { k: '已订阅人物', n: 3, entity_id: 901, following, has_image: true },
            { k: '停用订阅人物', n: 2, entity_id: 902, following: false },
            { k: '没有订阅人物', n: 1, entity_id: 903 },
          ], has_more: false,
        } }));
        await page.route('**/entity-image?**', route => route.fulfill({
          contentType: 'image/svg+xml',
          body: '<svg xmlns="http://www.w3.org/2000/svg" width="180" height="190"><rect width="180" height="190" fill="#888"/></svg>',
        }));
        await page.reload({ waitUntil: 'load' });
        for (const label of ['紧凑 · 圆形头像', '大图 · 竖幅头像']) {
          await page.getByRole('radio', { name: label, exact: true }).press('Space');
          const marker = page.locator('#index [data-k="已订阅人物"]').getByRole('img', { name: '已订阅新作', exact: true });
          await marker.waitFor({ timeout: 5_000 });
          await page.waitForFunction(() => {
            const image = document.querySelector<HTMLImageElement>('#index [data-k="已订阅人物"] img');
            return image?.complete && image.naturalWidth > 0;
          });
          assert.equal(await page.locator('#index [data-index-feed]').count(), 1);
          for (const dark of [false, true]) {
            await page.evaluate(dark => {
              document.documentElement.dataset.theme = dark ? 'dark' : 'light';
              document.documentElement.classList.toggle('dark', dark);
            }, dark);
            const tone = await marker.evaluate(node => {
              const badge = node.firstElementChild!;
              const probe = document.createElement('span');
              probe.style.color = 'var(--feed-mark)'; node.append(probe);
              const orange = getComputedStyle(probe).color; probe.remove();
              return { foreground: getComputedStyle(badge).color, orange, background: getComputedStyle(badge).backgroundColor };
            });
            assert.equal(tone.foreground, tone.orange);
            assert.notEqual(tone.background, 'rgba(0, 0, 0, 0)');
          }
          assert.equal(await marker.evaluate(node => {
            const a = node.getBoundingClientRect(), b = node.closest('[data-index-cell]')!.getBoundingClientRect();
            return a.width >= 16 && a.height >= 16 && a.left >= b.left && a.right <= b.right && a.top >= b.top && a.bottom <= b.bottom;
          }), true);
          assert.equal(await marker.evaluate(node => {
            const box = node.getBoundingClientRect();
            return [box.top + 3, (box.top + box.bottom) / 2, box.bottom - 3].every(y => {
              const hit = document.elementFromPoint((box.left + box.right) / 2, y);
              return hit === node || (hit !== null && node.contains(hit));
            });
          }), true, 'RSS 标记应完整显示在头像上方');
          assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
          await page.locator('#index [data-index-grid]').screenshot({
            path: `../build/continuation-rss/rss-${viewport.name}-${label.startsWith('紧凑') ? 'compact' : 'big'}.png`,
          });
        }
        following = false;
        await page.reload({ waitUntil: 'load' });
        await page.locator('#index [data-k="已订阅人物"]').waitFor({ timeout: 15_000 });
        assert.equal(await page.locator('#index [data-index-feed]').count(), 0);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close() }
    });
  }
  for (const viewport of VIEWPORTS) {
    for (const [path, selected] of [['/performers', '艺人'], ['/creators', '卖家'],
      ['/performers?scope=online', '在线']] as const) {
      it(`加载占位页使用同级名册入口(${selected} · ${viewport.name})`, { timeout: 60_000 }, async () => {
        const context = await browser.newContext({
          viewport: { width: viewport.width, height: viewport.height }, isMobile: viewport.mobile,
        });
        const page = await context.newPage();
        let release!: () => void;
        const gate = new Promise<void>(resolve => { release = resolve });
        await page.route(url => url.pathname === '/api/index' || url.pathname === '/api/follow/authors', async route => {
          await gate;
          await route.continue();
        });
        try {
          // 主模块先铺索引骨架,首屏数据暂缓;标题行与名册入口在等待时已经完整可见。
          await page.goto(requiredEnv('PEACH_E2E_ORIGIN') + path, { waitUntil: 'commit' });
          await page.locator('#index [data-skeleton]').first().waitFor();
          assert.equal(await page.locator('#index [role="tablist"]').count(), 1);
          assert.deepEqual(await page.locator('#index [role="tab"]').allTextContents(), ['艺人', '卖家', '在线']);
          assert.deepEqual(await page.locator('#index [role="tab"][aria-selected="true"]').allTextContents(), [selected]);
          assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
          const waiting = await headControls(page, {
            search: '#index [data-index-search] [data-rac]', icon: '#index [data-index-search] svg', layout: '#index .ihead .iconswitch' });
          release();
          await page.locator('#index [data-index-page]').waitFor();
          assert.deepEqual(await page.locator('#index [role="tab"]').allTextContents(), ['艺人', '卖家', '在线']);
          const ready = await headControls(page, {
            search: '#index [data-index-search] [role="presentation"]', icon: '#index [data-index-search] svg',
            layout: '#index [data-index-layout]' });
          // 标题行的搜索框与版式开关接管时原地换下:框高、竖向落位、图标与开关宽度都不跳。
          // 横向位置跟着计数读数的字宽走,骨架那条占位条量不出真数字,不比。
          assert.deepEqual(waiting, ready, '骨架与接管后的标题行控件几何不同');
        } finally { release(); await context.close() }
      });
    }
    it(`两种版式的卡片尺寸一致,长名字留在卡片内(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/performers', viewport);
      try {
        const page = opened.page;
        const names = ['Jul3D', 'LazyProcrastinator', 'Suzutaru 3D (鈴太郎3D)', '很长的艺人名称'.repeat(6)];
        await page.route('**/api/index?**', route => route.fulfill({ json: {
          items: names.map(k => ({ k, n: 3 })), has_more: false,
        } }));
        await page.route('**/api/follow/authors?**', route => route.fulfill({ json: {
          items: names.map(k => ({ k, key: k, n: 3, avatar: '', avatar_fallback: '', providers: [] })), has_more: false,
        } }));
        await page.reload({ waitUntil: 'load' });
        const cell = page.locator('#index [data-index-cell]').first();
        await cell.waitFor({ timeout: 15_000 });
        const measure = () => page.locator('#index [data-index-cell]').evaluateAll(elements => elements.map(element => {
          const style = getComputedStyle(element);
          const rect = element.getBoundingClientRect();
          const name = element.querySelector('[data-index-name]')!.getBoundingClientRect();
          return { width: rect.width, padding: style.padding, radius: style.borderRadius,
            whiteSpace: style.whiteSpace, font: style.font, gap: style.gap,
            contained: name.left >= rect.left && name.right <= rect.right,
          };
        }));
        for (const label of ['紧凑 · 圆形头像', '大图 · 竖幅头像']) {
          await page.getByRole('tab', { name: '艺人', exact: true }).click();
          await page.getByRole('radio', { name: label, exact: true }).press('Space');
          const layout = label.startsWith('紧凑') ? 'compact' : 'big';
          await page.locator(`#index [data-index-grid][data-layout="${layout}"]`).waitFor();
          await settle(page);
          const local = await measure();
          await page.getByRole('tab', { name: '在线', exact: true }).click();
          await page.locator('#index [data-follow-author][data-index-cell]').first().waitFor();
          await settle(page);
          const online = await measure();
          assert.deepEqual(online, local, '在线卡片与本地卡片的布局不同');
          assert.ok(online.every(item => item.width >= 150 && item.contained && item.whiteSpace === 'normal'));
          assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
          assert.equal(await page.locator('#index [role="tablist"]').count(), 1);
          assert.deepEqual(await page.locator('#index [role="tab"]').allTextContents(), ['艺人', '卖家', '在线']);
          await page.getByRole('tab', { name: '卖家', exact: true }).click();
          await page.locator('#index [data-index-cell]').first().waitFor();
          await settle(page);
          assert.equal(await page.locator('#index [role="tablist"]').count(), 1);
          assert.equal(await page.locator('#index [aria-label="身份分类"]').count(), 0);
          assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
        }
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close() }
    });
  }
  it('卖家动画作者的旧链接就地落到艺人分类,地址、标题和历史只留一条', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/performers', VIEWPORTS[0]);
    try {
      const page = opened.page;
      await page.locator('#index [data-index-page]').waitFor({ timeout: 15_000 });
      const before = await page.evaluate(() => history.length);
      const requests: string[] = [];
      page.on('request', request => {
        const url = new URL(request.url());
        if (url.pathname === '/api/index') requests.push(url.search);
      });
      await page.goto(requiredEnv('PEACH_E2E_ORIGIN') + '/creators?category=animation', { waitUntil: 'load' });
      await page.waitForURL(url => url.pathname === '/performers', { timeout: 15_000 });
      await page.locator('#index [data-index-page]').waitFor({ timeout: 15_000 });
      await settle(page);
      assert.equal(new URL(page.url()).searchParams.get('category'), 'animation');
      assert.equal(await page.evaluate(() => history.length), before + 1, '规范化不该多压一条历史');
      assert.equal(await page.evaluate(() => document.body.dataset.surface), '/performers');
      assert.match(await page.title(), /^艺人/);
      assert.deepEqual(await page.locator('#index [role="tab"][aria-selected="true"]').allTextContents(), ['艺人']);
      assert.equal(requests.length, 1, `名册只取一次:${requests.join(' | ')}`);
      assert.match(requests[0], /kind=performers/);
      assert.match(requests[0], /category=animation/);
      assert.deepEqual(opened.problems, []);
    } finally { await opened.close() }
  });
  it('名册过滤框在手机上弹出的软键盘回车键标成「搜索」', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/performers', VIEWPORTS[1]);
    let release = () => {};
    try {
      const pending = new Promise<void>(resolve => { release = resolve });
      await opened.page.route('**/api/index?**', async route => { await pending; await route.fallback() });
      await opened.page.reload({ waitUntil: 'commit' });
      const input = opened.page.locator('#index [data-index-search] input');
      await opened.page.locator('#index [data-index-search] input[readonly]').waitFor({ timeout: 15_000 });
      assert.equal(await input.getAttribute('enterkeyhint'), 'search');
      release();
      await opened.page.locator('#index [data-index-search] input:not([readonly])').waitFor({ timeout: 15_000 });
      assert.equal(await input.getAttribute('enterkeyhint'), 'search');
      assert.deepEqual(opened.problems, []);
    } finally { release(); await opened.close() }
  });
});