071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
174 lines
5.1 KiB
TypeScript
174 lines
5.1 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { useState } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AutoGrowTextArea } from './AutoGrowTextArea';
|
|
|
|
beforeEach(() => {
|
|
Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect());
|
|
Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList);
|
|
vi.stubGlobal(
|
|
'ResizeObserver',
|
|
class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
},
|
|
);
|
|
vi.stubGlobal(
|
|
'ClipboardEvent',
|
|
class ClipboardEvent extends Event {
|
|
clipboardData: DataTransfer;
|
|
|
|
constructor(
|
|
type: string,
|
|
init: EventInit & { clipboardData: DataTransfer },
|
|
) {
|
|
super(type, init);
|
|
this.clipboardData = init.clipboardData;
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
function pastePlainText(editor: HTMLElement, value: string) {
|
|
const clipboardData = {
|
|
files: [],
|
|
getData: (type: string) => (type === 'text/plain' ? value : ''),
|
|
items: [],
|
|
types: ['text/plain'],
|
|
} as unknown as DataTransfer;
|
|
fireEvent(
|
|
editor,
|
|
new ClipboardEvent('paste', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clipboardData,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function ControlledEditor({
|
|
initialValue = '',
|
|
maxLength,
|
|
}: {
|
|
initialValue?: string;
|
|
maxLength?: number;
|
|
}) {
|
|
const [value, setValue] = useState(initialValue);
|
|
return (
|
|
<>
|
|
<AutoGrowTextArea
|
|
aria-label="受控编辑器"
|
|
value={value}
|
|
maxLength={maxLength}
|
|
onValueChange={setValue}
|
|
/>
|
|
<output aria-label="React 文本状态">{value}</output>
|
|
<button type="button" onClick={() => setValue('外部\n更新')}>
|
|
外部更新
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
describe('AutoGrowTextArea', () => {
|
|
it('keeps React state authoritative and preserves exact line breaks', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ControlledEditor initialValue={'第一行\n第二行'} />);
|
|
|
|
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
|
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
|
'第一行\n第二行',
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '外部更新' }));
|
|
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
|
'外部\n更新',
|
|
);
|
|
});
|
|
|
|
it('publishes edits through onValueChange and enforces maxLength', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ControlledEditor maxLength={4} />);
|
|
|
|
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
|
await user.click(editor);
|
|
pastePlainText(editor, '12345');
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'),
|
|
);
|
|
expect(editor.textContent).toBe('1234');
|
|
});
|
|
|
|
it('supports undo and redo while keeping React state synchronized', async () => {
|
|
const user = userEvent.setup();
|
|
render(<ControlledEditor />);
|
|
|
|
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
|
await user.click(editor);
|
|
pastePlainText(editor, '草稿');
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'),
|
|
);
|
|
|
|
await user.keyboard('{Control>}z{/Control}');
|
|
expect(screen.getByLabelText('React 文本状态').textContent).toBe('');
|
|
|
|
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
|
|
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿');
|
|
});
|
|
|
|
it('exposes read-only and disabled accessibility without form controls', () => {
|
|
const { container } = render(
|
|
<AutoGrowTextArea
|
|
aria-label="只读编辑器"
|
|
value="只读内容"
|
|
disabled
|
|
readOnly
|
|
onValueChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const editor = screen.getByRole('textbox', { name: '只读编辑器' });
|
|
expect(editor.getAttribute('aria-disabled')).toBe('true');
|
|
expect(editor.getAttribute('aria-readonly')).toBe('true');
|
|
expect(editor.getAttribute('tabindex')).toBe('-1');
|
|
expect(container.querySelector('textarea')).toBeNull();
|
|
expect(container.querySelector('form')).toBeNull();
|
|
});
|
|
|
|
it('uses a distinct internal viewport for overflow content', async () => {
|
|
const { container } = render(
|
|
<AutoGrowTextArea
|
|
aria-label="滚动编辑器"
|
|
value={Array.from(
|
|
{ length: 20 },
|
|
(_, index) => `第 ${index + 1} 行`,
|
|
).join('\n')}
|
|
onValueChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const viewport = await waitFor(() => {
|
|
const element = container.querySelector<HTMLElement>(
|
|
'[data-overlayscrollbars-viewport]',
|
|
);
|
|
expect(element).not.toBeNull();
|
|
return element!;
|
|
});
|
|
const content = container.querySelector<HTMLElement>(
|
|
'[data-overlayscrollbars-content]',
|
|
);
|
|
|
|
expect(viewport).not.toBe(content);
|
|
expect(
|
|
viewport.contains(screen.getByRole('textbox', { name: '滚动编辑器' })),
|
|
).toBe(true);
|
|
});
|
|
});
|