Ctrl K
ComunidadGuías…

frontend/e2e/photo-lightbox.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 图片灯箱在真浏览器里走一遍:真 Swiper、真键鼠,从资料页照片墙和关注详情两处打开。
 *
 * 取图口全部由这里回桩图,`/photo` 原图走计费来源,用例绝不碰真的。大图是 4000×3000 的 SVG:
 * 缩放条的百分比相对原图像素,图得真有尺寸,「适应窗口」和 1:1 才分得开。关灯箱走关闭键或
 * 点背景,不按 Escape:Playwright 合成的 Escape 触发不了 dialog 的 CloseWatcher。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

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

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

const DESKTOP = VIEWPORTS.find((viewport) => !viewport.mobile)!;
const NAME = '七沢みあ';
const ORIGINAL = { width: 4000, height: 3000 };
const svg = (width: number, height: number) => ({
  status: 200, contentType: 'image/svg+xml',
  body: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="#3a4a5a"/></svg>`,
});

async function stubImages(page: Page): Promise<void> {
  await page.route(/\/(?:photo|sample-image|follow-stream)\?/, (route) => route.fulfill(svg(ORIGINAL.width, ORIGINAL.height)));
  await page.route(/\/(?:photo-thumb|sample-thumb)\?|\/follow-thumb-stub\//, (route) => route.fulfill(svg(160, 120)));
}

/** 资料页照片档:一部作品两张样张加 `locals` 张本地图(默认三张,墙上五格)。 */
async function openWall(browser: Browser, viewport = DESKTOP, locals = 3): Promise<Visit> {
  const opened = await visit(browser, '/', viewport);
  const { page } = opened;
  await page.route(/\/api\/entity\?/, (route) => route.fulfill({ json: {
    id: 90_001, kind: 'performer', canonical_name: NAME, aliases: [], display_aliases: [], user_aliases: [],
    asset_count: 0, tags: [], related_performers: [], links: [], metadata: {}, has_image: false, has_avatar: false,
    avatar_focus: null, representative_asset_id: null, entry_links: [], feed: { following: false },
  } }));
  await page.route(/\/api\/photos\?/, (route) => {
    const offset = Number(new URL(route.request().url()).searchParams.get('offset') || 0);
    const items = offset ? [] : Array.from({ length: locals }, (_, at) => ({ id: 100 + at, name: `${100 + at}.jpg`, size: 2_345_678, location: 'local' }));
    return route.fulfill({ json: {
      kind: 'performer', name: NAME, entity_id: 90_001, total: items.length, items, seed: '', has_more: false,
      sample_total: 2, sets: [{ id: 'code:SSIS-057', kind: 'code', code: 'SSIS-057', name: '雨の日',
        title: 'SSIS-057 雨の日', n: 2, release_date: '2021-05-18', site: 'dmm', site_label: 'DMM', has_cover: true }],
    } });
  });
  await stubImages(page);
  await page.goto(new URL(`/performers/${encodeURIComponent(NAME)}?media=photos`, page.url()).href, { waitUntil: 'load' });
  await page.locator('[data-photo-cell] img').first().waitFor({ timeout: 15_000 });
  await settle(page);
  return opened;
}

/** 关注详情:一条三张图的帖子,详情大图点开就是这一组。 */
async function openFollowItem(browser: Browser): Promise<Visit> {
  const opened = await visit(browser, '/', DESKTOP);
  const { page } = opened;
  await stubImages(page);
  await page.route('**/follow-qualities**', (route) => route.fulfill({ json: {} }));
  await page.route('**/source-icon**', (route) => route.fulfill(svg(16, 16)));
  await page.route('**/api/follow/credentials', (route) => route.fulfill({ json: { providers: [] } }));
  await page.route('**/api/follow/check', (route) => (route.request().method() === 'GET'
    ? route.fulfill({ json: { status: 'idle' } }) : route.fallback()));
  const media = [0, 1, 2].map((index) => ({ index, media_kind: 'image', name: `帖子图 ${index + 1}.png`,
    size: 123_456, thumb_url: `/follow-thumb-stub/${index}`, width: ORIGINAL.width, height: ORIGINAL.height }));
  const item = { id: 501, provider: 'fanbox', provider_label: 'pixivFANBOX', resource_provider: 'fanbox', source_id: 1,
    source_label: '演示 · pixivFANBOX', external_id: '501', title: '演示图集', author: null, summary: null,
    url: 'https://example.com/501', thumb_url: '/follow-thumb-stub/0', published_at: '2026-09-16T00:20:00Z',
    published_precision: 'exact', version: null, duration: null, variant_kind: null, variant_label: null, status: 'new',
    asset_id: null, media_needs_credential: false, media_error: null, has_media: true, media_kind: 'image',
    width: ORIGINAL.width, height: ORIGINAL.height, playable: true, media_type: 'image/svg+xml', media_items: media,
    hidden_media: [], resource_urls: [], tags: [], detail_tags: [], tag_types: {} };
  await page.route((url) => url.pathname === '/api/follow', (route) => route.fulfill({ json: {
    groups: [{ release_key: 'r501', primary: item, variants: [], duplicates: [], providers: ['fanbox'], has_wip: false,
      is_release: false, newest_at: item.published_at, stack: null }],
    sources: [], counts: { new: 1, seen: 0, saved: 0, ignored: 0 }, facets: { authors: [], providers: ['fanbox'] },
    has_more: false } }));
  await page.goto(new URL('/posts/501', page.url()).href, { waitUntil: 'load' });
  await page.locator('[data-follow-detail-poster]').waitFor({ timeout: 15_000 });
  await settle(page);
  return opened;
}

const lightbox = (page: Page) => page.locator('dialog[data-photo-lightbox][open]');
const labelled = (box: Locator, label: string) => box.locator(`[aria-label="${label}"]`).first();

/** 缩略图条里当前那一格的序号,以及它的中线离条的中线多远。 */
const stripFocus = (page: Page) => page.evaluate(() => {
  const strip = document.querySelector('[data-photo-strip]')!;
  const slides = [...strip.querySelectorAll('.swiper-slide')];
  const active = strip.querySelector('.swiper-slide-thumb-active');
  const center = (el: Element) => { const box = el.getBoundingClientRect(); return box.left + box.width / 2 };
  return { at: active ? slides.indexOf(active) : -1, offset: active ? Math.abs(center(active) - center(strip)) : Infinity };
});

async function expectStripOn(page: Page, at: number): Promise<void> {
  await page.waitForFunction((want) => {
    const strip = document.querySelector('[data-photo-strip]')!;
    const active = strip.querySelector('.swiper-slide-thumb-active');
    if (!active || [...strip.querySelectorAll('.swiper-slide')].indexOf(active) !== want) return false;
    const a = active.getBoundingClientRect(), s = strip.getBoundingClientRect();
    return Math.abs(a.left + a.width / 2 - (s.left + s.width / 2)) <= 1;
  }, at, { timeout: 5_000 }).catch(async () => assert.fail(`缩略图条没有停到第 ${at + 1} 张的中线:${JSON.stringify(await stripFocus(page))}`));
}

/** 当前这张大图的显示宽度,与「适应窗口」时它对原图的百分比。 */
const shown = (box: Locator) => box.locator('[data-photo-main] .swiper-slide-active img').evaluate((img: HTMLImageElement) => ({
  width: img.getBoundingClientRect().width,
  fit: Math.round(Math.min(100, img.offsetWidth / img.naturalWidth * 100, img.offsetHeight / img.naturalHeight * 100)),
}));

describe('图片灯箱', () => {
  let browser: Browser;
  before(async () => { browser = await launch(); });
  after(async () => { await browser?.close(); });

  it('资料页照片墙点第 3 格:从这一张开始,页码 3 / 5,翻页时页码与缩略图条一起跟上', { timeout: 60_000 }, async () => {
    const opened = await openWall(browser);
    const { page } = opened;
    try {
      const cell = page.locator('[data-photo-cell]').nth(2);
      const thumb = (await cell.locator('img').getAttribute('src'))!;
      await cell.click();
      const box = lightbox(page);
      await box.waitFor();
      assert.equal(await page.evaluate(() => document.body.classList.contains('photolight-open')), true);
      const count = box.locator('[data-photo-count]');
      assert.equal((await count.innerText()).trim(), '3 / 5');
      assert.equal(await count.getAttribute('aria-live'), 'polite');
      assert.equal(await box.locator('[data-photo-main] .swiper-slide-active img').getAttribute('src'),
        thumb.replace('/photo-thumb?', '/photo?').replace('/sample-thumb?', '/sample-image?'), '点开的不是那一张');
      await expectStripOn(page, 2);

      await box.locator('[data-photo-nav="fwd"]').click();
      await page.waitForFunction(() => document.querySelector('[data-photo-count]')?.textContent === '4 / 5');
      await expectStripOn(page, 3);
      await page.keyboard.press('ArrowLeft');
      await page.waitForFunction(() => document.querySelector('[data-photo-count]')?.textContent === '3 / 5');
      await expectStripOn(page, 2);
      await box.locator('[data-photo-strip] .swiper-slide').nth(4).click();
      await page.waitForFunction(() => document.querySelector('[data-photo-count]')?.textContent === '5 / 5');
      await expectStripOn(page, 4);

      await labelled(box, '关闭').click();
      await box.waitFor({ state: 'detached' });
      assert.equal(await page.evaluate(() => document.body.classList.contains('photolight-open')), false);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('缩放条:百分比相对原图像素,步进十个点,1:1 是原图大小,适应窗口回到起点', { timeout: 60_000 }, async () => {
    const opened = await openWall(browser);
    const { page } = opened;
    try {
      await page.locator('[data-photo-cell]').nth(2).click();
      const box = lightbox(page);
      await box.waitFor();
      const readout = box.locator('[data-photo-zoom-readout]');
      await page.waitForFunction(() => document.querySelector<HTMLImageElement>('[data-photo-main] .swiper-slide-active img')!
        .naturalWidth > 0, undefined, { timeout: 10_000 });
      const start = await shown(box);
      const { fit } = start;
      assert.ok(fit < 100, `4000×3000 的图在 1280×800 里应当缩着放,读到 ${fit}%`);
      await page.waitForFunction((want) => document.querySelector('[data-photo-zoom-readout]')?.textContent === want, `${fit}%`);
      await labelled(box, '放大').click();
      assert.equal(await readout.innerText(), `${fit + 10}%`);
      await labelled(box, '缩小').click();
      assert.equal(await readout.innerText(), `${fit}%`);
      await labelled(box, '原大小').click();
      assert.equal(await readout.innerText(), '100%');
      await page.waitForFunction((want) => Math.abs(document.querySelector('[data-photo-main] .swiper-slide-active img')!
        .getBoundingClientRect().width - want) <= want * .01, ORIGINAL.width, { timeout: 5_000 });
      await box.locator('[data-photo-zoom] input[type=range]').evaluate((input: HTMLInputElement) => {
        Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, '200');
        input.dispatchEvent(new Event('input', { bubbles: true }));
      });
      assert.equal(await readout.innerText(), '200%');
      await labelled(box, '适应窗口').click();
      assert.equal(await readout.innerText(), `${fit}%`);
      await page.waitForFunction((want) => Math.abs(document.querySelector('[data-photo-main] .swiper-slide-active img')!
        .getBoundingClientRect().width - want) <= 1, start.width, { timeout: 5_000 })
        .catch(async () => assert.fail(`适应窗口没有收回:${start.width} → ${(await shown(box)).width}`));
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('圆圈 i 开合信息面板,点面板外只收面板;点图四周的黑底关掉灯箱', { timeout: 60_000 }, async () => {
    const opened = await openWall(browser);
    const { page } = opened;
    try {
      await page.locator('[data-photo-cell]').nth(4).click();
      const box = lightbox(page);
      await box.waitFor();
      const toggle = labelled(box, '图片详情');
      const panel = box.locator('[data-photo-detail]');
      await toggle.click();
      await panel.waitFor({ state: 'visible' });
      assert.equal(await toggle.getAttribute('aria-expanded'), 'true');
      assert.equal((await panel.locator('h2').innerText()).trim(), '102.jpg');
      assert.match((await panel.locator('[data-photo-detail-meta]').innerText()).trim(), /^本地 · 2(?:\.\d)? MB$/);
      assert.equal(await panel.locator('[data-photo-reveal]').isVisible(), true, '本地图要有定位键');
      await box.locator('[data-photo-zoom-readout]').click();
      await panel.waitFor({ state: 'hidden' });
      assert.equal(await toggle.getAttribute('aria-expanded'), 'false');
      assert.equal(await box.isVisible(), true, '收面板那一下不该连灯箱一起关');

      // 主画布铺满视口,图上方那条留白属于 zoom 容器。
      await page.mouse.click(DESKTOP.width / 2, 10);
      await box.waitFor({ state: 'detached' });
      assert.equal(await page.locator('[data-photo-main]').count(), 0);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('关注详情的大图打开同一个灯箱:整组三张,在线图写来源与分辨率,没有定位键', { timeout: 60_000 }, async () => {
    const opened = await openFollowItem(browser);
    const { page } = opened;
    try {
      await page.locator('[data-follow-detail-poster]').click();
      const box = lightbox(page);
      await box.waitFor();
      assert.equal((await box.locator('[data-photo-count]').innerText()).trim(), '1 / 3');
      assert.equal(await box.locator('[data-photo-main] .swiper-slide-active img').getAttribute('src'),
        '/follow-stream?id=501&media=0');
      await labelled(box, '图片详情').click();
      const panel = box.locator('[data-photo-detail]');
      await panel.waitFor({ state: 'visible' });
      assert.equal((await panel.locator('h2').innerText()).trim(), '帖子图 1.png');
      const meta = await panel.locator('[data-photo-detail-meta]').innerText();
      assert.match(meta, /^pixivFANBOX · 4000 × 3000 · 121\sKB$/u);
      assert.doesNotMatch(meta, /第/, '第几张只看底栏的页码,面板不另编一个号');
      assert.equal(await panel.locator('[data-photo-reveal]').isVisible(), false, '在线图不该有定位键');
      await labelled(box, '关闭').click();
      await box.waitFor({ state: 'detached' });
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('320 宽翻到上千张的最后一张:页码一行写完,底栏整条留在屏内', { timeout: 90_000 }, async () => {
    const opened = await openWall(browser, { name: '320', width: 320, height: 720, mobile: true }, 1287);
    const { page } = opened;
    try {
      await page.locator('[data-photo-cell]').first().click();
      const box = lightbox(page);
      await box.waitFor();
      await page.evaluate(() => {
        const main = document.querySelector('[data-photo-main]') as HTMLElement & { swiper: { slideTo(at: number, speed: number): void } };
        main.swiper.slideTo(1288, 0);
      });
      const count = box.locator('[data-photo-count]');
      await page.waitForFunction(() => document.querySelector('[data-photo-count]')?.textContent === '1289 / 1289');
      const bar = await box.locator('[data-photo-bar]').evaluate((node) => ({
        line: parseFloat(getComputedStyle(node.querySelector('[data-photo-count]')!).lineHeight) || 16,
        count: node.querySelector('[data-photo-count]')!.getBoundingClientRect().height,
        right: Math.max(...[...node.querySelectorAll('button, [data-photo-count]')].map((el) => el.getBoundingClientRect().right)),
        view: document.documentElement.clientWidth,
      }));
      assert.ok(bar.count < bar.line * 2 + 16, `页码折行了:${JSON.stringify(bar)}`);
      assert.ok(bar.right <= bar.view, `底栏越出视口:${JSON.stringify(bar)}`);
      assert.equal(await count.isVisible(), true);
    } finally {
      await opened.close();
    }
  });
});