@linhey/rhine-design 0.3.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 +46 -0
- package/README.md +63 -14
- package/components/Actions.tsx +2 -0
- package/components/AdvancedForms.tsx +184 -0
- package/components/AppShell.tsx +71 -0
- package/components/Background.tsx +29 -0
- package/components/COMMON_COMPONENTS.md +5 -1
- package/components/Charts.tsx +94 -0
- package/components/Data.tsx +98 -18
- package/components/FormWorkflow.tsx +25 -0
- package/components/Forms.tsx +14 -8
- package/components/MOTION.md +6 -2
- package/components/Media.tsx +82 -0
- package/components/Navigation.tsx +42 -0
- package/components/Overlays.tsx +10 -3
- package/components/README.md +20 -5
- package/components/SPACING.md +82 -0
- package/components/StatusTimeline.tsx +67 -0
- package/components/TYPOGRAPHY.md +34 -2
- package/components/background.css +43 -0
- package/components/charts.css +27 -0
- package/components/common.css +17 -8
- package/components/data.css +6 -0
- package/components/drawer-placement.css +4 -0
- package/components/extensions.css +70 -0
- package/components/index.ts +18 -2
- package/components/media-parity.css +3 -0
- package/components/patterns.css +39 -0
- package/components/styles.css +15 -8
- package/components/theme-arknights.css +306 -0
- package/components/theme-neo-brutalism.css +90 -0
- package/components/theme.tsx +58 -0
- package/components/timeline.css +23 -0
- package/components/tokens.css +49 -1
- package/docs/ANTD_MUI_AUDIT.md +38 -0
- package/docs/APPLICATION_COMPONENTS.md +175 -0
- package/docs/DESIGN_SYSTEM.md +12 -2
- package/docs/DESIGN_SYSTEM_AUDIT.md +6 -4
- package/docs/INTERACTION_PATTERNS.md +129 -0
- package/docs/ISSUE_4_VALIDATION.md +21 -0
- package/docs/ISSUE_VALIDATION.md +52 -0
- package/docs/REFERENCE_RESEARCH.md +38 -0
- package/docs/THEME_NEO_BRUTALISM.md +37 -0
- package/docs/THEMING.md +145 -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 +5 -1
- package/preview/BackgroundGallery.tsx +46 -0
- package/preview/CommonGallery.tsx +8 -8
- package/preview/ExtensionsGallery.tsx +95 -0
- package/preview/GalleryShell.tsx +107 -0
- package/preview/LineHeightGallery.tsx +27 -0
- package/preview/MonitoringGallery.tsx +27 -0
- package/preview/MotionGallery.tsx +6 -2
- package/preview/PatternGallery.tsx +52 -0
- package/preview/SpacingGallery.tsx +32 -0
- package/preview/SubscriptionGallery.tsx +84 -0
- package/preview/backgrounds.css +10 -0
- package/preview/extensions.css +6 -0
- package/preview/line-height.css +2 -0
- package/preview/main.tsx +48 -25
- package/preview/patterns.css +7 -0
- package/preview/preview.css +2 -8
- package/preview/shell.css +35 -0
- package/preview/spacing.css +2 -0
- package/preview/subscriptions.css +27 -0
- package/preview/theme-arknights.css +234 -0
- package/preview/theme-neo-brutalism.css +11 -0
- package/skills/rhine-design/SKILL.md +26 -3
- package/skills/rhine-design/references/API_MAP.md +11 -3
- package/skills/rhine-design/references/TASK_MAP.md +5 -1
- package/skills/rhine-design/references/integration.md +7 -3
- 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/GalleryShell.test.tsx +98 -0
- package/tests/Monitoring.test.tsx +38 -0
- package/tests/MonitoringParity.test.tsx +125 -0
- package/tests/Patterns.test.tsx +75 -0
- package/tests/ShellMediaParity.test.tsx +180 -0
- package/tests/StatusTimeline.test.tsx +15 -0
- package/tests/Theme.test.tsx +112 -0
|
@@ -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,98 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { GalleryShell } from '../preview/GalleryShell';
|
|
5
|
+
import { themes } from '@linhey/rhine-design/theme';
|
|
6
|
+
|
|
7
|
+
function Demo() {
|
|
8
|
+
const [motion, setMotion] = useState('system');
|
|
9
|
+
return <GalleryShell motion={motion} onMotionChange={setMotion}>{section => <h1>{section}</h1>}</GalleryShell>;
|
|
10
|
+
}
|
|
11
|
+
function navigate(hash: string) {
|
|
12
|
+
act(() => {
|
|
13
|
+
window.history.replaceState(null, '', hash);
|
|
14
|
+
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
window.history.replaceState(null, '', '/');
|
|
20
|
+
window.localStorage.clear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('gallery theme integration', () => {
|
|
24
|
+
it('uses public metadata, prioritizes URL theme and persists choices without losing the chapter', () => {
|
|
25
|
+
window.localStorage.setItem('rhine-preview-theme', 'rhine');
|
|
26
|
+
window.history.replaceState(null, '', '/?theme=arknights&other=keep#forms');
|
|
27
|
+
render(<Demo />);
|
|
28
|
+
const picker = screen.getByRole('combobox', { name: '界面主题' });
|
|
29
|
+
expect(picker).toHaveValue('arknights');
|
|
30
|
+
expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(themes.map(item => item.label));
|
|
31
|
+
const main = screen.getByRole('main');
|
|
32
|
+
main.scrollTop = 120;
|
|
33
|
+
fireEvent.change(picker, { target: { value: 'neo-brutalism' } });
|
|
34
|
+
expect(main.closest('.rhine-theme')).toHaveAttribute('data-rhine-theme', 'neo-brutalism');
|
|
35
|
+
expect(main.scrollTop).toBe(120);
|
|
36
|
+
expect(screen.getByRole('heading')).toHaveTextContent('forms');
|
|
37
|
+
expect(window.localStorage.getItem('rhine-preview-theme')).toBe('neo-brutalism');
|
|
38
|
+
expect(window.location.search).toBe('?other=keep');
|
|
39
|
+
expect(window.location.hash).toBe('#forms');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('restores a stored theme when the URL is invalid and falls back for unknown stored values', () => {
|
|
43
|
+
window.localStorage.setItem('rhine-preview-theme', 'neo-brutalism');
|
|
44
|
+
window.history.replaceState(null, '', '/?theme=unknown');
|
|
45
|
+
const { unmount } = render(<Demo />);
|
|
46
|
+
expect(screen.getByRole('combobox', { name: '界面主题' })).toHaveValue('neo-brutalism');
|
|
47
|
+
unmount();
|
|
48
|
+
window.localStorage.setItem('rhine-preview-theme', 'unknown');
|
|
49
|
+
render(<Demo />);
|
|
50
|
+
expect(screen.getByRole('combobox', { name: '界面主题' })).toHaveValue('rhine');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('still switches when browser storage is unavailable', () => {
|
|
54
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage disabled'); });
|
|
55
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage disabled'); });
|
|
56
|
+
render(<Demo />);
|
|
57
|
+
const picker = screen.getByRole('combobox', { name: '界面主题' });
|
|
58
|
+
fireEvent.change(picker, { target: { value: 'arknights' } });
|
|
59
|
+
expect(picker).toHaveValue('arknights');
|
|
60
|
+
expect(screen.getByRole('main').closest('.rhine-theme')).toHaveAttribute('data-rhine-theme', 'arknights');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('gallery chapter navigation', () => {
|
|
65
|
+
it('opens deep links and follows history changes with one chapter and a current link', () => {
|
|
66
|
+
window.history.replaceState(null, '', '#typography');
|
|
67
|
+
render(<Demo />);
|
|
68
|
+
expect(screen.getByRole('heading')).toHaveTextContent('typography');
|
|
69
|
+
expect(screen.getByRole('link', {name:'字体与文字'})).toHaveAttribute('aria-current', 'page');
|
|
70
|
+
navigate('#motion');
|
|
71
|
+
expect(screen.getAllByRole('heading')).toHaveLength(1);
|
|
72
|
+
expect(screen.getByRole('heading')).toHaveTextContent('motion');
|
|
73
|
+
expect(screen.getByRole('main')).toHaveFocus();
|
|
74
|
+
navigate('#typography');
|
|
75
|
+
expect(screen.getByRole('heading')).toHaveTextContent('typography');
|
|
76
|
+
navigate('#unknown');
|
|
77
|
+
expect(screen.getByRole('heading')).toHaveTextContent('top');
|
|
78
|
+
});
|
|
79
|
+
it('keeps motion preferences while closing navigation on chapter changes', () => {
|
|
80
|
+
render(<Demo />);
|
|
81
|
+
fireEvent.click(screen.getByRole('radio', {name:'减少动态'}));
|
|
82
|
+
fireEvent.click(screen.getByRole('button', {name:'目录'}));
|
|
83
|
+
expect(screen.getByRole('button', {name:'目录'})).toHaveAttribute('aria-expanded','true');
|
|
84
|
+
navigate('#startup');
|
|
85
|
+
expect(screen.getByRole('button', {name:'目录'})).toHaveAttribute('aria-expanded','false');
|
|
86
|
+
expect(screen.getByRole('main').closest('.rhine-theme')).toHaveAttribute('data-rhine-motion','reduced');
|
|
87
|
+
});
|
|
88
|
+
it('closes the compact directory with Escape and returns focus to its trigger', () => {
|
|
89
|
+
render(<Demo />);
|
|
90
|
+
const trigger = screen.getByRole('button', {name:'目录'});
|
|
91
|
+
fireEvent.click(trigger);
|
|
92
|
+
const link = screen.getByRole('link', {name:'字体与文字'});
|
|
93
|
+
link.focus();
|
|
94
|
+
fireEvent.keyDown(link, {key:'Escape'});
|
|
95
|
+
expect(trigger).toHaveAttribute('aria-expanded','false');
|
|
96
|
+
expect(trigger).toHaveFocus();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { Breadcrumbs, Drawer, ErrorSummary, Fieldset, SideNav, Steps, TextField } from '../components';
|
|
4
|
+
import { PatternGallery } from '../preview/PatternGallery';
|
|
5
|
+
|
|
6
|
+
describe('navigation and form workflow contracts', () => {
|
|
7
|
+
it('keeps split dialog selection and preview in one accessible modal', async () => {
|
|
8
|
+
render(<PatternGallery section="patterns"/>);
|
|
9
|
+
fireEvent.change(screen.getByRole('combobox',{name:'我正在设计什么场景'}),{target:{value:'monitor'}});
|
|
10
|
+
fireEvent.click(screen.getByRole('button',{name:'左右分栏预览'}));
|
|
11
|
+
const dialog=await screen.findByRole('dialog',{name:'选择范围'});
|
|
12
|
+
expect(dialog).toHaveClass('rhine-dialog--split');
|
|
13
|
+
fireEvent.change(within(dialog).getByRole('combobox',{name:'范围'}),{target:{value:'backup'}});
|
|
14
|
+
expect(within(dialog).getByRole('heading',{name:'备用资源 · 健康记录'})).toBeVisible();
|
|
15
|
+
expect(within(dialog).getByRole('slider')).toHaveAttribute('aria-valuetext','时段 96 · 未知 / 无数据');
|
|
16
|
+
fireEvent.keyDown(document.activeElement!,{key:'Escape'});
|
|
17
|
+
await waitFor(()=>expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
18
|
+
});
|
|
19
|
+
it('preserves real navigation links and identifies the current location', () => {
|
|
20
|
+
const navigate = vi.fn();
|
|
21
|
+
render(<><SideNav groups={[{label:'工作区',items:[{id:'a',label:'概览',href:'/overview',icon:'chart'},{id:'b',label:'记录',href:'/records'}]}]} currentId="b" onNavigate={(item,event)=>{event.preventDefault();navigate(item.id);}}/><Breadcrumbs items={[{label:'首页',href:'/'},{label:'记录'}]}/></>);
|
|
22
|
+
expect(screen.getByRole('link',{name:'记录'})).toHaveAttribute('href','/records');
|
|
23
|
+
expect(screen.getByRole('link',{name:'记录'})).toHaveAttribute('aria-current','page');
|
|
24
|
+
fireEvent.click(screen.getByRole('link',{name:'概览'})); expect(navigate).toHaveBeenCalledWith('a');
|
|
25
|
+
expect(within(screen.getByRole('navigation',{name:'当前位置'})).getByText('记录')).toHaveAttribute('aria-current','page');
|
|
26
|
+
});
|
|
27
|
+
it('only permits returning to completed steps and exposes current/error/completed states', () => {
|
|
28
|
+
const change=vi.fn(), items=[{id:'a',label:'输入'},{id:'b',label:'核对'},{id:'c',label:'完成'}];
|
|
29
|
+
const {rerender}=render(<Steps items={items} current={1} onStepChange={change} error="信息不完整"/>);
|
|
30
|
+
expect(screen.getAllByRole('button')).toHaveLength(1);
|
|
31
|
+
fireEvent.click(screen.getByRole('button',{name:'返回输入'}));expect(change).toHaveBeenCalledWith(0);
|
|
32
|
+
expect(screen.getByText('核对').closest('li')).toHaveAttribute('aria-current','step');
|
|
33
|
+
expect(screen.getByText(/需要修正/)).toHaveTextContent('信息不完整');
|
|
34
|
+
rerender(<Steps items={items} current={3}/>);
|
|
35
|
+
expect(screen.getAllByText('已完成')).toHaveLength(3);
|
|
36
|
+
expect(document.querySelector('[aria-current=step]')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('groups fields semantically and supports native disabled fieldsets', () => {
|
|
39
|
+
render(<Fieldset legend="连接" hint="只读任务暂不可编辑" disabled><TextField label="地址"/></Fieldset>);
|
|
40
|
+
expect(screen.getByRole('group',{name:'连接'})).toHaveAccessibleDescription('只读任务暂不可编辑');
|
|
41
|
+
expect(screen.getByRole('textbox',{name:'地址'})).toBeDisabled();
|
|
42
|
+
});
|
|
43
|
+
it('focuses an error summary only for submissions and links back without changing routes', () => {
|
|
44
|
+
const errors=[{fieldId:'workspace',message:'请输入名称'}];
|
|
45
|
+
const {rerender}=render(<><ErrorSummary errors={errors} focusKey={1}/><TextField id="workspace" label="名称"/></>);
|
|
46
|
+
expect(screen.getByRole('region',{name:'请修正以下问题'})).toHaveFocus();
|
|
47
|
+
const before=window.location.hash;fireEvent.click(screen.getByRole('link',{name:'请输入名称'}));
|
|
48
|
+
expect(screen.getByRole('textbox')).toHaveFocus();expect(window.location.hash).toBe(before);
|
|
49
|
+
rerender(<><ErrorSummary errors={[...errors]} focusKey={1}/><TextField id="workspace" label="名称"/></>);
|
|
50
|
+
expect(screen.getByRole('textbox')).toHaveFocus();
|
|
51
|
+
rerender(<><ErrorSummary errors={errors} focusKey={2}/><TextField id="workspace" label="名称"/></>);
|
|
52
|
+
expect(screen.getByRole('region')).toHaveFocus();
|
|
53
|
+
});
|
|
54
|
+
it('opens a themed drawer and restores the trigger focus on Escape', async () => {
|
|
55
|
+
render(<div className="rhine-theme" data-rhine-motion="reduced"><Drawer trigger={<button>查看详情</button>} title="详情" description="记录信息"><TextField label="备注"/></Drawer></div>);
|
|
56
|
+
const trigger=screen.getByRole('button',{name:'查看详情'}); trigger.focus();fireEvent.click(trigger);
|
|
57
|
+
const dialog=await screen.findByRole('dialog'); expect(dialog).toHaveClass('rhine-drawer');
|
|
58
|
+
expect(dialog.closest('.rhine-theme')).toHaveAttribute('data-rhine-motion','reduced');
|
|
59
|
+
expect(dialog).toContainElement(document.activeElement as HTMLElement);
|
|
60
|
+
fireEvent.keyDown(document.activeElement!,{key:'Escape'});
|
|
61
|
+
await waitFor(()=>expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
62
|
+
await waitFor(()=>expect(trigger).toHaveFocus());
|
|
63
|
+
});
|
|
64
|
+
it('retains form input on failure and clears the summary only after valid resubmission', () => {
|
|
65
|
+
render(<PatternGallery section="form-workflow"/>);
|
|
66
|
+
fireEvent.change(screen.getByRole('textbox',{name:'工作区名称'}),{target:{value:'研究'}});
|
|
67
|
+
fireEvent.click(screen.getByRole('button',{name:'验证配置'}));
|
|
68
|
+
expect(screen.getByRole('textbox',{name:'工作区名称'})).toHaveValue('研究');
|
|
69
|
+
expect(screen.getByRole('region')).toHaveFocus();
|
|
70
|
+
fireEvent.change(screen.getByRole('textbox',{name:'服务地址'}),{target:{value:'https://example.com'}});
|
|
71
|
+
fireEvent.click(screen.getByRole('button',{name:'验证配置'}));
|
|
72
|
+
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
|
73
|
+
expect(screen.getByRole('status')).toHaveTextContent('没有保存到服务端');
|
|
74
|
+
});
|
|
75
|
+
});
|