Ctrl K
ComunidadGuías…

frontend/e2e/management-layout.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, Locator, Page } from 'playwright-core';
import { launch, layout, settle, visit, VIEWPORTS } from './harness.ts';
import { stubServer } from './settings-fixture.ts';

const box = (target: Locator) => target.evaluate(node => {
  const { x, y, width, height } = node.getBoundingClientRect();
  return { x, y, width, height };
});
type Box = Awaited<ReturnType<typeof box>>;
/** 找节点与量矩形在页面里的同一次同步调用里做:骨架会被同一副几何的新节点换下,分两步量会量到已脱离文档的旧节点。 */
const liveBox = async (page: Page, selector: string): Promise<Box> => {
  const found = await page.waitForFunction(sel => {
    const node = document.querySelector(sel);
    if (!node) return null;
    const { x, y, width, height } = node.getBoundingClientRect();
    return { x, y, width, height };
  }, selector, { polling: 'raf' });
  return await found.jsonValue() as Box;
};
const aligned = (waiting: Box, ready: Box, keys: readonly (keyof Box)[] = ['x', 'y', 'width', 'height']) => {
  for (const key of keys) assert.ok(Math.abs(waiting[key] - ready[key]) <= 1, `${key}: ${waiting[key]} / ${ready[key]}`);
};
/* 冷启动时壳层的过渡(`body` 的 padding-left、侧栏宽度等)还在走,整页正文跟着它横移;机器一忙,
   这段过渡会拖得更长。量骨架前先等全部 CSS 过渡落定:这里比的是骨架与接管后的落位,不是量的那一刻
   过渡走到了哪儿。骨架的循环闪光是 CSS 动画,不在等待之列。 */
const transitionsSettled = (page: Page) => page.evaluate(() => Promise.all(document.getAnimations()
  .filter((motion) => motion instanceof CSSTransition)
  .map((motion) => motion.finished.catch(() => undefined))));

describe('管理页面容器与骨架', () => {
  let browser: Browser;
  before(async () => { browser = await launch(); });
  after(async () => { await browser?.close(); });
  for (const reducedMotion of ['no-preference', 'reduce'] as const) {
    it(`管理菜单拖动显示插入位置并保存顺序(${reducedMotion})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/data-cleanup', VIEWPORTS.find(viewport => !viewport.mobile)!);
      const { page } = opened;
      try {
        const server = await stubServer(page);
        await page.emulateMedia({ reducedMotion });
        await page.reload({ waitUntil: 'load' });
        await settle(page);
        const menu = page.locator('[data-manage-menu]');
        await menu.locator('[data-manage="configuration"]').waitFor({ state: 'visible' });
        const keys = () => menu.locator('button[data-manage]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-manage')));
        const original = await keys();
        const picked = menu.locator('[data-manage="activity"]');
        const neighbor = menu.locator('[data-manage="taste"]');
        const start = (await picked.boundingBox())!;
        const target = (await neighbor.boundingBox())!;
        await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2);
        await page.mouse.down();
        await page.mouse.move(target.x + 2, target.y + target.height / 2, { steps: 10 });
        assert.equal(await picked.getAttribute('data-dragging'), '');
        const line = menu.locator('[data-manage-drop-line]');
        assert.ok(await line.isVisible(), '插入线应显示在目标位置');
        assert.notEqual(await line.locator('span').evaluate(node => getComputedStyle(node).backgroundColor), 'rgba(0, 0, 0, 0)');
        assert.equal(await neighbor.evaluate(node => node.style.getPropertyValue('--sidebar-nudge')), '10px');
        assert.equal(await menu.locator('[data-manage="cleanup"]').evaluate(node => node.style.getPropertyValue('--sidebar-delay')), '24ms');
        assert.equal(await menu.locator('[data-pill-thumb]').evaluate(node => getComputedStyle(node).opacity), '0');
        if (reducedMotion === 'no-preference') {
          await page.waitForFunction(() => parseFloat(getComputedStyle(document.querySelector('[data-manage="taste"]')!).translate) > 0);
          await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-manage="activity"]')!).scale === '0.9');
          assert.equal(await picked.evaluate(node => {
            const rect = node.getBoundingClientRect();
            return document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)?.closest('[data-dragging]') === node;
          }), true, '拾起项移出菜单行后仍应完整可见');
        } else {
          assert.equal(await neighbor.evaluate(node => getComputedStyle(node).translate), 'none');
          assert.equal(await picked.evaluate(node => getComputedStyle(node).position), 'static');
        }
        const saved = page.waitForResponse(response => response.url().endsWith('/api/settings') && response.request().method() === 'POST');
        await page.mouse.up();
        const expected = original.filter(key => key !== 'activity');
        expected.splice(1, 0, 'activity');
        await saved;
        assert.deepEqual(await keys(), expected);
        assert.deepEqual(server.posts.find(post => 'manageOrder' in post.body)?.body.manageOrder, expected);
        assert.equal(await menu.locator('[data-dragging],[data-manage-drop-line]').count(), 0);
        assert.equal(await menu.evaluate(node => getComputedStyle(node).overflowY), 'hidden');
        assert.equal(await page.locator('[data-manage-bar] .ov-y:not([hidden])').count(), 0, '放下后菜单右侧应清除纵向滚动条');
        assert.equal(new URL(page.url()).pathname, '/data-cleanup');
        await page.reload({ waitUntil: 'load' });
        await settle(page);
        await menu.locator('[data-manage="configuration"]').waitFor({ state: 'visible' });
        assert.deepEqual(await keys(), expected);
        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, '/data-cleanup', viewport);
      try {
        await settle(opened.page);
        for (const name of ['扫描与采集', '链接管理']) {
          if (name === '链接管理') await opened.page.goto(new URL('/data-cleanup/links', opened.page.url()).href);
          const card = opened.page.locator('section').filter({ has: opened.page.getByRole('heading', { name, exact: true }) }).last();
          const geometry = await card.evaluate(node => {
            const children = Array.from(node.children);
            const body = children[0]!.getBoundingClientRect();
            const footer = children.at(-1)!;
            const rect = footer.getBoundingClientRect();
            return { bodyBottom: body.bottom, footerTop: rect.top, width: rect.width,
              bodyWidth: body.width, border: getComputedStyle(footer).borderTopWidth };
          });
          assert.ok(Math.abs(geometry.bodyBottom - geometry.footerTop) <= 1);
          assert.equal(geometry.width, geometry.bodyWidth);
          assert.equal(geometry.border, '1px');
        }
      } finally { await opened.close(); }
    });
    it(`统计读数和库存图在接管时对齐(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/stats', viewport);
      let release = () => {};
      try {
        const page = opened.page;
        await page.getByRole('tablist', { name: '统计视图' }).waitFor();
        await page.emulateMedia({ reducedMotion: 'no-preference' });
        const cpu = await page.context().newCDPSession(page);
        await cpu.send('Emulation.setCPUThrottlingRate', { rate: 4 });
        const pending = new Promise<void>(resolve => { release = resolve; });
        await page.route('**/api/stats*', async route => {
          const response = await route.fetch();
          await pending;
          await route.fulfill({ response });
        });
        await page.addInitScript(() => {
          const frames: { inventory: boolean; ranking: boolean; metricsY: number; dimensionsY: number }[] = [];
          (window as unknown as { statsPaintFrames: typeof frames }).statsPaintFrames = frames;
          const sample = () => {
            const metrics = document.querySelector('[role="tablist"][aria-label="统计视图"]');
            const dimensions = document.querySelector('[role="tablist"][aria-label="统计维度"]');
            if (metrics && dimensions) frames.push({
              inventory: !!document.querySelector('[role="img"][aria-label="网盘与本地"]'),
              ranking: !!document.querySelector('[role="tabpanel"][id$="tags"]'),
              metricsY: metrics.getBoundingClientRect().y,
              dimensionsY: dimensions.getBoundingClientRect().y,
            });
            if (frames.length < 12) requestAnimationFrame(sample);
          };
          requestAnimationFrame(sample);
        });
        await page.reload({ waitUntil: 'load' });
        const skeleton = page.locator('[data-skeleton="board/stats"]');
        await skeleton.waitFor();
        await transitionsSettled(page);
        const metrics = await box(skeleton.locator('[data-stats-metrics]'));
        const chart = await box(skeleton.locator('[data-stats-chart]').first());
        const waitingLayout = await layout(page);
        release();
        await page.getByRole('tablist', { name: '统计视图' }).waitFor();
        await settle(page);
        aligned(metrics, await box(page.getByRole('tablist', { name: '统计视图' })));
        aligned(chart, await box(page.getByRole('img', { name: '网盘与本地', exact: true }).locator('..')), ['x', 'y', 'width']);
        await page.waitForFunction(() => (window as unknown as { statsPaintFrames: unknown[] }).statsPaintFrames.length >= 12);
        const frames = await page.evaluate(() => (window as unknown as {
          statsPaintFrames: { inventory: boolean; ranking: boolean; metricsY: number; dimensionsY: number }[];
        }).statsPaintFrames);
        assert.ok(frames.every(frame => frame.inventory && frame.ranking), '统计首个可见帧缺少选中页签内容');
        for (const frame of frames) {
          assert.ok(Math.abs(frame.metricsY - frames[0]!.metricsY) <= 1, '读数卡加载完成后发生位移');
          assert.ok(Math.abs(frame.dimensionsY - frames[0]!.dimensionsY) <= 1, '统计维度加载完成后发生位移');
        }
        assert.ok(waitingLayout.scrollWidth <= waitingLayout.viewportWidth);
        assert.deepEqual(opened.problems, []);
      } finally { release(); await opened.close(); }
    });
    it(`重复文件汇总与文件行使用正文尺寸(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/duplicates', viewport);
      let release = () => {};
      try {
        const page = opened.page;
        const pending = new Promise<void>(resolve => { release = resolve; });
        await page.route('**/api/duplicates?**', async route => {
          await pending;
          await route.fulfill({ json: { total: 1, files: 2, reclaimable: 1000, groups: [{
            code: 'DEMO-001', count: 2, identical: true, drives: ['R:'], cross_drive: false, reclaimable: 1000,
            files: [1, 2].map(id => ({ id, name: `DEMO-${id}.mp4`, path: `R:\\Media\\DEMO-${id}.mp4`,
              location: 'local', drive: 'R:', size: 1000, duration: 3600, is_largest: id === 1, is_longest: id === 2 })),
          }] } });
        });
        await page.reload({ waitUntil: 'load' });
        const skeleton = page.locator('[data-skeleton="board/duplicates"]');
        await skeleton.waitFor();
        await transitionsSettled(page);
        const summary = await box(skeleton.locator('[data-collection-summary]'));
        const row = await box(skeleton.locator('.duplicate-row').first());
        release();
        const body = page.locator('#stats section[aria-label="DEMO-001"]');
        await body.waitFor();
        await settle(page);
        aligned(summary, await box(page.locator('#stats [data-collection-summary]')));
        aligned(row, await box(body.locator('.duplicate-row').first()));
        const dimensions = await layout(page);
        assert.ok(dimensions.scrollWidth <= dimensions.viewportWidth);
      } finally { release(); await opened.close(); }
    });
    it(`高清版骨架与落地同一副几何,摘要在两种主题下都和页面底色分得开(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/quality-goals', viewport);
      let release = () => {};
      try {
        const page = opened.page;
        const pending = new Promise<void>(resolve => { release = resolve; });
        await page.route('**/api/quality-goals**', async route => {
          await pending;
          await route.fulfill({ json: { total: 3, offset: 0, has_more: false, items: [1, 2, 3].map(id => ({
            id, name: `DEMO-${id}.mp4`, code: `DEMO-${id}`, location: 'local', size: 1e9, duration: 3600,
            reason: '', cost: '', has_thumb: false, has_cover: false })) } });
        });
        await page.reload({ waitUntil: 'load' });
        const skeleton = page.locator('[data-skeleton="board/quality-goals"]');
        await skeleton.waitFor();
        await transitionsSettled(page);
        const summary = await box(skeleton.locator('[data-collection-summary]'));
        const card = await box(skeleton.locator('.card-grid-cover > li').first());
        release();
        const first = page.locator('#stats li[data-goal-id]').first();
        await first.waitFor();
        await settle(page);
        aligned(summary, await box(page.locator('#stats [data-collection-summary]')));
        aligned(card, await box(first));
        // 浅色下 primary 就是页面底色:摘要取 primary 的话整块化进页面,看不出那是一块面。
        const faces = await page.evaluate(() => {
          const root = document.documentElement;
          const before = { theme: root.dataset.theme, dark: root.classList.contains('dark') };
          const read = (theme: string) => {
            root.dataset.theme = theme;
            root.classList.toggle('dark', theme === 'dark');
            return [getComputedStyle(document.querySelector('#stats [data-collection-summary]')!).backgroundColor,
              getComputedStyle(document.body).backgroundColor];
          };
          const result = { light: read('light'), dark: read('dark') };
          if (before.theme === undefined) delete root.dataset.theme;
          else root.dataset.theme = before.theme;
          root.classList.toggle('dark', before.dark);
          return result;
        });
        for (const [theme, [face, ground]] of Object.entries(faces)) {
          assert.notEqual(face, ground, `${theme} 主题下高清版摘要和页面同一个底色:${face}`);
        }
      } finally { release(); await opened.close(); }
    });
    it(`垃圾卡骨架与落地同一副几何(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/junk-files', viewport);
      let release = () => {};
      try {
        const page = opened.page;
        const pending = new Promise<void>(resolve => { release = resolve; });
        await page.route('**/api/ads?**', async route => {
          await pending;
          await route.fulfill({ json: { total: 2, all_total: 2, dismissed_total: 0, counts: { video: 2 },
            items: [1, 2].map(id => ({ id, name: `推广-${id}.mp4`, junk_kind: 'video', why: '文件名像推广',
              size: 1048576, location: 'local', cost: '' })) } });
        });
        await page.reload({ waitUntil: 'load' });
        await page.locator('#grid .catalog-skeleton .skeletoncard').first().waitFor();
        await transitionsSettled(page);
        const card = await liveBox(page, '#grid .catalog-skeleton .skeletoncard');
        release();
        await page.locator('#grid [data-junk-card]').first().waitFor();
        await settle(page);
        aligned(card, await liveBox(page, '#grid [data-junk-card]'));
      } finally { release(); await opened.close(); }
    });
    it(`回收站网格与标题、汇总栏同宽(${viewport.name})`, { timeout: 60_000 }, async () => {
      const opened = await visit(browser, '/trash', viewport);
      try {
        await opened.page.locator('[data-manage-lede] [data-lede-text]').waitFor();
        await settle(opened.page);
        const title = await box(opened.page.locator('[data-manage-title]'));
        const grid = await box(opened.page.locator('#grid'));
        const lede = await box(opened.page.locator('[data-manage-lede]'));
        aligned(title, grid, ['x', 'width']);
        aligned(title, lede, ['x', 'width']);
        assert.deepEqual(opened.problems, []);
      } finally { await opened.close(); }
    });
  }
});

describe('管理页在极端内容下不撑出视口', () => {
  const narrow = { name: 'narrow', width: 320, height: 720, mobile: true };
  const LONG_PATH = `R:/media/${'很长的没有空格的目录名'.repeat(12)}/FC2-PPV-4728193.mp4`;
  let browser: Browser;
  before(async () => { browser = await launch(); });
  after(async () => { await browser?.close(); });

  it('复核页骨架在窄屏上不越过视口,数据到了以后侧栏仍收着', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/review', narrow);
    let release = () => {};
    try {
      const page = opened.page;
      const pending = new Promise<void>(resolve => { release = resolve; });
      await page.route('**/api/review**', async route => { await pending; await route.continue(); });
      await page.evaluate(() => sessionStorage.removeItem('board.sidebar'));
      await page.reload({ waitUntil: 'load' });
      await page.locator('[data-skeleton="review"]').waitFor();
      const skeleton = await page.evaluate(() => ({
        workspace: document.querySelector('[data-skeleton="review"]')!.getBoundingClientRect().width,
        page: document.documentElement.scrollWidth,
      }));
      assert.ok(skeleton.workspace <= narrow.width, `骨架宽 ${skeleton.workspace}`);
      assert.ok(skeleton.page <= narrow.width, `页面宽 ${skeleton.page}`);
      release();
      await page.locator('[data-skeleton="review"]').waitFor({ state: 'detached' });
      await settle(page);
      assert.equal(await page.evaluate(() => document.body.classList.contains('drawer-open')), false);
    } finally { release(); await opened.close(); }
  });

  it('高清版的判定原因是一长串路径时在卡内折行,不把页面撑宽', { timeout: 60_000 }, async () => {
    const opened = await visit(browser, '/quality-goals', narrow);
    try {
      const page = opened.page;
      await page.route('**/api/quality-goals**', route => route.fulfill({ json: {
        total: 1234567, offset: 0, has_more: true, items: [{
          id: 1, name: 'FC2-PPV-4728193.mp4', code: 'FC2-PPV-4728193', location: 'local', size: 1e9, duration: 3600,
          reason: LONG_PATH, cost: '', has_thumb: false, has_cover: false }] } }));
      await page.reload({ waitUntil: 'load' });
      const card = page.locator('#stats li[data-goal-id]').first();
      await card.waitFor();
      await settle(page);
      const right = (await box(card)).x + (await box(card)).width;
      assert.ok(right <= narrow.width, `卡片右缘 ${right}`);
      assert.ok(await page.evaluate(() => document.documentElement.scrollWidth) <= narrow.width);
      assert.equal(await page.locator('#stats [data-collection-summary] strong').textContent(), '1,234,567 部作品');
      assert.match(await page.locator('#stats').textContent() ?? '', /显示前 1 部,共 1,234,567 部。/);
    } finally { await opened.close(); }
  });
});