Ctrl K
ComunidadGuías…

frontend/e2e/design-configuration-controls.test.ts

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

Archivo original ↗Preguntar a KiBot
/* 配置与设置入口的语义样式:图标、状态对齐、主次动作与单行入口。 */
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { Browser } from 'playwright-core';
import { configurationBody, expectBody, layout, settle, visit, VIEWPORTS } from './harness.ts';
import { followCredential, installDesignBrowser, openActivity, openFollowManage, settledRun } from './design-fixture.ts';
import { openHome, openPanel, PANEL } from './settings-fixture.ts';

describe('设计决定:配置控件', () => {
  let browser: Browser;
  installDesignBrowser((next) => { browser = next; });

  for (const viewport of VIEWPORTS) {
    it(`来源菜单适应长选项并受视口宽度约束(${viewport.name})`, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const { page } = opened;
        for (const name of ['Rule34 Paheal / SubscribeStar.adult',
          'https://www.subscribestar.adult/creators/a-long-creator-display-name-without-spaces']) {
          await page.route('**/api/follow/credentials', route => route.fulfill({ json: {
            root: '', providers: [followCredential(name, 'none', false, [])],
          } }));
          await page.goto(new URL('/subscriptions?tab=add', page.url()).href, { waitUntil: 'load' });
          await page.getByRole('button', { name: '全部来源', exact: true }).click();
          const menu = page.getByRole('dialog', { name: '来源筛选', exact: true });
          assert.equal(await menu.getByRole('checkbox', { name, exact: true }).count(), 1);
          const fit = await menu.evaluate(element => {
            const label = element.querySelector('label')!;
            const box = element.getBoundingClientRect();
            return { right: box.right, left: box.left, width: box.width, viewport: innerWidth,
              scroll: label.scrollWidth, client: label.clientWidth, content: element.scrollWidth, frame: element.clientWidth };
          });
          assert.ok(fit.left >= 0 && fit.right <= fit.viewport, JSON.stringify(fit));
          assert.ok(fit.scroll <= fit.client + 1 && fit.content <= fit.frame + 1, JSON.stringify(fit));
          if (viewport.width > 1000) assert.ok(fit.width > 203, '长选项应撑开菜单');
          await page.keyboard.press('Escape');
        }
        assert.deepEqual((await layout(page)).offenders, []);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`来源筛选短操作紧邻排列,选项靠近触发器(${viewport.name})`, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const { page } = opened;
        await page.getByRole('tab', { name: '添加关注', exact: true }).click();
        await page.getByRole('button', { name: '全部来源', exact: true }).click();
        const dialog = page.getByRole('dialog', { name: '来源筛选', exact: true });
        const geometry = await dialog.evaluate(element => {
          const actions = element.querySelector('[data-selection-actions]')!;
          const buttons = [...actions.querySelectorAll('button,a')].map(node => {
            const box = node.getBoundingClientRect();
            return { top: box.top, bottom: box.bottom, center: box.top + box.height / 2, left: box.left, right: box.right, width: box.width,
              scrollWidth: node.scrollWidth, clientWidth: node.clientWidth };
          });
          const options = element.querySelector('[data-selection-options]')!.getBoundingClientRect();
          const optionWidths = [...element.querySelectorAll('label')].map(label =>
            label.lastElementChild!.getBoundingClientRect().right - label.getBoundingClientRect().left);
          const actionWidth = buttons.reduce((sum, button) => sum + button.width, 0)
            + Math.max(0, buttons.length - 1) * parseFloat(getComputedStyle(actions).columnGap);
          return { actions: buttons, width: actions.getBoundingClientRect().width,
            contentWidth: element.getBoundingClientRect().width, naturalWidth: Math.max(actionWidth, ...optionWidths),
            placement: element.getAttribute('data-selection-placement'), optionTop: options.top, optionBottom: options.bottom,
            right: element.getBoundingClientRect().right, viewport: innerWidth };
        });
        assert.equal(geometry.actions.length, 3);
        assert.ok(Math.abs(geometry.contentWidth - geometry.naturalWidth) <= 1, '菜单跟随选项和操作行中较宽的一组');
        assert.ok(geometry.actions.every(action => Math.abs(action.center - geometry.actions[0]!.center) <= 1));
        assert.ok(geometry.actions.every(action => (geometry.placement === 'top'
          ? action.bottom <= geometry.optionTop : action.top >= geometry.optionBottom) && action.width < geometry.width));
        assert.ok(geometry.actions[2]!.left - geometry.actions[1]!.right <= 9, '凭据链接紧邻全不选');
        assert.ok(geometry.actions.every(action => action.scrollWidth <= action.clientWidth + 1));
        assert.ok(geometry.right <= geometry.viewport);
        assert.equal(await dialog.getByRole('link', { name: '凭据设置', exact: true }).count(), 1);
        assert.equal(await dialog.getByRole('link', { name: '凭据设置', exact: true }).getAttribute('href'), '/settings/subscriptions');
        assert.equal(await dialog.getByRole('link', { name: '凭据设置', exact: true }).locator('svg').count(), 1);
        await dialog.getByRole('button', { name: '全不选', exact: true }).click();
        assert.equal(await dialog.getByRole('checkbox', { checked: true }).count(), 0);
        await dialog.getByRole('button', { name: '全选', exact: true }).click();
        assert.equal(await dialog.getByRole('checkbox', { checked: false }).count(), 0);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`多选菜单随视口空间翻转,键盘顺序与选项位置一致(${viewport.name})`, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const { page } = opened;
        await page.setViewportSize({ width: viewport.width, height: 1200 });
        await page.getByRole('tab', { name: '添加关注', exact: true }).click();
        const trigger = page.getByRole('button', { name: '全部来源', exact: true });
        await trigger.click();
        const dialog = page.getByRole('dialog', { name: '来源筛选', exact: true });
        await page.waitForFunction(() => document.querySelector('[data-selection-placement]')?.getAttribute('data-selection-placement') === 'bottom');
        assert.equal(await dialog.locator('button,a,input').first().getAttribute('type'), 'checkbox');
        await page.setViewportSize({ width: viewport.width, height: 480 });
        await page.waitForFunction(() => document.querySelector('[data-selection-placement]')?.getAttribute('data-selection-placement') === 'top');
        assert.equal(await dialog.locator('button,a,input').first().innerText(), '全选');
        const ordered = await dialog.evaluate(element => {
          const actions = element.querySelector('[data-selection-actions]')!.getBoundingClientRect();
          const options = element.querySelector('[data-selection-options]')!.getBoundingClientRect();
          return actions.bottom <= options.top;
        });
        assert.ok(ordered);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`关注管理集合和跨页管理导航保留滑动层与减少动态效果(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openFollowManage(browser, viewport);
      try {
        const { page } = opened;
        await page.emulateMedia({ reducedMotion: 'no-preference' });
        const tabs = page.getByRole('tablist', { name: '关注管理区域' });
        const surface = tabs.locator(':scope > [data-pill-thumb]');
        await surface.waitFor();
        const original = await surface.elementHandle();
        assert.ok(original);
        await tabs.getByRole('tab', { name: '添加关注', exact: true }).click();
        await page.waitForFunction(() => {
          const host = document.querySelector('[role="tablist"][aria-label="关注管理区域"]')!;
          const selected = host.querySelector('[data-pill-selected]')!.getBoundingClientRect();
          const pane = host.querySelector('[data-pill-thumb]')!.getBoundingClientRect();
          return Math.abs(selected.left - pane.left) < 1 && Math.abs(selected.width - pane.width) < 1;
        });
        assert.equal(await original.evaluate((node) => node.isConnected), true);
        assert.match(await surface.evaluate((node) => getComputedStyle(node).transitionDuration), /0.3s/);
        await tabs.getByRole('tab', { name: '添加关注', exact: true }).press('ArrowRight');
        await tabs.getByRole('tab', { name: '标签管理', selected: true }).waitFor();
        await tabs.getByRole('tab', { name: '标签管理', exact: true }).press('ArrowRight');
        await tabs.getByRole('tab', { name: '关注列表', selected: true }).waitFor();
        assert.equal(await tabs.getByRole('tab').count(), 3);
        assert.equal(await surface.count(), 1);
        await page.emulateMedia({ reducedMotion: 'reduce' });
        assert.equal(await surface.evaluate((node) => getComputedStyle(node).transitionDuration), '0s');
        const navigation = page.getByRole('navigation', { name: '管理', exact: true });
        const managementSurface = navigation.locator('[data-pill-thumb]');
        const managementOriginal = await managementSurface.elementHandle();
        assert.ok(managementOriginal);
        await navigation.getByRole('button', { name: '任务中心', exact: true }).click();
        await page.waitForURL((url) => url.pathname === '/tasks');
        await page.getByRole('heading', { name: '任务中心', exact: true }).waitFor();
        assert.equal(await managementOriginal.evaluate((node) => node.isConnected), true);
        assert.equal(await navigation.locator('[data-pill-selected]').textContent(), '任务中心');
        assert.deepEqual((await layout(page)).offenders, []);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`页面页签复用 PillTabList 选中层并保留方向键导航(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/studios', viewport);
      try {
        const page = opened.page;
        await page.emulateMedia({ reducedMotion: 'no-preference' });
        const tabs = page.getByRole('tablist', { name: '资料类型' });
        const surface = tabs.locator(':scope > span[aria-hidden="true"]');
        await surface.waitFor();
        const original = await surface.elementHandle();
        assert.ok(original);
        const style = await surface.evaluate((node) => ({ duration: getComputedStyle(node).transitionDuration,
          properties: getComputedStyle(node).transitionProperty }));
        assert.match(style.properties, /transform/);
        assert.ok(style.duration.split(',').every((value) => value.trim() === '0.3s'));
        await tabs.getByRole('tab', { name: '事务所', exact: true }).click();
        await page.waitForURL((url) => url.pathname === '/agencies');
        await tabs.getByRole('tab', { name: '事务所', selected: true }).waitFor();
        // 等的就是开头取到的那一块选中层滑到位并停下:读它自己的父元素,不按标签名另找一排。
        await page.waitForFunction((thumb) => {
          const selected = thumb.parentElement!.querySelector('[aria-selected="true"]')!.getBoundingClientRect();
          const pane = thumb.getBoundingClientRect();
          return thumb.getAnimations().length === 0
            && Math.abs(selected.left - pane.left) <= 1 && Math.abs(selected.width - pane.width) <= 1;
        }, original);
        assert.equal(await original.evaluate((node) => node.isConnected), true);
        const selected = await tabs.getByRole('tab', { selected: true }).boundingBox();
        const pane = await surface.boundingBox();
        assert.ok(selected && pane && Math.abs(selected.x - pane.x) <= 1 && Math.abs(selected.width - pane.width) <= 1);
        await tabs.getByRole('tab', { selected: true }).press('ArrowLeft');
        await page.waitForURL((url) => url.pathname === '/series');
        await tabs.getByRole('tab', { name: '系列', selected: true }).press('ArrowLeft');
        await page.waitForURL((url) => url.pathname === '/studios');
        await tabs.getByRole('tab', { name: '厂牌', selected: true }).waitFor();
        await page.emulateMedia({ reducedMotion: 'reduce' });
        assert.equal(await surface.evaluate((node) => getComputedStyle(node).transitionDuration), '0s');
        assert.deepEqual((await layout(page)).offenders, []);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`设置项分隔线左侧内缩、右侧通栏,独立行组之间有一条线(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/settings/library', viewport);
      try {
        const page = opened.page;
        const section = page.getByRole('region', { name: '视频预览', exact: true });
        await section.getByRole('switch', { name: '生成视频预览' }).waitFor();
        await settle(page);
        const measured = await section.evaluate((element) => {
          const card = element.lastElementChild!;
          const box = card.getBoundingClientRect();
          const groups = [...card.querySelectorAll(':scope > .ui-settings-rows')];
          return groups.map((group) => ({
            top: parseFloat(getComputedStyle(group).borderTopWidth),
            rows: [...group.children].map((row) => ({
              left: row.getBoundingClientRect().left - box.left,
              right: box.right - row.getBoundingClientRect().right,
              bottom: parseFloat(getComputedStyle(row).borderBottomWidth),
            })),
          }));
        });
        // 总开关、时间轴档位、抽帧解码方式各占一个行组,组与组之间由下一组的上边线分开。
        assert.equal(measured.length, 3);
        assert.deepEqual(measured.map((group) => group.rows.length), [1, 1, 1]);
        assert.deepEqual(measured.map((group) => group.top), [0, 1, 1]);
        assert.deepEqual(measured.flatMap((group) => group.rows.map((row) => row.bottom)), [0, 0, 0]);
        for (const row of measured.flatMap((group) => group.rows)) {
          assert.ok(Math.abs(row.left - 12) <= .5 && Math.abs(row.right) <= .5, JSON.stringify(row));
        }
        const footer = await page.getByRole('form', { name: '自动发现新文件', exact: true })
          .getByRole('button', { name: '保存配置', exact: true }).evaluate((button) => {
            const bar = button.parentElement!;
            const card = bar.parentElement!.getBoundingClientRect();
            const box = bar.getBoundingClientRect();
            return { left: box.left - card.left, right: card.right - box.right, top: getComputedStyle(bar).borderTopWidth };
          });
        assert.ok(Math.abs(footer.left) <= .5 && Math.abs(footer.right) <= .5, JSON.stringify(footer));
        assert.equal(footer.top, '1px');
        await openPanel(page);
        const rows = await page.locator(`${PANEL} [data-setting-group]:visible [data-setting-row]`).evaluateAll((elements) =>
          elements.map((row) => {
            const group = row.parentElement!.getBoundingClientRect();
            const box = row.getBoundingClientRect();
            return { left: box.left - group.left, right: group.right - box.right };
          }));
        assert.ok(rows.length > 1);
        for (const row of rows) assert.ok(Math.abs(row.left - 12) <= .5 && Math.abs(row.right) <= .5, JSON.stringify(row));
        assert.deepEqual((await layout(page)).offenders, []);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`设置导航字形可见,口味入口呈按钮并进入对应页面(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await openHome(browser, viewport);
      try {
        const page = opened.page;
        await openPanel(page);
        const marks = await page.locator(`${PANEL} [data-settings-nav] button svg`).evaluateAll((icons) => icons.map((icon) => {
          const shape = (icon as SVGSVGElement).getBBox();
          const symbol = document.querySelector(icon.querySelector('use')!.getAttribute('href')!);
          return { width: shape.width, height: shape.height, text: symbol?.textContent, name: icon.closest('button')!.textContent };
        }));
        for (const mark of marks) assert.ok(mark.width > 0 && mark.height > 0, `${mark.name} 字形为空`);
        assert.ok(marks.find((mark) => mark.name === 'JAV 与新作')?.text?.includes('番'));
        await page.locator(PANEL).getByRole('tab', { name: '搜索与推荐' }).click();
        const link = page.locator(PANEL).getByRole('link', { name: '打开口味与记录' });
        const style = await link.evaluate((node) => ({ display: getComputedStyle(node).display,
          height: node.getBoundingClientRect().height, decoration: getComputedStyle(node).textDecorationLine }));
        assert.ok(['flex', 'inline-flex'].includes(style.display));
        assert.ok(style.height >= 32);
        assert.equal(style.decoration, 'none');
        await link.click();
        await page.waitForURL((url) => url.pathname === '/taste');
        await page.locator(PANEL).waitFor({ state: 'hidden' });
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });

    it(`媒体状态居中,辅助动作与保存有层级,新手教程入口可见(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/settings/library', viewport);
      try {
        const page = opened.page;
        await page.route('**/api/configuration', async (route) => {
          const data = await (await route.fetch()).json();
          const paths = ['R:\\media', 'B:\\', 'A:\\'];
          await route.fulfill({ json: { ...data, editable: true, windows: true, media_dirs: paths, media_sources: paths.map((path, index) => ({
            path, location: ['local', '115', 'pikpak'][index], online: index > 0, library: ['media', '115', 'PikPak'][index],
          })) } });
        });
        await page.reload({ waitUntil: 'load' });
        await expectBody(page, '/settings/library', configurationBody(page));
        await settle(page);
        await page.locator('[data-mount]').first().waitFor();
        const centers = await page.locator('[data-folder-row]').evaluateAll((rows) => rows.map((row) => {
          const input = row.querySelector('input')!.getBoundingClientRect();
          const status = row.querySelector('[data-mount]')!.getBoundingClientRect();
          return Math.abs(input.top + input.height / 2 - status.top - status.height / 2);
        }));
        assert.equal(centers.length, 3);
        for (const delta of centers) assert.ok(delta <= 1, `状态偏离输入框中线 ${delta}px`);
        const media = page.getByRole('form', { name: '媒体库与目录', exact: true });
        assert.notEqual(await media.getByRole('button', { name: '添加媒体库', exact: true }).evaluate((node) => getComputedStyle(node).backgroundImage),
          await media.getByRole('button', { name: '保存配置', exact: true }).evaluate((node) => getComputedStyle(node).backgroundImage));
        await page.getByRole('tab', { name: '应用与维护', exact: true }).click();
        const tutorial = page.getByRole('region', { name: '新手教程', exact: true });
        await tutorial.waitFor();
        const aligned = await tutorial.evaluate((node) => {
          const label = [...node.querySelectorAll('p')].find((item) => item.textContent === '新手教程')!.parentElement!.getBoundingClientRect();
          const button = node.querySelector('button')!.getBoundingClientRect();
          return { delta: Math.abs(label.top + label.height / 2 - button.top - button.height / 2), gap: button.left - label.right };
        });
        assert.ok(aligned.delta <= 1 && aligned.gap > 0, JSON.stringify(aligned));
        await page.screenshot({ path: `../build/agent-verification/browser/configuration-${viewport.name}.png`, fullPage: true });
        assert.deepEqual((await layout(page)).offenders, []);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });
  }

  for (const viewport of VIEWPORTS) for (const path of ['/settings/library', '/review']) {
    it(`分类目录在桌面左侧、手机上方,选中态区别于悬停(${path} · ${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, path, viewport);
      try {
        const page = opened.page;
        const nav = page.locator('#stats [data-section-nav]:not([data-skeleton] *)');
        const selected = nav.getByRole('tab', { selected: true });
        await selected.waitFor();
        await settle(page);
        const content = page.getByRole('tabpanel').filter({ visible: true }).first();
        const a = (await nav.boundingBox())!;
        const b = (await content.boundingBox())!;
        assert.ok(viewport.name === 'desktop' ? a.x + a.width <= b.x + 1 : a.y + a.height <= b.y + 1,
          JSON.stringify({ nav: a, content: b }));
        const surface = nav.locator('[data-pill-thumb]');
        const fill = await surface.evaluate((node) => getComputedStyle(node).backgroundColor);
        assert.notEqual(fill, 'rgba(0, 0, 0, 0)');
        const selectedBox = await selected.boundingBox();
        const surfaceBox = await surface.boundingBox();
        assert.ok(selectedBox && surfaceBox && Math.abs(selectedBox.x - surfaceBox.x) <= 1
          && Math.abs(selectedBox.y - surfaceBox.y) <= 1 && Math.abs(selectedBox.width - surfaceBox.width) <= 1
          && Math.abs(selectedBox.height - surfaceBox.height) <= 1);
        const unselected = nav.getByRole('tab', { selected: false }).first();
        await unselected.hover();
        await settle(page);
        assert.notEqual(await unselected.evaluate((node) => getComputedStyle(node).backgroundColor), fill);
        assert.equal(await unselected.evaluate((node) => getComputedStyle(node).backgroundColor), 'rgba(0, 0, 0, 0)');
        assert.deepEqual((await layout(page)).offenders, []);
        await page.screenshot({ path: `../build/agent-verification/browser/${path.slice(1)}-navigation-${viewport.name}.png`, fullPage: true });
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });
  }

  it('大量已完成后继可通过键盘展开和收起', { timeout: 60_000 }, async () => {
    const parent = settledRun(1000, 'succeeded', '订阅源拉取');
    const children = Array.from({ length: 20 }, (_, index) => ({ ...settledRun(index + 1, 'succeeded', '资料图片补全'),
      parent_run_id: parent.id, root_run_id: parent.id, followup_key: `entity-avatar:${index}`, followup_depth: 1 }));
    const opened = await openActivity(browser, [parent, ...children]);
    try {
      const page = opened.page;
      const summary = page.locator('summary').filter({ hasText: '任务明细 · 20 项' });
      assert.equal(await page.locator('[data-followup-key]:visible').count(), 0);
      await summary.focus();
      await page.keyboard.press('Enter');
      await settle(page);
      assert.equal(await page.locator('[data-followup-key]:visible').count(), 20);
      await page.keyboard.press('Enter');
      await page.waitForFunction(() => ![...document.querySelectorAll('details')].find((node) => node.textContent?.includes('任务明细 · 20 项'))?.open);
      assert.equal(await page.locator('[data-followup-key]:visible').count(), 0);
      assert.deepEqual(opened.problems, []);
    } finally { await opened.close(); }
  });
});