290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
// 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');
|
|
});
|
|
});
|