Ctrl K
ComunidadGuías…

frontend/e2e/settings-panel.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 设置面板(`settings-panel` 岛,ADR-0031):开合与焦点、每一类设置改完立刻生效并落到该去的地方。
 * 写接口的桩在 `settings-fixture.ts`;面板的版式与像素由 `design-settings.test.ts` 量,这里只写行为。 */
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

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

import { launch, layout, requiredEnv, settle, visit, VIEWPORTS } from './harness.ts';
import { openHome, openPanel, ORDER, PANEL, stubServer, type Server } from './settings-fixture.ts';

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

/** 等到条件成立,最多五秒。 */
async function until(page: Page, condition: () => boolean, message: string): Promise<void> {
  for (let tries = 0; tries < 100 && !condition(); tries += 1) await page.waitForTimeout(50);
  assert.ok(condition(), message);
}

/** 等到 `path` 收到一次带 `key` 的写入,返回那一次的请求体。 */
async function posted(page: Page, server: Server, path: string, key: string): Promise<Record<string, unknown>> {
  const find = () => server.posts.find((post) => post.path === path && key in post.body);
  await until(page, () => !!find(), `${path} 没有收到 ${key} 的写入`);
  return find()!.body;
}

const stored = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem('peach.settings.v1') || '{}'));

async function pick(page: Page, field: string, value: string): Promise<void> {
  await page.locator(`${field} [data-select-trigger]`).click();
  await page.locator(`${field} [data-select-option="${value}"]`).click();
}

/** 自建一份上下文:起始设置只在本地还没有时写一次,刷新之后不会被冲掉。 */
async function persistentContext(browser: Browser): Promise<BrowserContext> {
  const context = await browser.newContext({
    viewport: { width: DESKTOP.width, height: DESKTOP.height }, reducedMotion: 'reduce', colorScheme: 'light',
  });
  await context.addInitScript(() => {
    if (!localStorage.getItem('peach.settings.v1')) {
      localStorage.setItem('peach.settings.v1', JSON.stringify({ detailAutoplay: false }));
    }
  });
  return context;
}

describe('设置面板', () => {
  let browser: Browser;
  before(async () => { browser = await launch() });
  after(async () => { await browser?.close() });

  it('持久标注开关不自动加载工具,明确开启仅在当前页面有效', { timeout: 60_000 }, async () => {
    let loads = 0;
    const opened = await visit(browser, '/', DESKTOP, async (page) => {
      await page.addInitScript(() => localStorage.setItem('peach.agentation', 'on'));
      await page.route('**/dev/agentation.js', (route) => {
        loads += 1;
        return route.fulfill({ contentType: 'application/javascript', body: 'document.body.dataset.annotationTool = "ready";' });
      });
    });
    const { page } = opened;
    try {
      await page.waitForFunction(() => localStorage.getItem('peach.agentation') === null);
      assert.equal(loads, 0);
      assert.equal(await page.evaluate(() => localStorage.getItem('peach.agentation')), null);
      await page.goto(`${requiredEnv('PEACH_E2E_ORIGIN')}/?agentation=on`);
      await page.waitForFunction(() => document.body.dataset.annotationTool === 'ready');
      assert.equal(loads, 1);
      assert.equal(new URL(page.url()).searchParams.has('agentation'), false);
      await page.reload();
      await page.locator('#settingsBtn').waitFor({ state: 'attached' });
      await settle(page);
      assert.equal(loads, 1);
      assert.equal(await page.evaluate(() => document.body.dataset.annotationTool), undefined);
      assert.deepEqual(opened.problems, []);
    } finally { await opened.close(); }
  });

  it('五个偏好分区各自显示对应设置,Peach 配置单独直达配置页', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    const { page } = opened;
    try {
      await openPanel(page);
      assert.deepEqual(await page.locator(PANEL).getByRole('tab').allTextContents(),
        ['外观与导航', '浏览与播放', 'JAV 与新作', '搜索与推荐', '内容显示']);
      for (const [name, field] of [
        ['外观与导航', '#themeSetting'], ['浏览与播放', '#detailAutoplaySetting'],
        ['JAV 与新作', '#javImageSetting'], ['搜索与推荐', '#relatedLimitSetting'], ['内容显示', '#censorSetting'],
      ]) {
        await page.locator(PANEL).getByRole('tab', { name, exact: true }).click();
        await page.locator(field!).waitFor({ state: 'visible' });
        assert.deepEqual((await layout(page)).offenders, []);
      }
      assert.equal(await page.locator(`${PANEL} #videoThumbnailSetting, ${PANEL} #followScheduleSetting`).count(), 0);
      const configuration = page.locator(PANEL).getByRole('button', { name: 'Peach 配置', exact: true });
      await configuration.click();
      await page.waitForURL((url) => url.pathname === '/settings/library');
      await page.locator(PANEL).waitFor({ state: 'hidden' });
      assert.deepEqual(opened.problems, []);
    } finally { await opened.close() }
  });

  it('齿轮打开、关闭键与 Escape 关上;焦点进来落在关闭键、Tab 在面板里转圈、关上后回到齿轮', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    const { page } = opened;
    try {
      await page.locator('#settingsBtn').click();
      await page.locator(`${PANEL}:not([hidden])`).waitFor({ timeout: 10_000 });
      await page.waitForFunction(() => document.activeElement?.id === 'settingsClose');
      assert.equal(await page.locator(PANEL).getAttribute('aria-modal'), 'true');
      assert.equal(await page.evaluate(() => document.body.classList.contains('settings-open')), true);

      await page.keyboard.press('Shift+Tab');
      assert.equal(await page.evaluate((panel) => !!document.activeElement?.closest(panel), PANEL), true,
        '从第一枚往回 Tab 跳出了面板');
      assert.notEqual(await page.evaluate(() => document.activeElement?.id), 'settingsClose', '往回 Tab 没有落到最后一枚');
      await page.keyboard.press('Tab');
      assert.equal(await page.evaluate(() => document.activeElement?.id), 'settingsClose', '最后一枚往后 Tab 没有回到第一枚');

      await page.locator('#settingsClose').click();
      await page.locator(PANEL).waitFor({ state: 'hidden', timeout: 5_000 });
      await page.waitForFunction(() => document.activeElement?.id === 'settingsBtn');
      assert.equal(await page.evaluate(() => document.body.classList.contains('settings-open')), false);

      await page.locator('#settingsBtn').click();
      await page.locator(`${PANEL}:not([hidden])`).waitFor({ timeout: 10_000 });
      await page.keyboard.press('Escape');
      await page.locator(PANEL).waitFor({ state: 'hidden', timeout: 5_000 });
      await page.waitForFunction(() => document.activeElement?.id === 'settingsBtn');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('主题选深色当场换上,刷新之后仍是深色', { timeout: 60_000 }, async () => {
    const context = await persistentContext(browser);
    try {
      const page = await context.newPage();
      await stubServer(page);
      await page.goto(`${requiredEnv('PEACH_E2E_ORIGIN')}/`, { waitUntil: 'load' });
      await settle(page);
      assert.equal(await page.evaluate(() => document.documentElement.classList.contains('dark')), false);
      await openPanel(page);
      await page.locator('#themeSetting label:has(input[value="dark"])').click();
      await page.waitForFunction(() => document.documentElement.classList.contains('dark'));
      assert.equal((await stored(page)).theme, 'dark');

      await page.reload({ waitUntil: 'load' });
      await settle(page);
      assert.equal(await page.evaluate(() => document.documentElement.classList.contains('dark')), true, '刷新后主题没有保持');
      await openPanel(page);
      assert.equal(await page.locator('#themeSetting input[value="dark"]').isChecked(), true, '刷新后面板里选中的不是深色');
    } finally {
      await context.close();
    }
  });

  it('改过的 SFW 快捷键按物理键位生效,原来的 Shift+S 不再切换', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const page = opened.page;
      const censored = () => page.evaluate(() => document.body.classList.contains('censor'));
      await page.evaluate(() => localStorage.setItem('peach.sfw-shortcut', 'Alt+KeyH'));
      await page.reload({ waitUntil: 'load' });
      await settle(page);
      await page.locator('body').click({ position: { x: 1, y: 1 } });
      await page.keyboard.press('Shift+S');
      assert.equal(await censored(), false);
      await page.keyboard.press('Alt+KeyH');
      assert.equal(await censored(), true);
      await openPanel(page);
      await page.locator(PANEL).getByRole('tab', { name: '内容显示', exact: true }).click();
      assert.equal(await page.locator(`${PANEL} [data-shortcut-keys]`).innerText(), 'Alt+H');
      assert.deepEqual(opened.problems, []);
    } finally { await opened.close() }
  });

  it('Shift+S 在页面上切换 SFW 并记住,在输入框里不切换', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    try {
      const page = opened.page;
      const censored = () => page.evaluate(() => document.body.classList.contains('censor'));
      await page.locator('body').click({ position: { x: 1, y: 1 } });
      await page.keyboard.press('Shift+S');
      assert.equal(await censored(), true);
      assert.equal(await page.evaluate(() => localStorage.getItem('peach-censor')), '1');
      await page.keyboard.press('Shift+S');
      assert.equal(await censored(), false);
      await page.locator('#q').focus();
      await page.keyboard.press('Shift+S');
      assert.equal(await censored(), false, '在搜索框里打大写 S 不该切换 SFW');
      assert.deepEqual(opened.problems, []);
    } finally { await opened.close() }
  });

  it('界面音效关掉后点按不再出声,打开后又出声', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/', DESKTOP);
    const { page } = opened;
    try {
      /* 每一声都要新建一枚振荡器或噪声源;数它们就知道响没响。 */
      await page.context().addInitScript(() => {
        const counted = window as unknown as { __uiTones: number };
        counted.__uiTones = 0;
        for (const method of ['createOscillator', 'createBufferSource'] as const) {
          const native = AudioContext.prototype[method];
          AudioContext.prototype[method] = function (this: AudioContext) {
            counted.__uiTones += 1;
            return native.call(this);
          } as never;
        }
      });
      await stubServer(page);
      await page.reload({ waitUntil: 'load' });
      await settle(page);
      await openPanel(page);
      const tones = () => page.evaluate(() => (window as unknown as { __uiTones: number }).__uiTones);
      const box = page.locator('#uiSoundsSetting');
      assert.equal(await box.isChecked(), true, '界面音效默认没有打开');
      await box.click();
      assert.equal(await box.isChecked(), false);
      assert.equal((await stored(page)).uiSounds, false);
      const silent = await tones();
      await page.locator(PANEL).getByRole('tab', { name: '浏览与播放', exact: true }).click();
      await page.locator(PANEL).getByRole('tab', { name: '外观与导航', exact: true }).click();
      assert.equal(await tones(), silent, '关掉界面音效之后点分区还在出声');
      await box.click();
      assert.equal((await stored(page)).uiSounds, true);
      await page.locator(PANEL).getByRole('tab', { name: '浏览与播放', exact: true }).click();
      assert.ok(await tones() > silent, '打开界面音效之后点分区不出声');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('光晕预设点一档就换上;拉条按一下方向键改参数并落盘', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    const { page } = opened;
    try {
      await openPanel(page);
      const grid = page.locator('#homeGlowControls [data-glow-grid]');
      await grid.waitFor({ state: 'visible', timeout: 10_000 });
      const target = grid.locator('[data-glow-preset][aria-pressed="false"]').first();
      const key = await target.getAttribute('data-glow-preset');
      const label = await target.getAttribute('aria-label');
      await target.click();
      await page.waitForFunction((preset) => document.querySelector(
        `#homeGlowControls [data-glow-preset="${preset}"]`)?.getAttribute('aria-pressed') === 'true', key);
      assert.equal(await page.locator('#homeGlowControls [data-glow-preset-name]').textContent(), label);
      assert.equal((await stored(page)).homeGlow.preset, key);

      /* 「玻璃原色」那一档收起强度;挑一档有三枚光晕的,再动强度。 */
      if (await page.locator('[data-glow-field="strength"]').isHidden()) {
        await grid.locator('[data-glow-preset="ash"]').click();
      }
      const dial = page.locator('[data-glow-dial="strength"] [role="slider"]');
      const before = Number(await dial.getAttribute('aria-valuenow'));
      await dial.focus();
      await page.keyboard.press(before > 0 ? 'ArrowLeft' : 'ArrowRight');
      const expected = before > 0 ? before - 1 : before + 1;
      await page.waitForFunction((value) => document.querySelector(
        '[data-glow-dial="strength"] [role="slider"]')?.getAttribute('aria-valuenow') === String(value), expected);
      assert.equal((await stored(page)).homeGlow.strength, expected, '拉条改的强度没有落盘');

      await page.locator('#homeGlowSetting').click();
      await page.locator('#homeGlowControls').waitFor({ state: 'hidden', timeout: 5_000 });
      assert.equal((await stored(page)).homeGlow.on, false);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('JAV 默认封面与默认排序方向改完落盘;随机排序时方向收起', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    const { page } = opened;
    try {
      await openPanel(page, 'JAV 与新作');
      await page.locator('#javImageSetting label:has(input[value="thumbnail"])').click();
      assert.equal((await stored(page)).javImage, 'thumbnail');
      assert.equal(await page.locator('#javImageSetting input[value="thumbnail"]').isChecked(), true);

      await page.locator(PANEL).getByRole('tab', { name: '浏览与播放', exact: true }).click();
      await pick(page, '#defaultSortSetting', 'seed');
      await page.locator('#defaultSortDirectionSetting').waitFor({ state: 'hidden', timeout: 5_000 });

      await pick(page, '#defaultSortSetting', 'rating');
      await page.locator('#defaultSortDirectionSetting').waitFor({ state: 'visible', timeout: 5_000 });
      await pick(page, '#defaultSortDirectionSetting', 'asc');
      await page.waitForFunction(() =>
        document.querySelector('#defaultSortDirectionSetting [data-sort-direction-label]')?.textContent === '升序');
      const saved = await stored(page);
      assert.equal(saved.defaultSort, 'rating');
      assert.equal(saved.defaultSortDirection, 'asc');
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it('侧栏排序拖动一行:侧栏当场按新顺序重排,再写进数据库', { timeout: 60_000 }, async () => {
    const opened = await openHome(browser);
    const { page, server } = opened;
    try {
      await openPanel(page);
      const list = page.locator('#sidebarOrderSetting');
      await list.evaluate((node) => node.scrollIntoView({ block: 'center' }));
      const rows = list.locator('[data-sidebar-row]');
      const keys = () => rows.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-sidebar-row')));
      const initial = await keys();
      assert.deepEqual(initial, ORDER, '面板里的顺序不是数据库那一份');
      const from = (await rows.nth(1).boundingBox())!, to = (await rows.nth(3).boundingBox())!;
      await page.mouse.move(from.x + 30, from.y + from.height / 2);
      await page.mouse.down();
      await page.mouse.move(from.x + 34, from.y + from.height / 2 + 6, { steps: 4 });
      await page.mouse.move(to.x + 40, to.y + to.height * 0.75, { steps: 8 });
      assert.equal(await rows.nth(1).getAttribute('data-dragging'), '', '拖动中的那一行没有标出来');
      await page.mouse.up();
      const moved = [initial[0], initial[2], initial[3], initial[1], ...initial.slice(4)];
      await page.waitForFunction((want) => JSON.stringify([...document.querySelectorAll(
        '#sidebarOrderSetting [data-sidebar-row]')].map((node) => node.getAttribute('data-sidebar-row'))) === want,
      JSON.stringify(moved));
      const nav = await page.locator('#drawer [data-sidebar-nav] [data-nav]').evaluateAll((nodes) =>
        nodes.map((node) => node.getAttribute('data-nav')));
      assert.deepEqual(nav, moved, '侧栏没有跟着面板里的新顺序重排');
      assert.deepEqual((await posted(page, server, '/api/settings', 'sidebarOrder')).sidebarOrder, moved);
      assert.deepEqual((await stored(page)).sidebarOrder, moved);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });

  it(`手机 ${MOBILE.width} 宽:面板铺满不横向溢出,分区横排可切换`, { timeout: 60_000 }, async () => {
    const opened = await openHome(browser, MOBILE);
    const { page } = opened;
    try {
      await openPanel(page);
      const geometry = await layout(page);
      assert.ok(geometry.scrollWidth <= geometry.viewportWidth, `页面横向溢出:${geometry.scrollWidth}px`);
      assert.deepEqual(geometry.offenders, []);
      const card = (await page.locator(`${PANEL} [data-settings-card]`).boundingBox())!;
      assert.ok(card.x >= 0 && card.x + card.width <= MOBILE.width, '设置卡片越出了视口');
      const tops = await page.locator(PANEL).getByRole('tab').evaluateAll((nodes) =>
        nodes.slice(0, 3).map((node) => Math.round(node.getBoundingClientRect().top)));
      assert.equal(new Set(tops).size, 1, '窄屏下分区没有横排');
      await page.locator(PANEL).getByRole('tab', { name: 'JAV 与新作', exact: true }).click();
      await page.locator('#javImageSetting').waitFor({ state: 'visible', timeout: 5_000 });
      assert.deepEqual((await layout(page)).offenders, []);
      assert.deepEqual(opened.problems, []);
    } finally {
      await opened.close();
    }
  });
});