@linhey/rhine-design 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -1
- package/components/AdvancedForms.tsx +184 -0
- package/components/AppShell.tsx +71 -0
- package/components/Background.tsx +29 -0
- package/components/COMMON_COMPONENTS.md +2 -2
- package/components/Charts.tsx +94 -0
- package/components/Data.tsx +98 -18
- package/components/Forms.tsx +14 -8
- package/components/Media.tsx +82 -0
- package/components/Overlays.tsx +4 -3
- package/components/README.md +8 -1
- package/components/StatusTimeline.tsx +50 -12
- package/components/background.css +43 -0
- package/components/charts.css +27 -0
- package/components/common.css +5 -5
- package/components/data.css +6 -0
- package/components/drawer-placement.css +4 -0
- package/components/extensions.css +70 -0
- package/components/index.ts +12 -2
- package/components/media-parity.css +3 -0
- package/components/patterns.css +1 -1
- package/components/styles.css +4 -1
- package/components/theme-arknights.css +10 -5
- package/components/theme-neo-brutalism.css +10 -4
- package/components/timeline.css +2 -2
- package/components/tokens.css +43 -0
- package/docs/ANTD_MUI_AUDIT.md +38 -0
- package/docs/APPLICATION_COMPONENTS.md +175 -0
- package/docs/DESIGN_SYSTEM_AUDIT.md +2 -0
- package/docs/ISSUE_4_VALIDATION.md +21 -0
- package/docs/ISSUE_VALIDATION.md +52 -0
- package/docs/REFERENCE_RESEARCH.md +2 -0
- package/docs/THEMING.md +41 -0
- package/docs/assets/application-shell.png +0 -0
- package/docs/assets/issue4-shell.png +0 -0
- package/docs/assets/media-viewer-mobile.png +0 -0
- package/docs/assets/monitoring-desktop.png +0 -0
- package/docs/audits/DATA_ANTD_MUI.md +75 -0
- package/docs/audits/FORMS_ANTD_MUI.md +88 -0
- package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
- package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
- package/package.json +1 -1
- package/preview/BackgroundGallery.tsx +46 -0
- package/preview/ExtensionsGallery.tsx +95 -0
- package/preview/GalleryShell.tsx +3 -2
- package/preview/MonitoringGallery.tsx +27 -0
- package/preview/backgrounds.css +10 -0
- package/preview/extensions.css +6 -0
- package/preview/main.tsx +10 -1
- package/skills/rhine-design/SKILL.md +24 -3
- package/skills/rhine-design/references/API_MAP.md +4 -0
- package/skills/rhine-design/references/TASK_MAP.md +1 -0
- package/skills/rhine-design/references/integration.md +4 -2
- package/tests/Background.test.tsx +22 -0
- package/tests/DataParity.test.tsx +148 -0
- package/tests/Extensions.test.tsx +145 -0
- package/tests/FormParity.test.tsx +185 -0
- package/tests/Monitoring.test.tsx +38 -0
- package/tests/MonitoringParity.test.tsx +125 -0
- package/tests/ShellMediaParity.test.tsx +180 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { AppShell, Background, DataTable, DateRangePicker, Dialog, MediaCard, MediaViewer, NumberField, Pagination, RemoteMultiSelect, TreeSelect } from '../components';
|
|
5
|
+
import { ThemeProvider } from '../components/theme';
|
|
6
|
+
|
|
7
|
+
describe('controlled form extensions', () => {
|
|
8
|
+
it('blocks global disabled tree expansion while keeping disabled nodes browsable in enabled trees', () => {
|
|
9
|
+
const expand = vi.fn();
|
|
10
|
+
const props = { label: '树', nodes: [{ id: 'root', label: '根', disabled: true, children: [{ id: 'child', label: '子' }] }], value: [], onValueChange: () => {}, expandedKeys: [], onExpandedChange: expand };
|
|
11
|
+
const { rerender } = render(<TreeSelect {...props} disabled />);
|
|
12
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: '根' }), { key: 'ArrowRight' });
|
|
13
|
+
expect(expand).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: '展开根' })).toBeDisabled();
|
|
14
|
+
rerender(<TreeSelect {...props} />);
|
|
15
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: '根' }), { key: 'ArrowRight' }); expect(expand).toHaveBeenCalledWith(['root']);
|
|
16
|
+
});
|
|
17
|
+
it('retains intermediate number input and commits precision only on blur or Enter', () => {
|
|
18
|
+
const change = vi.fn();
|
|
19
|
+
render(<NumberField label="金额" value={1} onValueChange={change} min={-10} max={20} precision={2} step={0.25} />);
|
|
20
|
+
const input = screen.getByRole('spinbutton');
|
|
21
|
+
fireEvent.change(input, { target: { value: '-' } });
|
|
22
|
+
expect(input).toHaveValue('-'); expect(change).not.toHaveBeenCalled();
|
|
23
|
+
fireEvent.blur(input); expect(screen.getByRole('alert')).toHaveTextContent('有效数字');
|
|
24
|
+
fireEvent.change(input, { target: { value: '1.' } }); expect(input).toHaveValue('1.');
|
|
25
|
+
fireEvent.change(input, { target: { value: '1.238' } }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
26
|
+
expect(change).toHaveBeenLastCalledWith(1.24);
|
|
27
|
+
fireEvent.change(input, { target: { value: '21' } }); fireEvent.blur(input);
|
|
28
|
+
expect(input).toHaveValue('21'); expect(change).toHaveBeenCalledTimes(1);
|
|
29
|
+
fireEvent.change(input, { target: { value: '' } }); fireEvent.blur(input);
|
|
30
|
+
expect(change).toHaveBeenLastCalledWith(null);
|
|
31
|
+
});
|
|
32
|
+
it('steps with decimal precision and protects number IME input', () => {
|
|
33
|
+
const change = vi.fn(); render(<NumberField label="金额" value={0.1} onValueChange={change} step={0.2} precision={2} />);
|
|
34
|
+
const input = screen.getByRole('spinbutton');
|
|
35
|
+
fireEvent.compositionStart(input); fireEvent.keyDown(input, { key: 'Enter' }); expect(change).not.toHaveBeenCalled();
|
|
36
|
+
fireEvent.compositionEnd(input); fireEvent.keyDown(input, { key: 'ArrowUp' }); expect(change).toHaveBeenLastCalledWith(0.3);
|
|
37
|
+
});
|
|
38
|
+
it('uses date-only controlled values, range limits, presets and clear intent', () => {
|
|
39
|
+
const change = vi.fn();
|
|
40
|
+
render(<DateRangePicker label="范围" value={{ start: '2026-10-01', end: '2026-10-23' }} min="2026-01-01" max="2026-12-31" onValueChange={change} presets={[{ label: '超出范围', value: { start: '2025-01-01', end: '2025-12-31' } }]} />);
|
|
41
|
+
expect(screen.getByLabelText('结束日期')).toHaveAttribute('min', '2026-10-01');
|
|
42
|
+
expect(screen.getByRole('button', { name: '超出范围' })).toBeDisabled();
|
|
43
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-10-05' } });
|
|
44
|
+
expect(change).toHaveBeenLastCalledWith({ start: '2026-10-05', end: '2026-10-23' });
|
|
45
|
+
fireEvent.click(screen.getByRole('button', { name: '清空日期' })); expect(change).toHaveBeenLastCalledWith({ start: '', end: '' });
|
|
46
|
+
});
|
|
47
|
+
it('keeps remote selections outside the current result set and does not locally filter', () => {
|
|
48
|
+
const change = vi.fn(), query = vi.fn();
|
|
49
|
+
const { rerender } = render(<RemoteMultiSelect label="远程" query="不匹配标签" onQueryChange={query} value={[{ value: 'old', label: '旧结果' }]} onValueChange={change} options={[{ value: 'new', label: '新结果' }]} />);
|
|
50
|
+
const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
51
|
+
expect(change).toHaveBeenLastCalledWith([{ value: 'old', label: '旧结果' }, { value: 'new', label: '新结果' }]);
|
|
52
|
+
rerender(<RemoteMultiSelect label="远程" query="" onQueryChange={query} value={[{ value: 'old', label: '旧结果' }]} onValueChange={change} options={[]} loading />);
|
|
53
|
+
expect(screen.getByText('旧结果')).toBeVisible();
|
|
54
|
+
fireEvent.compositionStart(input); fireEvent.change(input, { target: { value: '中' } }); expect(query).not.toHaveBeenCalled();
|
|
55
|
+
fireEvent.compositionEnd(input); expect(query).toHaveBeenLastCalledWith('中');
|
|
56
|
+
});
|
|
57
|
+
it('navigates tree hierarchy and selects parents independently of children', () => {
|
|
58
|
+
function Demo() {
|
|
59
|
+
const [value, setValue] = useState<string[]>(['child']), [expanded, setExpanded] = useState<string[]>([]);
|
|
60
|
+
return <TreeSelect label="树" nodes={[{ id: 'root', label: '父节点', children: [{ id: 'child', label: '子节点' }, { id: 'locked', label: '禁选', disabled: true }] }]} value={value} onValueChange={setValue} expandedKeys={expanded} onExpandedChange={setExpanded} multiple />;
|
|
61
|
+
}
|
|
62
|
+
render(<Demo />); const parent = screen.getByRole('treeitem', { name: '父节点' }); parent.focus();
|
|
63
|
+
fireEvent.keyDown(parent, { key: 'ArrowRight' }); expect(parent).toHaveAttribute('aria-expanded', 'true');
|
|
64
|
+
fireEvent.keyDown(parent, { key: ' ' }); expect(parent).toHaveAttribute('aria-checked', 'true');
|
|
65
|
+
fireEvent.keyDown(parent, { key: 'ArrowRight' }); const child = screen.getByRole('treeitem', { name: '子节点' });
|
|
66
|
+
expect(child).toHaveFocus(); expect(child).toHaveAttribute('aria-checked', 'true');
|
|
67
|
+
fireEvent.keyDown(child, { key: 'ArrowDown' }); const locked = screen.getByRole('treeitem', { name: '禁选' }); expect(locked).toHaveFocus();
|
|
68
|
+
fireEvent.keyDown(locked, { key: ' ' }); expect(locked).toHaveAttribute('aria-checked', 'false');
|
|
69
|
+
fireEvent.keyDown(locked, { key: 'ArrowLeft' }); expect(parent).toHaveFocus();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('server-owned data', () => {
|
|
74
|
+
it('renders a numeric zero detail inside a region and associates its expansion control', () => {
|
|
75
|
+
render(<DataTable caption="记录" rows={[{ id: 'a' }]} rowKey={row => row.id} columns={[{ key: 'id', header: 'ID', render: row => row.id }]} expansion={{ keys: ['a'], onChange: () => {}, renderDetail: () => 0 }} />);
|
|
76
|
+
const detail = screen.getByRole('region', { name: 'a详情' }); expect(detail).toHaveTextContent('0');
|
|
77
|
+
expect(screen.getByRole('button', { name: '收起a' })).toHaveAttribute('aria-controls', detail.id);
|
|
78
|
+
});
|
|
79
|
+
it('preserves off-page selection and disabled keys while selecting visible rows', () => {
|
|
80
|
+
const change = vi.fn();
|
|
81
|
+
const props = { caption: '记录', rows: [{ id: 'a' }, { id: 'b' }, { id: 'locked' }], rowKey: (row: { id: string }) => row.id, columns: [{ key: 'id', header: 'ID', render: (row: { id: string }) => row.id }] };
|
|
82
|
+
const { rerender } = render(<DataTable {...props} selection={{ keys: ['off-page', 'a', 'locked'], onChange: change, isDisabled: row => row.id === 'locked' }} />);
|
|
83
|
+
expect(screen.getByRole('checkbox', { name: '选择当前可见行' })).toBePartiallyChecked();
|
|
84
|
+
fireEvent.click(screen.getByRole('checkbox', { name: '选择当前可见行' })); expect(change).toHaveBeenLastCalledWith(['off-page', 'a', 'locked', 'b']);
|
|
85
|
+
rerender(<DataTable {...props} selection={{ keys: ['off-page', 'a', 'locked', 'b'], onChange: change, isDisabled: row => row.id === 'locked' }} />);
|
|
86
|
+
fireEvent.click(screen.getByRole('checkbox', { name: '选择当前可见行' })); expect(change).toHaveBeenLastCalledWith(['off-page', 'locked']);
|
|
87
|
+
expect(screen.getByRole('checkbox', { name: '选择locked' })).toBeDisabled();
|
|
88
|
+
});
|
|
89
|
+
it('renders controlled child rows and independent details without sorting', () => {
|
|
90
|
+
const change = vi.fn(); const parent = { id: 'parent' };
|
|
91
|
+
const { rerender } = render(<DataTable caption="层级" rows={[parent]} rowKey={row => row.id} columns={[{ key: 'id', header: 'ID', render: row => row.id }]} expansion={{ keys: [], onChange: change, getChildren: row => row.id === 'parent' ? [{ id: 'child' }] : [] }} />);
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: '展开parent' })); expect(change).toHaveBeenCalledWith(['parent']); expect(screen.queryByText('child')).not.toBeInTheDocument();
|
|
93
|
+
rerender(<DataTable caption="层级" rows={[parent]} rowKey={row => row.id} columns={[{ key: 'id', header: 'ID', render: row => row.id }]} expansion={{ keys: ['parent'], onChange: change, getChildren: row => row.id === 'parent' ? [{ id: 'child' }] : [], renderDetail: row => <p>{row.id}详情内容</p> }} />);
|
|
94
|
+
expect(screen.getByText('child')).toBeVisible(); expect(screen.getByRole('region', { name: 'parent详情' })).toHaveTextContent('详情内容');
|
|
95
|
+
expect(screen.getByText('第 2 层')).toBeVisible();
|
|
96
|
+
});
|
|
97
|
+
it('emits page-size intent without implicitly changing the page', () => {
|
|
98
|
+
const page = vi.fn(), size = vi.fn(); render(<Pagination page={2} pageSize={10} hasNext onPageChange={page} onPageSizeChange={size} />);
|
|
99
|
+
fireEvent.change(screen.getByLabelText('每页条数'), { target: { value: '25' } }); expect(size).toHaveBeenCalledWith(25); expect(page).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('layout and media', () => {
|
|
104
|
+
it('returns focus to a surviving shell control when the focused secondary panel is removed', () => {
|
|
105
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
106
|
+
const bounds = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1200);
|
|
107
|
+
const props = { navigation: { label: '导航', content: <button>导航条目</button>, open: true, onOpenChange: () => {} }, secondary: { label: '详情', content: <button>详情操作</button>, open: true, onOpenChange: () => {} }, children: <p>正文</p> };
|
|
108
|
+
try {
|
|
109
|
+
const { rerender } = render(<AppShell {...props} layout="left-right" />); screen.getByRole('button', { name: '详情操作' }).focus();
|
|
110
|
+
rerender(<AppShell {...props} layout="single" />); expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus();
|
|
111
|
+
} finally { bounds.mockRestore(); vi.unstubAllGlobals(); }
|
|
112
|
+
});
|
|
113
|
+
it('does not introduce a theme root for a local background', () => {
|
|
114
|
+
const { container } = render(<ThemeProvider defaultTheme="neo-brutalism"><Background decoration="none"><p>正文</p></Background></ThemeProvider>);
|
|
115
|
+
expect(container.querySelectorAll('.rhine-theme')).toHaveLength(1); expect(container.querySelector('.rhine-background')).toHaveAttribute('data-decoration', 'none');
|
|
116
|
+
});
|
|
117
|
+
it('retains body input through layout, panel and theme changes', () => {
|
|
118
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
119
|
+
const bounds = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1200);
|
|
120
|
+
function Demo() {
|
|
121
|
+
const [layout, setLayout] = useState<'single' | 'left-right'>('single'), [open, setOpen] = useState(true), [dark, setDark] = useState(false);
|
|
122
|
+
return <ThemeProvider theme={dark ? 'arknights' : 'rhine'}><button onClick={() => { setLayout('left-right'); setDark(true); }}>切换</button><AppShell layout={layout} navigation={{ label: '导航', open, onOpenChange: setOpen, content: <p>导航内容</p> }}><input aria-label="草稿" defaultValue="保留" /></AppShell></ThemeProvider>;
|
|
123
|
+
}
|
|
124
|
+
try { render(<Demo />); const input = screen.getByLabelText('草稿'); fireEvent.change(input, { target: { value: '已修改' } }); fireEvent.click(screen.getByRole('button', { name: '收起导航' })); fireEvent.click(screen.getByText('切换')); expect(screen.getByLabelText('草稿')).toBe(input); expect(input).toHaveValue('已修改'); }
|
|
125
|
+
finally { bounds.mockRestore(); vi.unstubAllGlobals(); }
|
|
126
|
+
});
|
|
127
|
+
it('keeps media selection separate from opening and labels failed previews', () => {
|
|
128
|
+
const open = vi.fn(), select = vi.fn(); render(<MediaCard title="文件" kind="RAW" state="error" onOpen={open} selected onSelectedChange={select} />);
|
|
129
|
+
fireEvent.click(screen.getByRole('checkbox')); expect(select).toHaveBeenCalledWith(false); expect(open).not.toHaveBeenCalled();
|
|
130
|
+
expect(screen.getByText('预览无法解码')).toBeVisible(); expect(screen.getByRole('status')).toHaveTextContent('预览失败');
|
|
131
|
+
const opener = screen.getByRole('button', { name: '打开文件' }); fireEvent.click(opener); expect(open).toHaveBeenCalledOnce(); expect(opener).toHaveFocus();
|
|
132
|
+
});
|
|
133
|
+
it('closes nested dialogs in layers and returns focus to the media opener', async () => {
|
|
134
|
+
render(<ThemeProvider><MediaViewer trigger={<button>打开媒体</button>} title="照片" description="演示" metadataOpen metadata={<Dialog trigger={<button>资源信息</button>} title="资源详情" description="嵌套"><p>信息</p></Dialog>}><img alt="演示" /></MediaViewer></ThemeProvider>);
|
|
135
|
+
const trigger = screen.getByRole('button', { name: '打开媒体' }); trigger.focus(); fireEvent.click(trigger);
|
|
136
|
+
const viewer = await screen.findByRole('dialog', { name: '照片' });
|
|
137
|
+
const details = within(viewer).getByRole('button', { name: '资源信息' }); details.focus(); fireEvent.click(details);
|
|
138
|
+
const nested = await screen.findByRole('dialog', { name: '资源详情' });
|
|
139
|
+
fireEvent.keyDown(nested, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog', { name: '资源详情' })).not.toBeInTheDocument());
|
|
140
|
+
await waitFor(() => expect(details).toHaveFocus()); expect(viewer).toBeVisible();
|
|
141
|
+
fireEvent.keyDown(viewer, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
142
|
+
await waitFor(() => expect(trigger).toHaveFocus());
|
|
143
|
+
await act(async () => {});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Combobox, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('date field parity', () => {
|
|
7
|
+
it('reports controlled invalid dates and changed bounds without rewriting the value', () => {
|
|
8
|
+
const change = vi.fn(), validation = vi.fn();
|
|
9
|
+
const props = { label: '日期', value: '2026-10-24', onValueChange: change, onValidationChange: validation, name: 'date', hint: '仅日期,无时区' };
|
|
10
|
+
const { rerender } = render(<DateField {...props} min="2026-10-01" max="2026-10-31" />);
|
|
11
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
12
|
+
rerender(<DateField {...props} max="2026-10-23" />);
|
|
13
|
+
expect(screen.getByLabelText('日期')).toHaveAttribute('aria-invalid', 'true');
|
|
14
|
+
expect(validation).toHaveBeenLastCalledWith('日期不能晚于 2026-10-23。');
|
|
15
|
+
expect(screen.getByLabelText('日期')).toHaveValue('2026-10-24'); expect(change).not.toHaveBeenCalled();
|
|
16
|
+
rerender(<DateField {...props} value="2026-02-30" />);
|
|
17
|
+
expect(validation).toHaveBeenLastCalledWith('请输入有效日期。');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not repeat validation notifications when only the callback identity changes', () => {
|
|
21
|
+
const validation = vi.fn(); const props = { label: '日期', value: '2026-10-24', onValueChange: () => {}, max: '2026-10-23' };
|
|
22
|
+
const { rerender } = render(<DateField {...props} onValidationChange={error => validation(error)} />);
|
|
23
|
+
rerender(<DateField {...props} onValidationChange={error => validation(error)} />);
|
|
24
|
+
expect(validation).toHaveBeenCalledOnce();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('blocks invalid range presets and protects readonly fields and actions', () => {
|
|
28
|
+
const change = vi.fn();
|
|
29
|
+
const props = { label: '期间', value: { start: '2026-10-01', end: '2026-10-24' }, onValueChange: change, startLabel: '起始', endLabel: '截至', startName: 'from', endName: 'to', presets: [
|
|
30
|
+
{ label: '反向', value: { start: '2026-10-24', end: '2026-10-01' } },
|
|
31
|
+
{ label: '无效日期', value: { start: '2026-02-30', end: '2026-10-01' } },
|
|
32
|
+
{ label: '正常', value: { start: '2026-10-01', end: '2026-10-24' } },
|
|
33
|
+
] };
|
|
34
|
+
const { rerender } = render(<DateRangePicker {...props} />);
|
|
35
|
+
expect(screen.getByRole('button', { name: '反向' })).toBeDisabled();
|
|
36
|
+
expect(screen.getByRole('button', { name: '无效日期' })).toBeDisabled();
|
|
37
|
+
expect(screen.getByLabelText('起始')).toHaveAttribute('name', 'from');
|
|
38
|
+
rerender(<DateRangePicker {...props} readOnly required />);
|
|
39
|
+
expect(screen.getByLabelText('截至')).toHaveAttribute('readonly');
|
|
40
|
+
expect(screen.getByLabelText('起始')).toBeRequired();
|
|
41
|
+
fireEvent.change(screen.getByLabelText('起始'), { target: { value: '2026-10-03' } });
|
|
42
|
+
fireEvent.click(screen.getByRole('button', { name: '正常' }));
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: '清空日期' })); expect(change).not.toHaveBeenCalled();
|
|
44
|
+
rerender(<DateRangePicker {...props} clearable={false} />);
|
|
45
|
+
expect(screen.queryByRole('button', { name: '清空日期' })).not.toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('number field parity', () => {
|
|
50
|
+
it('does not report unchanged numeric or empty values when committing the draft', () => {
|
|
51
|
+
const change = vi.fn(); const props = { label: '数量', value: 4, onValueChange: change };
|
|
52
|
+
const { rerender } = render(<NumberField {...props} />); const input = screen.getByRole('spinbutton');
|
|
53
|
+
fireEvent.focus(input); fireEvent.blur(input); fireEvent.keyDown(input, { key: 'Enter' });
|
|
54
|
+
fireEvent.change(input, { target: { value: '4.00' } }); fireEvent.blur(input);
|
|
55
|
+
expect(input).toHaveValue('4'); expect(change).not.toHaveBeenCalled();
|
|
56
|
+
rerender(<NumberField {...props} value={null} />);
|
|
57
|
+
fireEvent.blur(input); fireEvent.keyDown(input, { key: 'Enter' });
|
|
58
|
+
expect(change).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reports each changed controlled value once across Enter and subsequent blur', () => {
|
|
62
|
+
const change = vi.fn();
|
|
63
|
+
function Demo() {
|
|
64
|
+
const [value, setValue] = useState<number | null>(4);
|
|
65
|
+
return <NumberField label="数量" value={value} precision={1} onValueChange={next => { change(next); setValue(next); }} />;
|
|
66
|
+
}
|
|
67
|
+
render(<Demo />); const input = screen.getByRole('spinbutton');
|
|
68
|
+
fireEvent.change(input, { target: { value: '4.24' } }); fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.blur(input);
|
|
69
|
+
expect(input).toHaveValue('4.2'); expect(change.mock.calls).toEqual([[4.2]]);
|
|
70
|
+
fireEvent.change(input, { target: { value: '' } }); fireEvent.blur(input); fireEvent.blur(input);
|
|
71
|
+
expect(change.mock.calls).toEqual([[4.2], [null]]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves parsed precision on ordinary commits unless precision was explicitly supplied', () => {
|
|
75
|
+
const change = vi.fn(); const props = { label: '小数', value: 1e-13, onValueChange: change };
|
|
76
|
+
const { rerender } = render(<NumberField {...props} />); const input = screen.getByRole('spinbutton');
|
|
77
|
+
fireEvent.blur(input); expect(input).toHaveValue('1e-13'); expect(change).not.toHaveBeenCalled();
|
|
78
|
+
fireEvent.change(input, { target: { value: '1.23456789012345' } }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
79
|
+
expect(change).toHaveBeenLastCalledWith(1.23456789012345);
|
|
80
|
+
rerender(<NumberField {...props} value={0.1} step={0.2} />);
|
|
81
|
+
fireEvent.keyDown(input, { key: 'ArrowUp' }); expect(change).toHaveBeenLastCalledWith(0.3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps readonly values selectable without accepting keyboard, pointer or blur changes', () => {
|
|
85
|
+
const change = vi.fn(); render(<NumberField label="数量" value={4} onValueChange={change} readOnly name="quantity" hint="审核中" />);
|
|
86
|
+
const input = screen.getByRole('spinbutton'); expect(input).not.toBeDisabled(); expect(input).toHaveAttribute('readonly');
|
|
87
|
+
fireEvent.change(input, { target: { value: '5' } }); fireEvent.keyDown(input, { key: 'ArrowUp' }); fireEvent.keyDown(input, { key: 'Enter' }); fireEvent.blur(input);
|
|
88
|
+
expect(screen.getByRole('button', { name: '增加数量' })).toBeDisabled(); expect(change).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('cancels an uncommitted draft with Escape and reports changed bounds without clamping', () => {
|
|
92
|
+
const change = vi.fn(); const props = { label: '数量', value: 4, onValueChange: change };
|
|
93
|
+
const { rerender } = render(<NumberField {...props} />); const input = screen.getByRole('spinbutton');
|
|
94
|
+
fireEvent.change(input, { target: { value: '-' } }); fireEvent.keyDown(input, { key: 'Escape' });
|
|
95
|
+
expect(input).toHaveValue('4'); expect(change).not.toHaveBeenCalled();
|
|
96
|
+
rerender(<NumberField {...props} max={3} controls={false} />);
|
|
97
|
+
expect(input).toHaveValue('4'); expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
98
|
+
expect(screen.queryByRole('button', { name: '增加数量' })).not.toBeInTheDocument(); expect(change).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('allows an empty negative range to be stepped into the range', () => {
|
|
102
|
+
const change = vi.fn(); render(<NumberField label="偏移" value={null} onValueChange={change} min={-10} max={-1} />);
|
|
103
|
+
const increase = screen.getByRole('button', { name: '增加偏移' }); expect(increase).toBeEnabled();
|
|
104
|
+
fireEvent.click(increase); expect(change).toHaveBeenLastCalledWith(-1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('remote multi-select parity', () => {
|
|
109
|
+
it('keeps the list open for consecutive selection and exposes the selected list semantics', () => {
|
|
110
|
+
const query = vi.fn();
|
|
111
|
+
function Demo() {
|
|
112
|
+
const [value, setValue] = useState([{ value: 'old', label: '旧结果' }]);
|
|
113
|
+
return <RemoteMultiSelect label="成员" query="服务端搜索词" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]} name="member" />;
|
|
114
|
+
}
|
|
115
|
+
const { container } = render(<Demo />); const input = screen.getByRole('combobox'); fireEvent.focus(input);
|
|
116
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
117
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
118
|
+
expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true');
|
|
119
|
+
expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
|
|
120
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
121
|
+
expect(screen.getByRole('button', { name: '移除Beta' })).toBeVisible();
|
|
122
|
+
expect(container.querySelectorAll('input[type="hidden"][name="member"]')).toHaveLength(3);
|
|
123
|
+
expect(query).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('removes the last removable tag with Backspace while respecting IME and locked values', () => {
|
|
127
|
+
const change = vi.fn(); render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: '固定', disabled: true }]} onValueChange={change} options={[]} />);
|
|
128
|
+
const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.compositionStart(input);
|
|
129
|
+
fireEvent.keyDown(input, { key: 'Backspace' }); expect(change).not.toHaveBeenCalled();
|
|
130
|
+
fireEvent.compositionEnd(input); fireEvent.keyDown(input, { key: 'Backspace' });
|
|
131
|
+
expect(change).toHaveBeenLastCalledWith([{ value: 'locked', label: '固定', disabled: true }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('restores input focus after removing a tag and supports opt-in query reset', () => {
|
|
135
|
+
const change = vi.fn(), query = vi.fn(); render(<RemoteMultiSelect label="成员" query="a" onQueryChange={query} value={[{ value: 'old', label: '旧结果' }]} onValueChange={change} options={[{ value: 'a', label: 'Alpha' }]} clearQueryOnSelect closeOnSelect />);
|
|
136
|
+
const remove = screen.getByRole('button', { name: '移除旧结果' }); act(() => remove.focus()); fireEvent.click(remove);
|
|
137
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
138
|
+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
|
|
139
|
+
expect(query).toHaveBeenLastCalledWith(''); expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('keeps the existing single Combobox close-on-selection behavior and readonly selections', () => {
|
|
143
|
+
const change = vi.fn(); const props = { label: '单选', query: '', onQueryChange: vi.fn(), value: null, onValueChange: change, options: [{ value: 'a', label: 'Alpha' }] };
|
|
144
|
+
const { rerender } = render(<Combobox {...props} />); const input = screen.getByRole('combobox');
|
|
145
|
+
fireEvent.focus(input); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
146
|
+
expect(input).toHaveAttribute('aria-expanded', 'false'); expect(change).toHaveBeenCalledOnce();
|
|
147
|
+
rerender(<Combobox {...props} readOnly />); fireEvent.focus(input); fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
|
|
148
|
+
expect(input).toHaveAttribute('aria-expanded', 'false'); expect(change).toHaveBeenCalledOnce();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('tree keyboard parity', () => {
|
|
153
|
+
const nodes = [{ id: 'root', label: 'Group', children: [{ id: 'alpha', label: 'Alpha' }, { id: 'beta', label: 'Beta', disabled: true }] }, { id: 'other', label: 'Other', selectable: false, children: [{ id: 'gamma', label: 'Gamma' }] }];
|
|
154
|
+
it('starts at a selected item and supports typeahead and sibling expansion without selecting', () => {
|
|
155
|
+
const change = vi.fn(), expand = vi.fn(); render(<TreeSelect label="目录" nodes={nodes} value={['alpha']} onValueChange={change} expandedKeys={['root']} onExpandedChange={expand} />);
|
|
156
|
+
const alpha = screen.getByRole('treeitem', { name: 'Alpha' }); expect(alpha).toHaveAttribute('tabindex', '0'); act(() => alpha.focus());
|
|
157
|
+
fireEvent.keyDown(alpha, { key: 'b' }); expect(screen.getByRole('treeitem', { name: 'Beta' })).toHaveFocus();
|
|
158
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: 'Beta' }), { key: 'Home' });
|
|
159
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: 'Group' }), { key: '*' });
|
|
160
|
+
expect(expand).toHaveBeenLastCalledWith(['root', 'other']); expect(change).not.toHaveBeenCalled();
|
|
161
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: 'Other' }), { key: 'Enter' }); expect(change).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('selects only visible eligible nodes and preserves offscreen and disabled selections', () => {
|
|
165
|
+
const change = vi.fn(); render(<TreeSelect label="目录" nodes={nodes} value={['offscreen', 'beta']} onValueChange={change} expandedKeys={['root']} onExpandedChange={() => {}} multiple />);
|
|
166
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: 'Group' }), { key: 'a', ctrlKey: true });
|
|
167
|
+
expect(change).toHaveBeenLastCalledWith(['offscreen', 'beta', 'root', 'alpha']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('contains unmatched typeahead inside the focused treeitem instead of bubbling to its parents', () => {
|
|
171
|
+
const outerKey = vi.fn();
|
|
172
|
+
render(<div onKeyDown={outerKey}><TreeSelect label="目录" nodes={nodes} value={[]} onValueChange={() => {}} expandedKeys={['root']} onExpandedChange={() => {}} /></div>);
|
|
173
|
+
const alpha = screen.getByRole('treeitem', { name: 'Alpha' }); act(() => alpha.focus());
|
|
174
|
+
fireEvent.keyDown(alpha, { key: 'z' }); expect(outerKey).not.toHaveBeenCalled(); expect(alpha).toHaveFocus();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns focus to the ancestor when the host collapses a focused child and protects readonly selection', () => {
|
|
178
|
+
const change = vi.fn(), expand = vi.fn(); const props = { label: '目录', nodes, value: ['alpha'], onValueChange: change, onExpandedChange: expand, readOnly: true };
|
|
179
|
+
const { rerender } = render(<TreeSelect {...props} expandedKeys={['root']} />); act(() => screen.getByRole('treeitem', { name: 'Alpha' }).focus());
|
|
180
|
+
fireEvent.keyDown(screen.getByRole('treeitem', { name: 'Alpha' }), { key: ' ' }); expect(change).not.toHaveBeenCalled();
|
|
181
|
+
rerender(<TreeSelect {...props} expandedKeys={[]} />);
|
|
182
|
+
const group = screen.getByRole('treeitem', { name: 'Group' }); expect(group).toHaveFocus();
|
|
183
|
+
fireEvent.keyDown(group, { key: 'ArrowRight' }); expect(expand).toHaveBeenLastCalledWith(['root']);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import { StackedBar, StatusTimeline, TrendChart } from '../components';
|
|
4
|
+
import { ThemeProvider } from '../components/theme';
|
|
5
|
+
|
|
6
|
+
it('exposes the same structured evidence for focus and pointer, preserving zero and unknown', async () => {
|
|
7
|
+
render(<ThemeProvider><StatusTimeline compact label="历史" startLabel="开始" endLabel="结束" segments={[{ id: 'a', label: '时段A', status: 'unknown', detail: { start: '10:00 UTC+8', end: '10:10 UTC+8', reason: '采集不全', counts: [{ id: 'zero', label: '失败', value: 0 }, { id: 'missing', label: '输出', value: null }], methodology: '最终结果' } }]} /></ThemeProvider>);
|
|
8
|
+
const slider = screen.getByRole('slider'); fireEvent.focus(slider);
|
|
9
|
+
expect(await screen.findByRole('dialog', { name: '历史时段详情' })).toHaveTextContent('10:00 UTC+8');
|
|
10
|
+
expect(screen.getByText('0')).toBeVisible(); expect(screen.getByText('未知')).toBeVisible(); expect(screen.getByText('统计口径:最终结果')).toBeVisible();
|
|
11
|
+
fireEvent.keyDown(slider, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
12
|
+
fireEvent.pointerDown(slider, { pointerType: 'touch' }); expect(await screen.findByRole('dialog')).toHaveTextContent('采集不全');
|
|
13
|
+
fireEvent.click(screen.getByRole('button', { name: '关闭时段详情' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('breaks line paths at missing points and supplies exact text data instead of interpolated zero', () => {
|
|
17
|
+
const { container } = render(<TrendChart kind="line" label="资源" unit="%" rangeLabel="今天" description="原始采样" points={[{ id: 'a', label: '10:00', value: 3 }, { id: 'b', label: '10:10', value: null }, { id: 'c', label: '10:20', value: 0 }, { id: 'd', label: '10:30', value: 5 }]} />);
|
|
18
|
+
expect(container.querySelector('.rhine-chart__line')?.getAttribute('d')?.match(/M/g)).toHaveLength(2);
|
|
19
|
+
expect(container.querySelectorAll('.rhine-chart__point')).toHaveLength(3);
|
|
20
|
+
fireEvent.click(screen.getByText('查看资源数据表'));
|
|
21
|
+
expect(screen.getByText('未知')).toBeVisible(); expect(screen.getByRole('cell', { name: '0' })).toBeVisible();
|
|
22
|
+
expect(screen.getByRole('img')).toHaveAccessibleName(/1 个未知值/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not calculate shares with unknown or negative counts', () => {
|
|
26
|
+
const props = { label: '分布', unit: '次', rangeLabel: '今天', description: '按宿主分类' };
|
|
27
|
+
const { rerender } = render(<StackedBar {...props} parts={[{ id: 'a', label: '完成', value: 10 }, { id: 'b', label: '失败', value: null }]} />);
|
|
28
|
+
expect(screen.getByRole('status')).toHaveTextContent('总量未知'); expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(screen.getByRole('list')).not.toHaveTextContent('%');
|
|
29
|
+
rerender(<StackedBar {...props} parts={[{ id: 'a', label: '完成', value: 0 }, { id: 'b', label: '失败', value: -1 }]} />);
|
|
30
|
+
expect(screen.getByRole('status')).toHaveTextContent('负数'); expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
|
31
|
+
rerender(<StackedBar {...props} parts={[{ id: 'a', label: '完成', value: 3 }, { id: 'b', label: '失败', value: 1 }]} />);
|
|
32
|
+
expect(screen.getByRole('list')).toHaveTextContent('75.0%'); expect(screen.getByRole('list')).toHaveTextContent('25.0%');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('retains chart data on error and marks stale data explicitly', () => {
|
|
36
|
+
render(<TrendChart label="请求" unit="次" rangeLabel="今天" description="说明" points={[{ id: 'a', label: '10:00', value: 2 }]} stale="20 分钟前" error="读取失败" />);
|
|
37
|
+
expect(screen.getByRole('alert')).toHaveTextContent('读取失败'); expect(screen.getByRole('status')).toHaveTextContent('20 分钟前'); expect(screen.getByRole('img')).toBeInTheDocument();
|
|
38
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { expect, it, vi } from 'vitest';
|
|
3
|
+
import { StackedBar, StatusTimeline, TrendChart, type TimelineSegment } from '../components';
|
|
4
|
+
|
|
5
|
+
const context = { label: '请求', unit: '次', rangeLabel: '今天', description: '原始采样' };
|
|
6
|
+
const points = [{ id: 'a', label: '10:00', value: 1200 }, { id: 'b', label: '10:10', value: null }, { id: 'c', label: '10:20', value: 0 }];
|
|
7
|
+
const segments: TimelineSegment[] = [{ id: 'a', label: 'A', status: 'healthy', detail: { start: '10:00', end: '10:10' } }, { id: 'b', label: 'B', status: 'unknown', detail: { start: '10:10', end: '10:20' } }];
|
|
8
|
+
|
|
9
|
+
it('inspects exact chart buckets with keyboard, including unknown and zero', () => {
|
|
10
|
+
const changed = vi.fn();
|
|
11
|
+
render(<TrendChart {...context} points={points} onActivePointChange={changed} />);
|
|
12
|
+
const chart = screen.getByRole('img');
|
|
13
|
+
fireEvent.focus(chart);
|
|
14
|
+
expect(chart).toHaveAccessibleDescription('10:00:1200 次');
|
|
15
|
+
fireEvent.keyDown(chart, { key: 'ArrowRight' });
|
|
16
|
+
expect(screen.getByText('10:10:未知')).toBeVisible();
|
|
17
|
+
fireEvent.keyDown(chart, { key: 'End' });
|
|
18
|
+
expect(chart).toHaveAccessibleDescription('10:20:0 次');
|
|
19
|
+
fireEvent.keyDown(chart, { key: 'Home' });
|
|
20
|
+
expect(changed.mock.calls.map(call => call[0])).toEqual(['a', 'b', 'c', 'a']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps controlled chart inspection and preserves raw data when formatting labels', () => {
|
|
24
|
+
const changed = vi.fn(), props = { ...context, points, activePointId: 'a', onActivePointChange: changed, valueFormatter: (value: number | null) => value === null ? '缺失' : value.toLocaleString('en-US') };
|
|
25
|
+
const { rerender } = render(<TrendChart {...props} />);
|
|
26
|
+
fireEvent.keyDown(screen.getByRole('img'), { key: 'ArrowRight' });
|
|
27
|
+
expect(changed).toHaveBeenCalledWith('b');
|
|
28
|
+
expect(screen.getByText('10:00:1,200 次')).toBeVisible();
|
|
29
|
+
rerender(<TrendChart {...props} activePointId="b" />);
|
|
30
|
+
expect(screen.getByText('10:10:缺失')).toBeVisible();
|
|
31
|
+
fireEvent.click(screen.getByText('查看请求数据表'));
|
|
32
|
+
expect(screen.getByRole('cell', { name: '1200' })).toBeVisible();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('preserves active identity through chart reorder and handles removal', () => {
|
|
36
|
+
const { rerender } = render(<TrendChart {...context} points={points} defaultActivePointId="b" />);
|
|
37
|
+
rerender(<TrendChart {...context} points={[points[2], points[1], points[0]]} defaultActivePointId="b" />);
|
|
38
|
+
expect(screen.getByText('10:10:未知')).toBeVisible();
|
|
39
|
+
rerender(<TrendChart {...context} points={[points[0]]} defaultActivePointId="b" />);
|
|
40
|
+
expect(screen.queryByText('10:10:未知')).not.toBeInTheDocument();
|
|
41
|
+
fireEvent.keyDown(screen.getByRole('img'), { key: 'Home' });
|
|
42
|
+
expect(screen.getByRole('img')).toHaveAccessibleDescription('10:00:1200 次');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('only offers chart inspection while at least one finite sample can be plotted', () => {
|
|
46
|
+
const { container, rerender } = render(<TrendChart {...context} points={[]} />);
|
|
47
|
+
expect(screen.getByText('暂无采样')).toBeVisible();
|
|
48
|
+
expect(container.querySelector('.rhine-chart__inspection')).not.toBeInTheDocument();
|
|
49
|
+
rerender(<TrendChart {...context} points={[points[1]]} activePointId="b" />);
|
|
50
|
+
expect(screen.getByText('所有采样值均未知')).toBeVisible();
|
|
51
|
+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
|
52
|
+
expect(container.querySelector('.rhine-chart__inspection')).not.toBeInTheDocument();
|
|
53
|
+
fireEvent.click(screen.getByText('查看请求数据表'));
|
|
54
|
+
expect(screen.getByRole('cell', { name: '10:10' })).toBeVisible();
|
|
55
|
+
expect(screen.getByRole('cell', { name: '未知' })).toBeVisible();
|
|
56
|
+
rerender(<TrendChart {...context} points={points} activePointId="b" />);
|
|
57
|
+
expect(screen.getByRole('img')).toHaveAccessibleDescription('10:10:未知');
|
|
58
|
+
rerender(<TrendChart {...context} points={[{ ...points[0], value: Infinity }, points[1]]} activePointId="b" />);
|
|
59
|
+
expect(container.querySelector('.rhine-chart__inspection')).not.toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps finite chart coordinates at large finite magnitudes and zero shares invisible', () => {
|
|
63
|
+
const { container, rerender } = render(<TrendChart {...context} kind="line" points={[{ id: 'a', label: 'A', value: -Number.MAX_VALUE }, { id: 'b', label: 'B', value: Number.MAX_VALUE }]} />);
|
|
64
|
+
expect(container.querySelector('.rhine-chart__line')?.getAttribute('d')).not.toMatch(/NaN|Infinity/);
|
|
65
|
+
rerender(<StackedBar {...context} parts={[{ id: 'zero', label: '无', value: 0 }, { id: 'one', label: '有', value: 1 }]} />);
|
|
66
|
+
expect(container.querySelectorAll('.rhine-chart__stack > span')).toHaveLength(1);
|
|
67
|
+
expect(screen.getByRole('list')).toHaveTextContent('无:0 次(0.0%)');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('preserves timeline identity across insertion and reorder', () => {
|
|
71
|
+
const { rerender } = render(<StatusTimeline label="历史" startLabel="开始" endLabel="结束" segments={segments} defaultActiveId="a" />);
|
|
72
|
+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuetext', 'A · 有输出·未见异常');
|
|
73
|
+
rerender(<StatusTimeline label="历史" startLabel="开始" endLabel="结束" segments={[segments[1], segments[0]]} defaultActiveId="a" />);
|
|
74
|
+
expect(screen.getByRole('slider')).toHaveValue('1');
|
|
75
|
+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuetext', 'A · 有输出·未见异常');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('allocates the entire percentage bar even when positive counts sum to less than one', () => {
|
|
79
|
+
const { container } = render(<StackedBar {...context} parts={[{ id: 'a', label: 'A', value: 0.1 }, { id: 'b', label: 'B', value: 0.2 }]} />);
|
|
80
|
+
const weights = [...container.querySelectorAll<HTMLElement>('.rhine-chart__stack > span')].map(part => Number(part.style.flexGrow));
|
|
81
|
+
expect(weights.reduce((sum, weight) => sum + weight, 0)).toBeCloseTo(1);
|
|
82
|
+
expect(weights[1] / weights[0]).toBeCloseTo(2);
|
|
83
|
+
expect(screen.getByRole('list')).toHaveTextContent('33.3%');
|
|
84
|
+
expect(screen.getByRole('list')).toHaveTextContent('66.7%');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('reports timeline controlled selection and open intent without overriding host state', async () => {
|
|
88
|
+
const selected = vi.fn(), opened = vi.fn();
|
|
89
|
+
const props = { label: '历史', startLabel: '开始', endLabel: '结束', segments, activeId: 'a', onActiveChange: selected, onOpenChange: opened };
|
|
90
|
+
const { rerender } = render(<StatusTimeline {...props} open={false} />);
|
|
91
|
+
fireEvent.focus(screen.getByRole('slider'));
|
|
92
|
+
expect(opened).toHaveBeenCalledWith(true); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
93
|
+
fireEvent.change(screen.getByRole('slider'), { target: { value: '1' } });
|
|
94
|
+
expect(selected).toHaveBeenCalledWith('b'); expect(screen.getByRole('slider')).toHaveValue('0');
|
|
95
|
+
rerender(<StatusTimeline {...props} open />);
|
|
96
|
+
expect(await screen.findByRole('dialog')).toHaveTextContent('A · 有输出');
|
|
97
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'Escape' });
|
|
98
|
+
expect(opened).toHaveBeenLastCalledWith(false); expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('lets keyboard users enter and dismiss timeline details without reopening on focus return', async () => {
|
|
102
|
+
render(<StatusTimeline label="历史" startLabel="开始" endLabel="结束" segments={segments} />);
|
|
103
|
+
const slider = screen.getByRole('slider');
|
|
104
|
+
expect(slider).not.toHaveAttribute('aria-expanded');
|
|
105
|
+
expect(slider).toHaveAttribute('aria-haspopup', 'dialog');
|
|
106
|
+
act(() => slider.focus()); const detail = await screen.findByRole('dialog');
|
|
107
|
+
expect(slider).not.toHaveAttribute('aria-expanded');
|
|
108
|
+
expect(slider).toHaveAttribute('aria-controls', detail.id);
|
|
109
|
+
expect(slider).toHaveAttribute('aria-details', detail.id);
|
|
110
|
+
fireEvent.keyDown(slider, { key: 'Enter' });
|
|
111
|
+
expect(screen.getByRole('button', { name: '关闭时段详情' })).toHaveFocus();
|
|
112
|
+
fireEvent.click(screen.getByRole('button', { name: '关闭时段详情' }));
|
|
113
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
114
|
+
await waitFor(() => expect(slider).toHaveFocus());
|
|
115
|
+
fireEvent.keyDown(slider, { key: 'Enter' });
|
|
116
|
+
await waitFor(() => expect(screen.getByRole('button', { name: '关闭时段详情' })).toHaveFocus());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('disables timeline interaction even with defaultOpen', () => {
|
|
120
|
+
const opened = vi.fn();
|
|
121
|
+
render(<StatusTimeline label="历史" startLabel="开始" endLabel="结束" segments={segments} disabled defaultOpen onOpenChange={opened} />);
|
|
122
|
+
expect(screen.getByRole('slider')).toBeDisabled();
|
|
123
|
+
fireEvent.pointerMove(screen.getByRole('slider'), { pointerType: 'mouse' });
|
|
124
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(opened).not.toHaveBeenCalled();
|
|
125
|
+
});
|