@linhey/rhine-design 0.4.0 → 0.6.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +24 -7
  3. package/components/AdvancedForms.tsx +184 -0
  4. package/components/AppShell.tsx +71 -0
  5. package/components/Background.tsx +29 -0
  6. package/components/COMMON_COMPONENTS.md +2 -2
  7. package/components/Charts.tsx +94 -0
  8. package/components/Data.tsx +98 -18
  9. package/components/Forms.tsx +14 -8
  10. package/components/MOTION.md +9 -1
  11. package/components/Media.tsx +82 -0
  12. package/components/OrbitBackdrop.tsx +33 -0
  13. package/components/Overlays.tsx +4 -3
  14. package/components/README.md +12 -1
  15. package/components/StatusTimeline.tsx +50 -12
  16. package/components/background.css +43 -0
  17. package/components/charts.css +27 -0
  18. package/components/common.css +5 -5
  19. package/components/data.css +6 -0
  20. package/components/drawer-placement.css +4 -0
  21. package/components/extensions.css +70 -0
  22. package/components/index.ts +14 -2
  23. package/components/media-parity.css +3 -0
  24. package/components/orbit.css +42 -0
  25. package/components/orbitRenderer.ts +230 -0
  26. package/components/patterns.css +1 -1
  27. package/components/styles.css +5 -1
  28. package/components/theme-arknights.css +10 -5
  29. package/components/theme-neo-brutalism.css +10 -4
  30. package/components/timeline.css +2 -2
  31. package/components/tokens.css +43 -0
  32. package/docs/ANTD_MUI_AUDIT.md +38 -0
  33. package/docs/APPLICATION_COMPONENTS.md +175 -0
  34. package/docs/DESIGN_SYSTEM_AUDIT.md +2 -0
  35. package/docs/ISSUE_4_VALIDATION.md +21 -0
  36. package/docs/ISSUE_VALIDATION.md +52 -0
  37. package/docs/ORBIT_BACKDROP.md +81 -0
  38. package/docs/REFERENCE_RESEARCH.md +2 -0
  39. package/docs/THEMING.md +41 -0
  40. package/docs/assets/application-shell.png +0 -0
  41. package/docs/assets/issue4-shell.png +0 -0
  42. package/docs/assets/media-viewer-mobile.png +0 -0
  43. package/docs/assets/monitoring-desktop.png +0 -0
  44. package/docs/assets/orbit-backdrop.png +0 -0
  45. package/docs/audits/DATA_ANTD_MUI.md +75 -0
  46. package/docs/audits/FORMS_ANTD_MUI.md +88 -0
  47. package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
  48. package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
  49. package/package.json +1 -1
  50. package/preview/BackgroundGallery.tsx +46 -0
  51. package/preview/ExtensionsGallery.tsx +95 -0
  52. package/preview/GalleryShell.tsx +4 -3
  53. package/preview/MonitoringGallery.tsx +27 -0
  54. package/preview/OrbitGallery.tsx +47 -0
  55. package/preview/backgrounds.css +10 -0
  56. package/preview/extensions.css +6 -0
  57. package/preview/main.tsx +12 -1
  58. package/skills/rhine-design/SKILL.md +24 -3
  59. package/skills/rhine-design/references/API_MAP.md +6 -0
  60. package/skills/rhine-design/references/TASK_MAP.md +3 -1
  61. package/skills/rhine-design/references/integration.md +4 -2
  62. package/tests/Background.test.tsx +22 -0
  63. package/tests/DataParity.test.tsx +148 -0
  64. package/tests/Extensions.test.tsx +145 -0
  65. package/tests/FormParity.test.tsx +185 -0
  66. package/tests/Monitoring.test.tsx +38 -0
  67. package/tests/MonitoringParity.test.tsx +125 -0
  68. package/tests/OrbitBackdrop.test.tsx +183 -0
  69. package/tests/ShellMediaParity.test.tsx +180 -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,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,183 @@
1
+ import { StrictMode } from 'react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { OrbitBackdrop } from '../components';
5
+ import { mountOrbit } from '../components/orbitRenderer';
6
+ import { OrbitGallery } from '../preview/OrbitGallery';
7
+
8
+ class Media extends EventTarget {
9
+ matches = false;
10
+ set(value: boolean) { this.matches = value; this.dispatchEvent(new Event('change')); }
11
+ }
12
+
13
+ describe('OrbitBackdrop artwork and lifecycle', () => {
14
+ let reduced: Media, compact: Media, hidden: boolean, bounds: { width: number; height: number };
15
+ let frameId: number, frames: Map<number, FrameRequestCallback>;
16
+ let resize: () => void, intersect: (value: boolean) => void;
17
+ let resizeDisconnect: () => void, intersectionDisconnect: () => void;
18
+ let draws: number, lines: number, trails: number, glows: number, firstPoint: number[], firstRadius: number, zoom: number;
19
+ let hasPoint: boolean;
20
+ let context: CanvasRenderingContext2D;
21
+
22
+ beforeEach(() => {
23
+ reduced = new Media(); compact = new Media(); hidden = false;
24
+ bounds = { width: 600, height: 680 }; frameId = 0; frames = new Map();
25
+ draws = lines = trails = glows = firstRadius = 0; firstPoint = []; hasPoint = false; zoom = 1;
26
+ resizeDisconnect = vi.fn(); intersectionDisconnect = vi.fn();
27
+ vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
28
+ vi.spyOn(HTMLCanvasElement.prototype, 'clientWidth', 'get').mockImplementation(() => bounds.width);
29
+ vi.spyOn(HTMLCanvasElement.prototype, 'clientHeight', 'get').mockImplementation(() => bounds.height);
30
+ vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: bounds.width * zoom, height: bounds.height * zoom, x: 0, y: 0, top: 0, left: 0, right: bounds.width * zoom, bottom: bounds.height * zoom, toJSON() {} }));
31
+ vi.stubGlobal('matchMedia', (query: string) => query.includes('prefers-reduced') ? reduced : compact);
32
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { const id = ++frameId; frames.set(id, callback); return id; });
33
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id));
34
+ vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() { resizeDisconnect(); } });
35
+ vi.stubGlobal('IntersectionObserver', class {
36
+ constructor(private callback: (entries: Partial<IntersectionObserverEntry>[]) => void) {}
37
+ observe(target: Element) { intersect = value => this.callback([{ target, isIntersecting: value }]); }
38
+ disconnect() { intersectionDisconnect(); }
39
+ });
40
+ const gradient = { addColorStop() {} };
41
+ context = {
42
+ setTransform() {}, clearRect() { draws++; lines = trails = glows = firstRadius = 0; hasPoint = false; },
43
+ beginPath() {}, closePath() {}, stroke() {}, fill() {}, arc(_x: number, _y: number, radius: number) { if (!firstRadius) firstRadius = radius; },
44
+ moveTo(x: number, y: number) { if (!hasPoint) { hasPoint = true; firstPoint = [x, y]; } },
45
+ lineTo() { lines++; },
46
+ createLinearGradient() { trails++; return gradient; },
47
+ createRadialGradient() { glows++; return gradient; },
48
+ } as unknown as CanvasRenderingContext2D;
49
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
50
+ });
51
+ afterEach(() => { cleanup(); document.documentElement.removeAttribute('data-rhine-motion'); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
52
+
53
+ function advance(now: number) {
54
+ act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(now)); });
55
+ }
56
+
57
+ it('retains the complete seeded artwork, caps pixel density and pauses without catching up', () => {
58
+ vi.stubGlobal('devicePixelRatio', 3);
59
+ const canvas = document.createElement('canvas'); document.body.append(canvas);
60
+ const scene = mountOrbit(canvas, () => {});
61
+ expect(canvas.width).toBe(1200); expect(canvas.height).toBe(1360);
62
+ expect(trails).toBe(420); expect(glows).toBe(140); expect(lines).toBeGreaterThan(185 * 80);
63
+ advance(100); const initial = [...firstPoint]; advance(200); const moving = [...firstPoint];
64
+ expect(moving).not.toEqual(initial);
65
+ scene.setPaused(true); const stoppedDraws = draws;
66
+ expect(frames.size).toBe(0); advance(9000); expect(draws).toBe(stoppedDraws);
67
+ scene.setPaused(false); advance(10000); expect(firstPoint).toEqual(moving);
68
+ advance(10100); expect(firstPoint).not.toEqual(moving);
69
+ scene.dispose(); canvas.remove();
70
+ });
71
+
72
+ it('keeps children and keyboard-capable pause control while preserving the current canvas', () => {
73
+ const { container } = render(<OrbitBackdrop><input aria-label="草稿" defaultValue="保留内容" /></OrbitBackdrop>);
74
+ const canvas = container.querySelector('canvas'); expect(canvas).toHaveAttribute('aria-hidden', 'true');
75
+ const input = screen.getByLabelText('草稿'); fireEvent.change(input, { target: { value: '已经输入' } });
76
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
77
+ expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '继续动效' })).not.toHaveAttribute('aria-pressed');
78
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'paused');
79
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
80
+ expect(frames.size).toBe(1); expect(container.querySelector('canvas')).toBe(canvas); expect(input).toHaveValue('已经输入');
81
+ expect(screen.getByRole('button', { name: '暂停动效' })).not.toHaveAttribute('aria-pressed');
82
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'playing');
83
+ });
84
+
85
+ it('keeps geometry and stroke proportions stable when CSS zoom changes before a pause or resize', () => {
86
+ const { container } = render(<OrbitBackdrop />);
87
+ const canvas = container.querySelector('canvas')!;
88
+ const snapshot = () => ({ width: canvas.width, height: canvas.height, point: [...firstPoint], lineWidth: context.lineWidth, radius: firstRadius });
89
+ const before = snapshot();
90
+ // CSS zoom changes the visual rectangle without changing layout dimensions or issuing RO.
91
+ zoom = 2;
92
+ expect(canvas.getBoundingClientRect().width).toBe(canvas.clientWidth * 2);
93
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
94
+ expect(snapshot()).toEqual(before); expect(frames.size).toBe(0);
95
+ act(() => resize()); expect(snapshot()).toEqual(before);
96
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
97
+ advance(100); expect(snapshot()).toEqual(before); expect(frames.size).toBe(1);
98
+ });
99
+
100
+ it('honors system and explicit reduction, and never lets false bypass the system', () => {
101
+ reduced.matches = true;
102
+ const { rerender } = render(<OrbitBackdrop reducedMotion={false} />);
103
+ expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
104
+ act(() => reduced.set(false)); expect(frames.size).toBe(1);
105
+ rerender(<OrbitBackdrop reducedMotion />); expect(frames.size).toBe(0);
106
+ rerender(<OrbitBackdrop reducedMotion={false} />); expect(frames.size).toBe(1);
107
+ act(() => reduced.set(true)); expect(frames.size).toBe(0);
108
+ });
109
+
110
+ it('synchronizes a media change observed by a frame before the change event arrives', () => {
111
+ const { container } = render(<OrbitBackdrop />);
112
+ advance(100); advance(200); const frozen = [...firstPoint];
113
+ reduced.matches = true;
114
+ advance(300);
115
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'reduced');
116
+ expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
117
+ expect(frames.size).toBe(0); expect(firstPoint).toEqual(frozen);
118
+ const stoppedDraws = draws; advance(9000); expect(draws).toBe(stoppedDraws);
119
+ act(() => reduced.set(false));
120
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'playing');
121
+ expect(screen.getByRole('button', { name: '暂停动效' })).toBeEnabled();
122
+ advance(10000); expect(firstPoint).toEqual(frozen);
123
+ advance(10100); expect(firstPoint).not.toEqual(frozen);
124
+ });
125
+
126
+ it('observes ancestor motion changes even through a local system preference', async () => {
127
+ render(<div data-rhine-motion="system"><OrbitBackdrop /></div>);
128
+ expect(frames.size).toBe(1);
129
+ await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); });
130
+ expect(frames.size).toBe(0);
131
+ await act(async () => { document.documentElement.removeAttribute('data-rhine-motion'); });
132
+ expect(frames.size).toBe(1);
133
+ });
134
+
135
+ it('stops while hidden, offscreen, compact or empty and redraws a paused resize', () => {
136
+ const { container } = render(<OrbitBackdrop />);
137
+ act(() => { hidden = true; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(0);
138
+ act(() => { hidden = false; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(1);
139
+ act(() => intersect(false)); expect(frames.size).toBe(0);
140
+ act(() => intersect(true)); expect(frames.size).toBe(1);
141
+ act(() => compact.set(true)); expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '窄屏静态画面' })).toBeDisabled();
142
+ act(() => compact.set(false)); expect(frames.size).toBe(1);
143
+ act(() => { bounds.width = 0; resize(); }); expect(frames.size).toBe(0);
144
+ act(() => { bounds.width = 400; resize(); }); expect(frames.size).toBe(1);
145
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' })); const beforeResize = draws;
146
+ act(() => { bounds.height = 500; resize(); }); expect(draws).toBeGreaterThan(beforeResize); expect(frames.size).toBe(0);
147
+ expect(container.querySelector('canvas')).toHaveAttribute('height', '500');
148
+ });
149
+
150
+ it('cleans up all scheduling and observers, including Strict Mode mount replay', async () => {
151
+ const visibilitySpy = vi.spyOn(document, 'removeEventListener');
152
+ const resizeSpy = vi.spyOn(window, 'removeEventListener');
153
+ const reducedSpy = vi.spyOn(reduced, 'removeEventListener');
154
+ const compactSpy = vi.spyOn(compact, 'removeEventListener');
155
+ const { unmount } = render(<StrictMode><OrbitBackdrop /></StrictMode>);
156
+ expect(frames.size).toBe(1); unmount(); const finalDraws = draws;
157
+ expect(frames.size).toBe(0); expect(resizeDisconnect).toHaveBeenCalledTimes(2); expect(intersectionDisconnect).toHaveBeenCalledTimes(2);
158
+ expect(visibilitySpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
159
+ expect(resizeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
160
+ expect(reducedSpy).toHaveBeenCalledWith('change', expect.any(Function)); expect(compactSpy).toHaveBeenCalledWith('change', expect.any(Function));
161
+ await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); reduced.set(true); resize(); intersect(true); });
162
+ expect(draws).toBe(finalDraws); expect(frames.size).toBe(0);
163
+ });
164
+
165
+ it.each(['null', 'throw'])('keeps content usable when the canvas context is %s', mode => {
166
+ vi.mocked(HTMLCanvasElement.prototype.getContext).mockImplementation(() => { if (mode === 'throw') throw new Error('Canvas unavailable'); return null; });
167
+ const { container } = render(<OrbitBackdrop><button>继续工作</button></OrbitBackdrop>);
168
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'unavailable');
169
+ expect(screen.getByRole('button', { name: '继续工作' })).toBeEnabled();
170
+ expect(screen.getByRole('button', { name: '静态画面' })).toBeDisabled(); expect(frames.size).toBe(0);
171
+ });
172
+
173
+ it('replays the gallery artwork while preserving entered content and external replay focus', () => {
174
+ const { container } = render(<OrbitGallery />); const canvas = container.querySelector('canvas');
175
+ fireEvent.change(screen.getByRole('textbox', { name: '直接叠加的输入框' }), { target: { value: '新的轨道名称' } });
176
+ fireEvent.change(screen.getByRole('textbox', { name: '工作空间名称' }), { target: { value: '新的工作空间' } });
177
+ const replay = screen.getByRole('button', { name: '重播轨道' }); replay.focus(); fireEvent.click(replay);
178
+ expect(container.querySelector('canvas')).not.toBe(canvas); expect(replay).toHaveFocus(); expect(frames.size).toBe(1);
179
+ expect(screen.getByRole('textbox', { name: '直接叠加的输入框' })).toHaveValue('新的轨道名称');
180
+ expect(screen.getByRole('textbox', { name: '工作空间名称' })).toHaveValue('新的工作空间');
181
+ fireEvent.click(screen.getByRole('switch', { name: '减少轨道动态' })); expect(frames.size).toBe(0);
182
+ });
183
+ });