This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,289 @@
// tests/unit/renderer/resize-handle-keyboard.test.ts
//
// 守 VerticalResizeHandle 的**键盘操作契约**与**方向约定**
// (src/renderer/src/components/layout/VerticalResizeHandle.tsx 头注指向本文件)。
//
// 契约(每条都对应一个用户可见的后果):
//
// 1. 方向约定 —— 手柄永远在面板的「内侧」:
// side='left' (手柄在面板右沿):ArrowLeft = 面板收窄(-step)
// side='right' (手柄在面板左沿):ArrowLeft = 面板加宽(+step)
// 退化(两个 side 的 ± 号写反)⇒ 键盘用户按左键面板反而变宽,
// 与鼠标拖拽方向正好相反(鼠标往左拖就是收窄)⇒ 用户完全失去方向感。
// 2. 步长:普通键 10px,Shift 加速 50px。
// 退化 ⇒ 长按加速失效 / 加速步长变成 10px,细调与粗调分不出来。
// 3. Home = 跳到 min,End = 跳到 max。
// 退化 ⇒ 键盘用户没法一键到边界,只能长按几十次。
// 4. 方向键必须 preventDefault —— 否则浏览器跟着横向滚动整页。
// 5. a11y 数值必须对得上:role=separator / aria-orientation=vertical /
// aria-valuenow / valuemin / valuemax / aria-valuetext / title。
// px 模式读出 "320 px",pct 模式读出 "32%"(读屏用户不靠像素猜宽度)。
// 6. 双击手柄 = 复位到 defaultWidth;按下 = 开始拖拽(必须是 pointer 事件,
// 只有 pointer 事件能 setPointerCapture,拖出窗口松手才不卡死)。
//
// 本文件是 .ts(指针指定)⇒ 用 React.createElement 而不是 JSX。
//
// 变异思路(下列任一改动会红):
// - 把 arrowLeftAddsWidth 的判定反过来 / 把两个 case 的 ± 写反
// - SHIFT_STEP 改回 NORMAL_STEP
// - 去掉方向键分支里的 e.preventDefault()
// - Home / End 传错 min / max
// - fmt 里的 pct 分支去掉(百分比面板读出 "0.32 px")
// - onDoubleClick / onPointerDown 摘掉
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { createElement } from 'react';
import { VerticalResizeHandle } from '../../../src/renderer/src/components/layout/VerticalResizeHandle';
import { useResizablePanel } from '../../../src/renderer/src/hooks/useResizablePanel';
import type { ResizablePanelApi } from '../../../src/renderer/src/hooks/useResizablePanel';
// jsdom 没有 PointerEvent 构造器(见 memory jsdom-test-gaps)。
// 垫一个继承 MouseEvent 的替身,让 clientX / button / pointerId 不丢。
class FakePointerEvent extends MouseEvent {
pointerId: number;
pointerType: string;
isPrimary: boolean;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
this.pointerType = init.pointerType ?? 'mouse';
this.isPrimary = init.isPrimary ?? true;
}
}
beforeAll(() => {
(window as unknown as { PointerEvent: unknown }).PointerEvent = FakePointerEvent;
});
const NORMAL_STEP = 10;
const SHIFT_STEP = 50;
function makeApi(overrides: Partial<ResizablePanelApi> = {}): ResizablePanelApi {
return {
width: 300,
unit: 'px',
dragging: false,
panelRef: () => {},
onResizeStart: vi.fn(),
onResizeReset: vi.fn(),
setWidth: vi.fn(),
bumpWidth: vi.fn(),
...overrides,
};
}
function renderHandle(
api: ResizablePanelApi,
side: 'left' | 'right' = 'left',
min = 100,
max = 500,
): { handle: HTMLElement; container: HTMLElement; unmount: () => void } {
const utils = render(
createElement(VerticalResizeHandle, { api, side, min, max, ariaLabel: 'resize handle' }),
);
const handle = utils.container.querySelector('[role="separator"]') as HTMLElement;
return { handle, container: utils.container, unmount: utils.unmount };
}
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('VerticalResizeHandle 方向约定(键盘 ± step)', () => {
it("side='left'(手柄在右沿):ArrowLeft 收窄 / ArrowRight 加宽", () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left');
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(api.bumpWidth).toHaveBeenCalledWith(-NORMAL_STEP);
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(api.bumpWidth).toHaveBeenCalledWith(NORMAL_STEP);
});
it("side='right'(手柄在左沿):ArrowLeft 加宽 / ArrowRight 收窄(与 left 相反)", () => {
const api = makeApi();
const { handle } = renderHandle(api, 'right');
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(api.bumpWidth).toHaveBeenCalledWith(NORMAL_STEP);
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(api.bumpWidth).toHaveBeenCalledWith(-NORMAL_STEP);
});
it('Shift = 加速步长 50(普通 10)', () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left');
fireEvent.keyDown(handle, { key: 'ArrowLeft', shiftKey: true });
expect(api.bumpWidth).toHaveBeenCalledWith(-SHIFT_STEP);
fireEvent.keyDown(handle, { key: 'ArrowRight', shiftKey: true });
expect(api.bumpWidth).toHaveBeenCalledWith(SHIFT_STEP);
});
it('方向键 preventDefault(否则整页横向滚动)', () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left');
// fireEvent 返回 dispatchEvent 的结果:被 preventDefault 过就是 false
expect(fireEvent.keyDown(handle, { key: 'ArrowLeft' })).toBe(false);
expect(fireEvent.keyDown(handle, { key: 'ArrowRight' })).toBe(false);
});
it('Home = min,End = max', () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left', 100, 500);
fireEvent.keyDown(handle, { key: 'Home' });
expect(api.setWidth).toHaveBeenCalledWith(100);
fireEvent.keyDown(handle, { key: 'End' });
expect(api.setWidth).toHaveBeenCalledWith(500);
});
it('无关按键不触发任何宽度变更,也不拦默认行为', () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left');
expect(fireEvent.keyDown(handle, { key: 'a' })).toBe(true);
expect(fireEvent.keyDown(handle, { key: 'ArrowUp' })).toBe(true);
expect(fireEvent.keyDown(handle, { key: 'Tab' })).toBe(true);
expect(api.bumpWidth).not.toHaveBeenCalled();
expect(api.setWidth).not.toHaveBeenCalled();
});
});
describe('VerticalResizeHandle a11y 数值', () => {
it('separator 语义与 aria 数值都对得上当前宽度', () => {
const api = makeApi({ width: 320 });
const { handle } = renderHandle(api, 'left', 100, 500);
expect(handle.getAttribute('role')).toBe('separator');
expect(handle.getAttribute('aria-orientation')).toBe('vertical');
expect(handle.getAttribute('aria-valuemin')).toBe('100');
expect(handle.getAttribute('aria-valuemax')).toBe('500');
expect(handle.getAttribute('aria-valuenow')).toBe('320');
expect(handle.getAttribute('aria-valuetext')).toBe('320 px');
expect(handle.getAttribute('title')).toBe('320 px');
expect(handle.getAttribute('aria-label')).toBe('resize handle');
});
it('pct 模式读出百分比而不是像素', () => {
const api = makeApi({ width: 0.32, unit: 'pct' });
const { handle } = renderHandle(api, 'left', 0, 1);
expect(handle.getAttribute('aria-valuetext')).toBe('32%');
expect(handle.getAttribute('title')).toBe('32%');
});
});
describe('VerticalResizeHandle 鼠标交互', () => {
it('双击手柄 = 复位', () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left');
fireEvent.doubleClick(handle);
expect(api.onResizeReset).toHaveBeenCalledTimes(1);
});
it('按下手柄用 pointer 事件(才能 setPointerCapture,拖出窗口不卡死)', () => {
const api = makeApi();
const { handle } = renderHandle(api, 'left');
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100, clientY: 50 });
expect(api.onResizeStart).toHaveBeenCalledTimes(1);
});
});
describe('VerticalResizeHandle 接真 hook:键盘真的改变面板宽度', () => {
function Harness({
side,
min = 100,
max = 500,
defaultWidth = 300,
}: {
side: 'left' | 'right';
min?: number;
max?: number;
defaultWidth?: number;
}) {
const api = useResizablePanel({
storageKey: 'test.resize.handle.width',
defaultWidth,
min,
max,
side,
unit: 'px',
});
return createElement(
'div',
null,
createElement('div', {
ref: api.panelRef,
'data-testid': 'panel',
style: { width: `${api.width}px` },
}),
createElement(VerticalResizeHandle, { api, side, min, max, ariaLabel: 'resize handle' }),
);
}
function panelWidth(): string {
return (document.querySelector('[data-testid="panel"]') as HTMLElement).style.width;
}
it("side='left':ArrowLeft 让面板真的收窄 10px", () => {
const { container } = render(createElement(Harness, { side: 'left' }));
const handle = container.querySelector('[role="separator"]') as HTMLElement;
expect(panelWidth()).toBe('300px');
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(panelWidth()).toBe('290px');
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(panelWidth()).toBe('300px');
});
it("side='right':ArrowLeft 让面板真的加宽 10px(与 left 相反)", () => {
const { container } = render(createElement(Harness, { side: 'right' }));
const handle = container.querySelector('[role="separator"]') as HTMLElement;
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(panelWidth()).toBe('310px');
});
it('连续按键逐步推进,不会被夹死在 min / max 之外', () => {
const { container } = render(createElement(Harness, { side: 'left' }));
const handle = container.querySelector('[role="separator"]') as HTMLElement;
for (let i = 0; i < 40; i += 1) fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(panelWidth()).toBe('100px'); // min
for (let i = 0; i < 60; i += 1) fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(panelWidth()).toBe('500px'); // max
});
it('Home / End 直接落到边界', () => {
const { container } = render(createElement(Harness, { side: 'left' }));
const handle = container.querySelector('[role="separator"]') as HTMLElement;
fireEvent.keyDown(handle, { key: 'End' });
expect(panelWidth()).toBe('500px');
fireEvent.keyDown(handle, { key: 'Home' });
expect(panelWidth()).toBe('100px');
});
it('双击手柄 = 复位到 defaultWidth', () => {
const { container } = render(createElement(Harness, { side: 'left', defaultWidth: 300 }));
const handle = container.querySelector('[role="separator"]') as HTMLElement;
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(panelWidth()).toBe('310px');
fireEvent.doubleClick(handle);
expect(panelWidth()).toBe('300px');
});
});