@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +63 -14
  3. package/components/Actions.tsx +2 -0
  4. package/components/AdvancedForms.tsx +184 -0
  5. package/components/AppShell.tsx +71 -0
  6. package/components/Background.tsx +29 -0
  7. package/components/COMMON_COMPONENTS.md +5 -1
  8. package/components/Charts.tsx +94 -0
  9. package/components/Data.tsx +98 -18
  10. package/components/FormWorkflow.tsx +25 -0
  11. package/components/Forms.tsx +14 -8
  12. package/components/MOTION.md +6 -2
  13. package/components/Media.tsx +82 -0
  14. package/components/Navigation.tsx +42 -0
  15. package/components/Overlays.tsx +10 -3
  16. package/components/README.md +20 -5
  17. package/components/SPACING.md +82 -0
  18. package/components/StatusTimeline.tsx +67 -0
  19. package/components/TYPOGRAPHY.md +34 -2
  20. package/components/background.css +43 -0
  21. package/components/charts.css +27 -0
  22. package/components/common.css +17 -8
  23. package/components/data.css +6 -0
  24. package/components/drawer-placement.css +4 -0
  25. package/components/extensions.css +70 -0
  26. package/components/index.ts +18 -2
  27. package/components/media-parity.css +3 -0
  28. package/components/patterns.css +39 -0
  29. package/components/styles.css +15 -8
  30. package/components/theme-arknights.css +306 -0
  31. package/components/theme-neo-brutalism.css +90 -0
  32. package/components/theme.tsx +58 -0
  33. package/components/timeline.css +23 -0
  34. package/components/tokens.css +49 -1
  35. package/docs/ANTD_MUI_AUDIT.md +38 -0
  36. package/docs/APPLICATION_COMPONENTS.md +175 -0
  37. package/docs/DESIGN_SYSTEM.md +12 -2
  38. package/docs/DESIGN_SYSTEM_AUDIT.md +6 -4
  39. package/docs/INTERACTION_PATTERNS.md +129 -0
  40. package/docs/ISSUE_4_VALIDATION.md +21 -0
  41. package/docs/ISSUE_VALIDATION.md +52 -0
  42. package/docs/REFERENCE_RESEARCH.md +38 -0
  43. package/docs/THEME_NEO_BRUTALISM.md +37 -0
  44. package/docs/THEMING.md +145 -0
  45. package/docs/assets/application-shell.png +0 -0
  46. package/docs/assets/issue4-shell.png +0 -0
  47. package/docs/assets/media-viewer-mobile.png +0 -0
  48. package/docs/assets/monitoring-desktop.png +0 -0
  49. package/docs/audits/DATA_ANTD_MUI.md +75 -0
  50. package/docs/audits/FORMS_ANTD_MUI.md +88 -0
  51. package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
  52. package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
  53. package/package.json +5 -1
  54. package/preview/BackgroundGallery.tsx +46 -0
  55. package/preview/CommonGallery.tsx +8 -8
  56. package/preview/ExtensionsGallery.tsx +95 -0
  57. package/preview/GalleryShell.tsx +107 -0
  58. package/preview/LineHeightGallery.tsx +27 -0
  59. package/preview/MonitoringGallery.tsx +27 -0
  60. package/preview/MotionGallery.tsx +6 -2
  61. package/preview/PatternGallery.tsx +52 -0
  62. package/preview/SpacingGallery.tsx +32 -0
  63. package/preview/SubscriptionGallery.tsx +84 -0
  64. package/preview/backgrounds.css +10 -0
  65. package/preview/extensions.css +6 -0
  66. package/preview/line-height.css +2 -0
  67. package/preview/main.tsx +48 -25
  68. package/preview/patterns.css +7 -0
  69. package/preview/preview.css +2 -8
  70. package/preview/shell.css +35 -0
  71. package/preview/spacing.css +2 -0
  72. package/preview/subscriptions.css +27 -0
  73. package/preview/theme-arknights.css +234 -0
  74. package/preview/theme-neo-brutalism.css +11 -0
  75. package/skills/rhine-design/SKILL.md +26 -3
  76. package/skills/rhine-design/references/API_MAP.md +11 -3
  77. package/skills/rhine-design/references/TASK_MAP.md +5 -1
  78. package/skills/rhine-design/references/integration.md +7 -3
  79. package/tests/Background.test.tsx +22 -0
  80. package/tests/DataParity.test.tsx +148 -0
  81. package/tests/Extensions.test.tsx +145 -0
  82. package/tests/FormParity.test.tsx +185 -0
  83. package/tests/GalleryShell.test.tsx +98 -0
  84. package/tests/Monitoring.test.tsx +38 -0
  85. package/tests/MonitoringParity.test.tsx +125 -0
  86. package/tests/Patterns.test.tsx +75 -0
  87. package/tests/ShellMediaParity.test.tsx +180 -0
  88. package/tests/StatusTimeline.test.tsx +15 -0
  89. package/tests/Theme.test.tsx +112 -0
@@ -0,0 +1,180 @@
1
+ import { createRef, useState } from 'react';
2
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { AppShell, Background, Dialog, Drawer, MediaCard, MediaViewer } from '../components';
5
+ import { ThemeProvider } from '../components/theme';
6
+
7
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
8
+
9
+ function shellWidth(initialWidth: number) {
10
+ let width = initialWidth, onResize = () => {};
11
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width);
12
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: width * 2 } as DOMRect));
13
+ vi.stubGlobal('ResizeObserver', class {
14
+ constructor(callback: () => void) { onResize = callback; }
15
+ observe() {} disconnect() {}
16
+ });
17
+ return (next: number) => act(() => { width = next; onResize(); });
18
+ }
19
+
20
+ describe('Ant Design / MUI shell and background contracts', () => {
21
+ it('exposes the background DOM ref without creating another theme root or hiding native attributes', () => {
22
+ const ref = createRef<HTMLDivElement>();
23
+ const { container } = render(<ThemeProvider><Background ref={ref} role="region" aria-label="局部背景" className="custom" style={{ padding: 12 }} appearance={{ color: 'pink' }} decoration="none">正文</Background></ThemeProvider>);
24
+ expect(ref.current).toBe(screen.getByRole('region', { name: '局部背景' }));
25
+ expect(ref.current).toHaveClass('rhine-background', 'custom');
26
+ expect(ref.current).toHaveStyle({ padding: '12px', '--rhine-background-color': 'pink' });
27
+ expect(container.querySelectorAll('.rhine-theme')).toHaveLength(1);
28
+ });
29
+
30
+ it('lets the host control the mobile drawer independently from the desktop open state', async () => {
31
+ shellWidth(400);
32
+ const onDrawerOpenChange = vi.fn(), onOpenChange = vi.fn();
33
+ const navigation = { label: '导航', content: <button>导航项目</button>, open: true, onOpenChange, drawerOpen: false, onDrawerOpenChange };
34
+ const { rerender } = render(<AppShell layout="single" navigation={navigation}>正文</AppShell>);
35
+ fireEvent.click(screen.getByRole('button', { name: '导航' }));
36
+ expect(onDrawerOpenChange).toHaveBeenLastCalledWith(true);
37
+ expect(onOpenChange).not.toHaveBeenCalled();
38
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
39
+ rerender(<AppShell layout="single" navigation={{ ...navigation, drawerOpen: true }}>正文</AppShell>);
40
+ expect(screen.getByRole('dialog', { name: '导航' })).toHaveAttribute('data-side', 'left');
41
+ fireEvent.click(screen.getByRole('button', { name: '关闭抽屉' }));
42
+ expect(onDrawerOpenChange).toHaveBeenLastCalledWith(false);
43
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
44
+ rerender(<AppShell layout="single" navigation={navigation}>正文</AppShell>);
45
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
46
+ });
47
+
48
+ it('supports a default-open drawer and keeps its side aligned with the desktop panel', async () => {
49
+ shellWidth(400);
50
+ const panel = { label: '导航', content: <p>导航内容</p>, open: true, onOpenChange: vi.fn() };
51
+ const secondary = { ...panel, label: '详情', defaultDrawerOpen: true };
52
+ const { rerender } = render(<AppShell layout="left-right" navigation={panel} secondary={secondary}>正文</AppShell>);
53
+ expect(screen.getByRole('dialog', { name: '详情' })).toHaveAttribute('data-side', 'right');
54
+ fireEvent.click(screen.getByRole('button', { name: '关闭抽屉' }));
55
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
56
+ rerender(<AppShell layout="double-left" navigation={panel} secondary={secondary}>正文</AppShell>);
57
+ fireEvent.click(screen.getByRole('button', { name: '详情' }));
58
+ expect(screen.getByRole('dialog', { name: '详情' })).toHaveAttribute('data-side', 'left');
59
+ });
60
+
61
+ it('uses custom container breakpoints and widths while returning focus when a pane hides', () => {
62
+ const resize = shellWidth(1000);
63
+ render(<AppShell layout="single" className="custom-shell" style={{ height: 600 }} navigation={{ label: '导航', content: <button>导航项目</button>, open: true, onOpenChange: vi.fn(), width: 300, breakpoint: 900 }}>正文</AppShell>);
64
+ const pane = screen.getByRole('complementary', { name: '导航' });
65
+ expect(pane).toHaveStyle({ flexBasis: '300px' });
66
+ expect(screen.getByRole('region', { name: '应用工作区' })).toHaveClass('custom-shell');
67
+ expect(screen.getByRole('region', { name: '应用工作区' })).toHaveStyle({ height: '600px' });
68
+ screen.getByRole('button', { name: '导航项目' }).focus();
69
+ resize(899);
70
+ expect(pane).not.toBeVisible();
71
+ expect(screen.getByRole('button', { name: '导航' })).toHaveFocus();
72
+ resize(900);
73
+ expect(pane).toBeVisible();
74
+ });
75
+
76
+ it('restores focus when the secondary prop is removed without stealing focus from outside the shell', () => {
77
+ shellWidth(1200);
78
+ const navigation = { label: '导航', content: <button>导航项目</button>, open: true, onOpenChange: vi.fn() };
79
+ const secondary = { ...navigation, label: '详情', content: <button>详情操作</button> };
80
+ const demo = (show: boolean) => <><button>外部操作</button><AppShell layout="left-right" navigation={navigation} secondary={show ? secondary : undefined}>正文</AppShell></>;
81
+ const { rerender } = render(demo(true));
82
+ screen.getByRole('button', { name: '详情操作' }).focus();
83
+ rerender(demo(false)); expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus();
84
+ rerender(demo(true)); screen.getByRole('button', { name: '详情操作' }).focus();
85
+ screen.getByRole('button', { name: '外部操作' }).focus();
86
+ rerender(demo(false)); expect(screen.getByRole('button', { name: '外部操作' })).toHaveFocus();
87
+ });
88
+
89
+ it('keeps the existing Drawer default on the right and supports an explicit left side', () => {
90
+ const props = { trigger: <button>打开</button>, title: '抽屉', description: '描述', open: true, children: <p>内容</p> };
91
+ const { rerender } = render(<Drawer {...props} />);
92
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-side', 'right');
93
+ rerender(<Drawer {...props} side="left" />);
94
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-side', 'left');
95
+ });
96
+ });
97
+
98
+ describe('Ant Design / MUI media contracts', () => {
99
+ it.each(['load', 'error'] as const)('mounts a hidden loading preview so its %s event can complete the host state', event => {
100
+ const completed = vi.fn();
101
+ function Demo() {
102
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
103
+ return <MediaCard title="预览" kind="photo" state={state} onOpen={() => {}} preview={<img alt="缩略图" src="/preview.png" onLoad={() => { completed(); setState('ready'); }} onError={() => { completed(); setState('error'); }} />} />;
104
+ }
105
+ render(<Demo />); const preview = screen.getByAltText('缩略图');
106
+ expect(preview).toBeInTheDocument(); expect(preview).not.toBeVisible();
107
+ expect(screen.getByText('正在加载预览…')).toBeVisible();
108
+ fireEvent[event](preview); expect(completed).toHaveBeenCalledOnce();
109
+ expect(screen.queryByText('正在加载预览…')).not.toBeInTheDocument();
110
+ if (event === 'load') { expect(screen.getByAltText('缩略图')).toBe(preview); expect(preview).toBeVisible(); }
111
+ else expect(screen.getByText('预览无法解码')).toBeVisible();
112
+ });
113
+
114
+ it('keeps card actions separate from selection and opening, including an error retry', () => {
115
+ const onOpen = vi.fn(), onSelectedChange = vi.fn(), retry = vi.fn(), ref = createRef<HTMLElement>();
116
+ const { rerender } = render(<MediaCard ref={ref} title="照片" kind="photo" state="error" onOpen={onOpen} onSelectedChange={onSelectedChange} className="custom-card" style={{ maxWidth: 320 }} actions={<button onClick={retry}>重新加载</button>} />);
117
+ fireEvent.click(screen.getByRole('button', { name: '重新加载' }));
118
+ expect(retry).toHaveBeenCalledOnce(); expect(onOpen).not.toHaveBeenCalled(); expect(onSelectedChange).not.toHaveBeenCalled();
119
+ expect(ref.current).toHaveClass('custom-card'); expect(ref.current).toHaveStyle({ maxWidth: '320px' });
120
+ fireEvent.click(screen.getByRole('checkbox')); expect(onSelectedChange).toHaveBeenCalledWith(true);
121
+ rerender(<MediaCard title="照片" kind="photo" onOpen={onOpen} onSelectedChange={onSelectedChange} disabled />);
122
+ expect(screen.getByRole('button', { name: '打开照片' })).toBeDisabled(); expect(screen.getByRole('checkbox')).toBeDisabled();
123
+ });
124
+
125
+ it('has an uncontrolled default-open viewer and a working uncontrolled metadata toggle', async () => {
126
+ render(<MediaViewer trigger={<button>打开</button>} title="照片" description="描述" defaultOpen defaultMetadataOpen metadata={0} className="custom-viewer" style={{ paddingTop: 4 }}><p>预览</p></MediaViewer>);
127
+ const dialog = screen.getByRole('dialog');
128
+ expect(dialog).toHaveClass('custom-viewer'); expect(dialog).toHaveStyle({ paddingTop: '4px' });
129
+ const information = screen.getByRole('complementary', { name: '媒体信息' });
130
+ expect(information).toHaveTextContent('0');
131
+ expect(screen.getByRole('button', { name: '收起信息' })).toHaveAttribute('aria-controls', information.id);
132
+ fireEvent.click(screen.getByRole('button', { name: '收起信息' })); expect(information).not.toBeVisible();
133
+ fireEvent.click(screen.getByRole('button', { name: '显示信息' })); expect(information).toBeVisible();
134
+ fireEvent.click(screen.getByRole('button', { name: '关闭媒体查看器' }));
135
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
136
+ });
137
+
138
+ it('does not mutate controlled metadata until the host updates the prop', () => {
139
+ const change = vi.fn();
140
+ const props = { trigger: <button>打开</button>, title: '照片', description: '描述', open: true, metadata: <p>详细信息</p>, onMetadataOpenChange: change, children: <p>预览</p> };
141
+ const { rerender } = render(<MediaViewer {...props} metadataOpen={false} />);
142
+ fireEvent.click(screen.getByRole('button', { name: '显示信息' }));
143
+ expect(change).toHaveBeenCalledWith(true); expect(screen.getByText('详细信息')).not.toBeVisible();
144
+ rerender(<MediaViewer {...props} metadataOpen />);
145
+ expect(screen.getByRole('complementary', { name: '媒体信息' })).toBeVisible();
146
+ });
147
+
148
+ it('navigates by arrow keys, respects independent boundaries, and allows opting out', () => {
149
+ const previous = vi.fn(), next = vi.fn();
150
+ const props = { trigger: <button>打开</button>, title: '照片', description: '描述', open: true, onPrevious: previous, onNext: next, children: <p>预览</p> };
151
+ const { rerender } = render(<MediaViewer {...props} previousDisabled />);
152
+ const dialog = screen.getByRole('dialog');
153
+ expect(screen.getByRole('button', { name: '上一项' })).toBeDisabled();
154
+ fireEvent.keyDown(dialog, { key: 'ArrowLeft' }); expect(previous).not.toHaveBeenCalled();
155
+ fireEvent.keyDown(dialog, { key: 'ArrowRight' }); expect(next).toHaveBeenCalledOnce();
156
+ screen.getByRole('button', { name: '下一项' }).focus();
157
+ rerender(<MediaViewer {...props} nextDisabled />);
158
+ expect(dialog).toHaveFocus();
159
+ fireEvent.keyDown(dialog, { key: 'ArrowLeft' }); expect(previous).toHaveBeenCalledOnce();
160
+ fireEvent.keyDown(dialog, { key: 'ArrowRight' }); expect(next).toHaveBeenCalledOnce();
161
+ rerender(<MediaViewer {...props} keyboardNavigation={false} />);
162
+ fireEvent.keyDown(dialog, { key: 'ArrowLeft' }); expect(previous).toHaveBeenCalledOnce();
163
+ expect(screen.getByRole('button', { name: '上一项' })).not.toHaveAttribute('aria-keyshortcuts');
164
+ fireEvent.click(screen.getByRole('button', { name: '下一项' })); expect(next).toHaveBeenCalledTimes(2);
165
+ });
166
+
167
+ it('leaves editor, media, modified, composition and nested dialog arrow keys to their owners', () => {
168
+ const next = vi.fn();
169
+ render(<MediaViewer trigger={<button>打开</button>} title="照片" description="描述" open onNext={next} actions={<Dialog trigger={<button>编辑详情</button>} title="编辑" description="编辑媒体"><button>内部按钮</button></Dialog>}>
170
+ <input aria-label="文件名" /><video aria-label="播放视频" controls /><div contentEditable suppressContentEditableWarning aria-label="注释">文字</div><div role="slider" aria-label="缩放" tabIndex={0} />
171
+ </MediaViewer>);
172
+ for (const control of [screen.getByRole('textbox'), screen.getByLabelText('播放视频'), screen.getByLabelText('注释'), screen.getByRole('slider')]) fireEvent.keyDown(control, { key: 'ArrowRight' });
173
+ const dialog = screen.getByRole('dialog');
174
+ fireEvent.keyDown(dialog, { key: 'ArrowRight', ctrlKey: true });
175
+ fireEvent.keyDown(dialog, { key: 'ArrowRight', isComposing: true });
176
+ fireEvent.click(screen.getByRole('button', { name: '编辑详情' }));
177
+ fireEvent.keyDown(within(screen.getByRole('dialog', { name: '编辑' })).getByRole('button', { name: '内部按钮' }), { key: 'ArrowRight' });
178
+ expect(next).not.toHaveBeenCalled();
179
+ });
180
+ });
@@ -0,0 +1,15 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { StatusTimeline, TimelineLegend } from '../components';
4
+
5
+ it('announces time and evidence with a single native range control, including unknown and empty states',()=>{
6
+ const {rerender}=render(<><StatusTimeline label="账号历史" startLabel="昨天" endLabel="今天" segments={[{id:'a',label:'10:00',status:'failed'},{id:'b',label:'10:15',status:'unknown'}]}/><TimelineLegend/></>);
7
+ const slider=screen.getByRole('slider',{name:'账号历史'});
8
+ expect(slider).toHaveAttribute('aria-valuetext','10:15 · 未知 / 无数据');
9
+ fireEvent.change(slider,{target:{value:'0'}});
10
+ expect(slider).toHaveAttribute('aria-valuetext','10:00 · 存在最终失败');
11
+ expect(screen.getByRole('list',{name:'健康记录图例'})).toHaveTextContent('重试已恢复');
12
+ rerender(<StatusTimeline label="账号历史" startLabel="昨天" endLabel="今天" segments={[]}/>);
13
+ expect(screen.getByRole('slider')).toBeDisabled();
14
+ expect(screen.getByText('暂无记录')).toBeVisible();
15
+ });
@@ -0,0 +1,112 @@
1
+ import { useState } from 'react';
2
+ import { fireEvent, render, screen, within } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ThemeProvider, themes, useTheme, type ThemeName } from '@linhey/rhine-design/theme';
5
+ import { Button, Dialog } from '../components';
6
+
7
+ function Controls() {
8
+ const { theme, setTheme } = useTheme();
9
+ const [count, setCount] = useState(0);
10
+ return <>
11
+ <output aria-label="当前主题">{theme}</output>
12
+ {themes.map(item => <button key={item.name} onClick={() => setTheme(item.name)}>{item.label}</button>)}
13
+ <button onClick={() => setCount(count + 1)}>计数 {count}</button>
14
+ <input aria-label="草稿" defaultValue="" />
15
+ </>;
16
+ }
17
+
18
+ describe('public theme API', () => {
19
+ it('defaults to rhine and preserves the DOM, state and input across all themes', () => {
20
+ const onThemeChange = vi.fn();
21
+ render(<ThemeProvider onThemeChange={onThemeChange} className="host" id="app" data-rhine-motion="reduced"><Controls /></ThemeProvider>);
22
+ const root = document.getElementById('app')!;
23
+ expect(root).toHaveClass('rhine-theme', 'host');
24
+ expect(root).toHaveAttribute('data-rhine-motion', 'reduced');
25
+ expect(root).toHaveAttribute('data-rhine-theme', 'rhine');
26
+ fireEvent.click(screen.getByRole('button', { name: '计数 0' }));
27
+ const input = screen.getByRole('textbox', { name: '草稿' });
28
+ fireEvent.change(input, { target: { value: '未保存的内容' } });
29
+ for (const name of ['arknights', 'neo-brutalism', 'rhine'] as const) {
30
+ fireEvent.click(screen.getByRole('button', { name: themes.find(item => item.name === name)!.label }));
31
+ expect(root).toHaveAttribute('data-rhine-theme', name);
32
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent(name);
33
+ expect(screen.getByRole('textbox')).toBe(input);
34
+ expect(input).toHaveValue('未保存的内容');
35
+ expect(screen.getByRole('button', { name: '计数 1' })).toBeInTheDocument();
36
+ }
37
+ expect(onThemeChange.mock.calls).toEqual([['arknights'], ['neo-brutalism'], ['rhine']]);
38
+ fireEvent.click(screen.getByRole('button', { name: '莱茵 · 纸面' }));
39
+ expect(onThemeChange).toHaveBeenCalledTimes(3);
40
+ });
41
+
42
+ it('uses defaultTheme only on mount', () => {
43
+ const { rerender } = render(<ThemeProvider defaultTheme="arknights"><Controls /></ThemeProvider>);
44
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('arknights');
45
+ rerender(<ThemeProvider defaultTheme="neo-brutalism"><Controls /></ThemeProvider>);
46
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('arknights');
47
+ });
48
+
49
+ it('leaves controlled updates to the host and follows subsequent props', () => {
50
+ const onThemeChange = vi.fn();
51
+ const { rerender } = render(<ThemeProvider theme="rhine" onThemeChange={onThemeChange}><Controls /></ThemeProvider>);
52
+ fireEvent.click(screen.getByRole('button', { name: '明日方舟 · 战术终端' }));
53
+ expect(onThemeChange).toHaveBeenCalledWith('arknights');
54
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('rhine');
55
+ rerender(<ThemeProvider theme="arknights" onThemeChange={onThemeChange}><Controls /></ThemeProvider>);
56
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('arknights');
57
+ expect(onThemeChange).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it('keeps sibling providers independent', () => {
61
+ render(<><ThemeProvider aria-label="A"><Controls /></ThemeProvider><ThemeProvider aria-label="B" defaultTheme="neo-brutalism"><Controls /></ThemeProvider></>);
62
+ const a = screen.getByLabelText('A'), b = screen.getByLabelText('B');
63
+ fireEvent.click(within(a).getByRole('button', { name: '明日方舟 · 战术终端' }));
64
+ expect(a).toHaveAttribute('data-rhine-theme', 'arknights');
65
+ expect(b).toHaveAttribute('data-rhine-theme', 'neo-brutalism');
66
+ expect(within(b).getByLabelText('当前主题')).toHaveTextContent('neo-brutalism');
67
+ });
68
+
69
+ it('does not read or write host storage', () => {
70
+ const read = vi.spyOn(Storage.prototype, 'getItem');
71
+ const write = vi.spyOn(Storage.prototype, 'setItem');
72
+ try {
73
+ render(<ThemeProvider><Controls /></ThemeProvider>);
74
+ fireEvent.click(screen.getByRole('button', { name: '明日方舟 · 战术终端' }));
75
+ expect(read).not.toHaveBeenCalled();
76
+ expect(write).not.toHaveBeenCalled();
77
+ } finally { read.mockRestore(); write.mockRestore(); }
78
+ });
79
+
80
+ it('keeps an open dialog in the same theme root, preserving its draft and focus', () => {
81
+ render(<ThemeProvider><Dialog trigger={<Button>打开</Button>} title="主题测试" description="保持浮层状态"><Controls /></Dialog></ThemeProvider>);
82
+ const trigger = screen.getByRole('button', { name: '打开' });
83
+ fireEvent.click(trigger);
84
+ const dialog = screen.getByRole('dialog');
85
+ const root = trigger.closest('.rhine-theme');
86
+ expect(dialog.closest('.rhine-theme')).toBe(root);
87
+ fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: '浮层草稿' } });
88
+ const switcher = within(dialog).getByRole('button', { name: '明日方舟 · 战术终端' });
89
+ switcher.focus();
90
+ fireEvent.click(switcher);
91
+ expect(root).toHaveAttribute('data-rhine-theme', 'arknights');
92
+ expect(screen.getByRole('dialog')).toBe(dialog);
93
+ expect(within(dialog).getByRole('textbox')).toHaveValue('浮层草稿');
94
+ expect(switcher).toHaveFocus();
95
+ });
96
+
97
+ it('explains missing providers, nested providers and invalid theme names', () => {
98
+ expect(() => render(<Controls />)).toThrow('useTheme must be used within');
99
+ expect(() => render(<ThemeProvider><ThemeProvider /></ThemeProvider>)).toThrow('Nested Rhine ThemeProvider is not supported');
100
+ expect(() => render(<ThemeProvider theme={'unknown' as ThemeName} />)).toThrow('Unknown Rhine theme');
101
+ });
102
+
103
+ it('rejects invalid imperative requests without updating state or notifying the host', () => {
104
+ let setTheme: (theme: ThemeName) => void = () => {};
105
+ function Capture() { setTheme = useTheme().setTheme; return null; }
106
+ const onThemeChange = vi.fn();
107
+ render(<ThemeProvider onThemeChange={onThemeChange}><Capture /><Controls /></ThemeProvider>);
108
+ expect(() => setTheme('unknown' as ThemeName)).toThrow('Unknown Rhine theme');
109
+ expect(screen.getByLabelText('当前主题')).toHaveTextContent('rhine');
110
+ expect(onThemeChange).not.toHaveBeenCalled();
111
+ });
112
+ });