@linhey/rhine-design 0.5.0 → 0.8.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 (100) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +26 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AppShell.tsx +46 -17
  5. package/components/MOTION.md +9 -1
  6. package/components/Media.tsx +7 -7
  7. package/components/NeutronStarBackdrop.tsx +43 -0
  8. package/components/OrbitBackdrop.tsx +35 -0
  9. package/components/Overlays.tsx +37 -8
  10. package/components/README.md +30 -2
  11. package/components/StatusTimeline.tsx +72 -6
  12. package/components/StatusTimelineList.tsx +38 -0
  13. package/components/ThemePicker.tsx +49 -0
  14. package/components/assets/orbit-bird.svg +578 -0
  15. package/components/assets/orbit-crystal.svg +11 -0
  16. package/components/assets/orbit-donut.svg +16 -0
  17. package/components/assets/theme-previews/arknights.png +0 -0
  18. package/components/assets/theme-previews/manifest.json +24 -0
  19. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  20. package/components/assets/theme-previews/rhine.png +0 -0
  21. package/components/common.css +3 -3
  22. package/components/components.tsx +3 -3
  23. package/components/dialog-settings.css +9 -0
  24. package/components/extensions.css +8 -32
  25. package/components/index.ts +13 -5
  26. package/components/media-parity.css +55 -2
  27. package/components/neutron.css +53 -0
  28. package/components/neutronRenderer.ts +327 -0
  29. package/components/orbit.css +120 -0
  30. package/components/orbitBirdFlight.ts +34 -0
  31. package/components/orbitBirdGeometry.ts +158 -0
  32. package/components/orbitDonut.ts +134 -0
  33. package/components/orbitDonutThree.ts +208 -0
  34. package/components/orbitObjectMaterials.ts +76 -0
  35. package/components/orbitObjects.ts +71 -0
  36. package/components/orbitObjectsThree.ts +215 -0
  37. package/components/orbitProjection.ts +21 -0
  38. package/components/orbitRenderer.ts +347 -0
  39. package/components/styles.css +4 -1
  40. package/components/theme-neo-brutalism.css +3 -2
  41. package/components/theme-picker.css +17 -0
  42. package/components/theme.tsx +2 -8
  43. package/components/themeMetadata.ts +7 -0
  44. package/components/timeline.css +17 -6
  45. package/components/useFloatingScale.ts +96 -0
  46. package/docs/APPLICATION_COMPONENTS.md +52 -3
  47. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  48. package/docs/DATE_RANGE_FILTER.md +60 -0
  49. package/docs/DESIGN_SYSTEM.md +7 -0
  50. package/docs/FLOATING_OVERLAYS.md +15 -0
  51. package/docs/INTERACTION_PATTERNS.md +5 -1
  52. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  53. package/docs/ORBIT_BACKDROP.md +73 -0
  54. package/docs/SETTINGS_DIALOG.md +21 -0
  55. package/docs/THEME_PICKER.md +26 -0
  56. package/docs/THEMING.md +2 -0
  57. package/docs/assets/compact-account-neo.png +0 -0
  58. package/docs/assets/neutron-star-original.png +0 -0
  59. package/docs/assets/neutron-star-theme.png +0 -0
  60. package/docs/assets/orbit-backdrop.png +0 -0
  61. package/docs/assets/orbit-bird.png +0 -0
  62. package/docs/assets/orbit-crystal.png +0 -0
  63. package/docs/assets/orbit-strawberry.png +0 -0
  64. package/package.json +8 -3
  65. package/preview/CompactAccountGallery.tsx +178 -0
  66. package/preview/DateRangeFilterGallery.tsx +109 -0
  67. package/preview/ExtensionsGallery.tsx +27 -18
  68. package/preview/GalleryShell.tsx +3 -3
  69. package/preview/MonitoringGallery.tsx +2 -0
  70. package/preview/NeutronStarGallery.tsx +56 -0
  71. package/preview/OrbitGallery.tsx +50 -0
  72. package/preview/OverlayBoundsGallery.tsx +49 -0
  73. package/preview/SettingsDialogGallery.tsx +33 -0
  74. package/preview/ThemePickerGallery.tsx +24 -0
  75. package/preview/ThemeSample.tsx +14 -0
  76. package/preview/TimelineListGallery.tsx +36 -0
  77. package/preview/extensions.css +9 -0
  78. package/preview/main.tsx +19 -4
  79. package/preview/preview.css +8 -0
  80. package/preview/theme-sample.html +1 -0
  81. package/scripts/check-overlay-bounds.js +76 -0
  82. package/scripts/check-settings-theme.js +88 -0
  83. package/skills/rhine-design/SKILL.md +3 -2
  84. package/skills/rhine-design/references/API_MAP.md +14 -1
  85. package/skills/rhine-design/references/TASK_MAP.md +8 -1
  86. package/skills/rhine-design/references/integration.md +1 -1
  87. package/tests/AppShellControls.test.tsx +82 -0
  88. package/tests/ButtonLink.test.tsx +94 -0
  89. package/tests/CompactAccountGallery.test.tsx +116 -0
  90. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  91. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  92. package/tests/NeutronStarGallery.test.tsx +62 -0
  93. package/tests/NeutronStarImport.test.tsx +15 -0
  94. package/tests/OrbitBackdrop.test.tsx +420 -0
  95. package/tests/OrbitDonutThree.test.tsx +91 -0
  96. package/tests/OrbitObjectsThree.test.tsx +164 -0
  97. package/tests/SettingsDialog.test.tsx +66 -0
  98. package/tests/StatusTimelineList.test.tsx +63 -0
  99. package/tests/ThemePicker.test.tsx +30 -0
  100. package/tests/TimelineLegend.test.tsx +60 -0
@@ -0,0 +1,164 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { BufferGeometry, InstancedMesh, Material, Texture, Vector3, type Mesh, type PerspectiveCamera, type Points, type Scene, type ShaderMaterial } from 'three';
3
+ import { createOrbitObject, type OrbitObjectKind } from '../components/orbitObjectsThree';
4
+
5
+ const gpu = vi.hoisted(() => ({ renderers: [] as Array<{
6
+ scene?: Scene; camera?: PerspectiveCamera; dispose: ReturnType<typeof vi.fn>; forceContextLoss: ReturnType<typeof vi.fn>;
7
+ }> }));
8
+ vi.mock('three', async importOriginal => {
9
+ const actual = await importOriginal<typeof import('three')>();
10
+ return { ...actual, WebGLRenderer: class {
11
+ ratio = 1; scene?: Scene; camera?: PerspectiveCamera;
12
+ dispose = vi.fn(); forceContextLoss = vi.fn();
13
+ constructor(private options: { canvas: HTMLCanvasElement }) { gpu.renderers.push(this); }
14
+ setClearColor() {} setPixelRatio(value: number) { this.ratio = value; }
15
+ setSize(width: number, height: number) { this.options.canvas.width = Math.round(width * this.ratio); this.options.canvas.height = Math.round(height * this.ratio); }
16
+ render(scene: Scene, camera: PerspectiveCamera) { this.scene = scene; this.camera = camera; scene.updateMatrixWorld(); camera.updateMatrixWorld(); }
17
+ } };
18
+ });
19
+
20
+ describe.each<OrbitObjectKind>(['paper', 'technical'])('Three.js %s object', kind => {
21
+ beforeEach(() => {
22
+ gpu.renderers = []; vi.stubGlobal('requestAnimationFrame', vi.fn()); vi.stubGlobal('devicePixelRatio', 3);
23
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ createLinearGradient: () => ({ addColorStop() {} }), fillRect() {}, fillText() {} } as unknown as CanvasRenderingContext2D);
24
+ });
25
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
26
+
27
+ it('animates the object and particles deterministically from the host clock, including after resize', () => {
28
+ const art = createOrbitObject(kind, vi.fn());
29
+ expect(art.draw(0, 620, 1)).toBe(false);
30
+ art.draw(400, 620, 1);
31
+ expect(art.canvas.width).toBe(600); expect(art.canvas.height).toBe(930);
32
+ const scene = gpu.renderers[0].scene!, bird = kind === 'paper';
33
+ expect(scene.name).toBe(bird ? 'origami-paper-bird' : 'originium-cluster');
34
+ const particles = scene.getObjectByName(bird ? 'floating-paper-flecks' : 'floating-fragments') as InstancedMesh;
35
+ const dust = scene.getObjectByName(bird ? 'paper-motes' : 'crystal-embers') as Points<BufferGeometry, ShaderMaterial>;
36
+ const capture = () => ({ wings: bird ? ['paper-wing-left', 'paper-wing-right'].map(name => scene.getObjectByName(name)!.rotation.z) : [], object: scene.getObjectByName('theme-object')!.matrixWorld.toArray(), particles: [...particles.instanceMatrix.array], time: dust.material.uniforms.time.value });
37
+ const frozen = capture(); art.draw(400, 620, 1); expect(capture()).toEqual(frozen);
38
+ art.draw(880, 526, 1); expect(capture()).toEqual(frozen);
39
+ art.draw(880, 526, 2); expect(capture().object).not.toEqual(frozen.object); expect(capture().particles).not.toEqual(frozen.particles);
40
+ expect(capture().time).toBe(2);
41
+ if (bird) {
42
+ expect(capture().wings).not.toEqual(frozen.wings);
43
+ const body = scene.getObjectByName('folded-body') as Mesh;
44
+ expect(body.geometry.getAttribute('position').count).toBeGreaterThan(20);
45
+ expect(scene.getObjectByName('paper-wing-left')).toBeDefined();
46
+ expect(scene.getObjectByName('paper-wing-right')).toBeDefined();
47
+ expect(scene.getObjectByName('folded-neck-and-beak')).toBeDefined();
48
+ } else {
49
+ for (let i = 0; i < 5; i++) expect(scene.getObjectByName(`originium-facet-${i}`)).toBeDefined();
50
+ expect(scene.getObjectByName('containment-ring')).toBeDefined();
51
+ }
52
+ expect(requestAnimationFrame).not.toHaveBeenCalled(); art.dispose();
53
+ });
54
+
55
+ if (kind === 'paper') it('continuously reshapes the existing paper buffers and creases, freezes mid-fold and reverses', () => {
56
+ const art = createOrbitObject(kind, vi.fn());
57
+ art.draw(880, 526, 0);
58
+ const object = gpu.renderers[0].scene!.getObjectByName('theme-object')!;
59
+ const meshes: Mesh[] = [];
60
+ object.traverse(node => { if ((node as Mesh).geometry) meshes.push(node as Mesh); });
61
+ const identities = meshes.map(mesh => [mesh.uuid, mesh.geometry.uuid, mesh.geometry.getAttribute('position')]);
62
+ const capture = () => meshes.map(mesh => [...mesh.geometry.getAttribute('position').array]);
63
+ const bird = capture();
64
+ art.draw(880, 526, 11); const halfway = capture();
65
+ expect(halfway).not.toEqual(bird);
66
+ art.draw(520, 460, 11); expect(capture()).toEqual(halfway);
67
+ art.draw(880, 526, 11.0001);
68
+ const adjacent = capture();
69
+ expect(Math.max(...adjacent.flatMap((points, i) => points.map((v, j) => Math.abs(v - halfway[i][j]))))).toBeLessThan(.001);
70
+ art.draw(880, 526, 21); const butterfly = capture();
71
+ expect(butterfly).not.toEqual(bird); expect(butterfly).not.toEqual(halfway);
72
+ expect(butterfly.every(points => points.every(Number.isFinite))).toBe(true);
73
+ art.draw(880, 526, 31); expect(capture()).toEqual(halfway);
74
+ art.draw(880, 526, 42); expect(capture()).toEqual(bird);
75
+ expect(meshes.map(mesh => [mesh.uuid, mesh.geometry.uuid, mesh.geometry.getAttribute('position')])).toEqual(identities);
76
+ expect(object.children.every(child => child.visible)).toBe(true);
77
+ art.dispose();
78
+ });
79
+
80
+ if (kind === 'paper') it('opens a nearly flat sheet between two folding gestures and suspends flapping', () => {
81
+ const art = createOrbitObject(kind, vi.fn());
82
+ const heightAt = (time: number) => {
83
+ art.draw(880, 526, time);
84
+ const wing = gpu.renderers[0].scene!.getObjectByName('paper-wing-left')!;
85
+ expect(Math.abs(wing.rotation.z)).toBe(0);
86
+ let height = 0;
87
+ wing.traverse(node => {
88
+ const positions = (node as Mesh).geometry?.getAttribute('position');
89
+ if (positions) for (let i = 0; i < positions.count; i++) height = Math.max(height, Math.abs(positions.getY(i)));
90
+ });
91
+ return height;
92
+ };
93
+ const gathered = heightAt(6.2), opened = heightAt(11), refolded = heightAt(14.4);
94
+ expect(opened).toBeLessThan(.08);
95
+ expect(gathered).toBeGreaterThan(opened * 4);
96
+ expect(refolded).toBeGreaterThan(opened * 4);
97
+ art.dispose();
98
+ });
99
+
100
+ if (kind === 'paper') it('flies, banks and beats its wings without leaving desktop or narrow frames', () => {
101
+ const art = createOrbitObject(kind, vi.fn()), vertex = new Vector3();
102
+ const motion: number[][] = [];
103
+ let maxX = 0, maxY = 0, extreme = '';
104
+ for (const [width, height] of [[400,360],[520,460],[880,526]]) for (let time = 0; time <= 84; time += .5) {
105
+ art.draw(width, height, time);
106
+ const { scene, camera } = gpu.renderers[0];
107
+ const bird = scene!.getObjectByName('theme-object')!;
108
+ motion.push([bird.position.x, bird.position.y, bird.position.z, bird.rotation.z, scene!.getObjectByName('paper-wing-left')!.rotation.z]);
109
+ scene!.getObjectByName('theme-object')!.traverse(node => {
110
+ const positions = (node as Mesh).geometry?.getAttribute('position');
111
+ if (!positions) return;
112
+ for (let i = 0; i < positions.count; i++) {
113
+ vertex.fromBufferAttribute(positions, i).applyMatrix4(node.matrixWorld).project(camera!);
114
+ maxX = Math.max(maxX, Math.abs(vertex.x));
115
+ if (Math.abs(vertex.y) > maxY) { maxY = Math.abs(vertex.y); extreme = `${width}x${height} t=${time} ${node.name}`; }
116
+ }
117
+ });
118
+ }
119
+ expect(maxX).toBeLessThan(.96);
120
+ expect(maxY, extreme).toBeLessThan(.94);
121
+ for (const [axis, minimumSpan] of [ .7, .3, .38, .22, .5 ].entries()) {
122
+ const values = motion.map(pose => pose[axis]);
123
+ expect(Math.max(...values) - Math.min(...values)).toBeGreaterThan(minimumSpan);
124
+ }
125
+ art.dispose();
126
+ });
127
+
128
+ it('recovers a frozen context and releases every owned resource exactly once', () => {
129
+ const geometrySpy = vi.spyOn(BufferGeometry.prototype, 'dispose'), materialSpy = vi.spyOn(Material.prototype, 'dispose');
130
+ const instanceSpy = vi.spyOn(InstancedMesh.prototype, 'dispose'), textureSpy = vi.spyOn(Texture.prototype, 'dispose'), invalidate = vi.fn();
131
+ const art = createOrbitObject(kind, invalidate); document.body.append(art.canvas); art.draw(880, 526, 3);
132
+ const renderer = gpu.renderers[0], geometries = new Set(), materials = new Set(), instances = new Set();
133
+ renderer.scene!.traverse(node => {
134
+ const mesh = node as Mesh;
135
+ if (mesh.geometry) geometries.add(mesh.geometry);
136
+ if (mesh.material) for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) materials.add(m);
137
+ if (node instanceof InstancedMesh) instances.add(node);
138
+ });
139
+ const lost = new Event('webglcontextlost', { cancelable: true }); art.canvas.dispatchEvent(lost);
140
+ expect(lost.defaultPrevented).toBe(true); expect(art.draw(880, 526, 8)).toBe(false);
141
+ art.canvas.dispatchEvent(new Event('webglcontextrestored')); expect(invalidate).toHaveBeenCalledTimes(2);
142
+ expect(art.draw(880, 526, 3)).toBe(true);
143
+ art.dispose(); art.dispose();
144
+ // Allocated but unused shared materials must also be released.
145
+ expect(new Set(geometrySpy.mock.contexts)).toEqual(geometries);
146
+ expect(geometrySpy).toHaveBeenCalledTimes(geometries.size);
147
+ expect(materialSpy.mock.contexts).toEqual(expect.arrayContaining([...materials]));
148
+ expect(new Set(materialSpy.mock.contexts).size).toBe(materialSpy.mock.calls.length);
149
+ expect(new Set(instanceSpy.mock.contexts)).toEqual(instances); expect(instanceSpy).toHaveBeenCalledTimes(instances.size);
150
+ expect(textureSpy).toHaveBeenCalledTimes(kind === 'paper' ? 2 : 1);
151
+ expect(new Set(textureSpy.mock.contexts).size).toBe(textureSpy.mock.calls.length);
152
+ expect(renderer.dispose).toHaveBeenCalledOnce(); expect(renderer.forceContextLoss).toHaveBeenCalledOnce();
153
+ expect(art.canvas.isConnected).toBe(false); expect(art.draw(400, 620, 4)).toBe(false);
154
+ art.canvas.dispatchEvent(new Event('webglcontextrestored')); expect(invalidate).toHaveBeenCalledTimes(2);
155
+ });
156
+
157
+ it('releases partial allocations if object construction fails', () => {
158
+ const disposeGeometry = vi.spyOn(BufferGeometry.prototype, 'dispose');
159
+ vi.spyOn(BufferGeometry.prototype, 'setAttribute').mockImplementationOnce(() => { throw new Error('Synthetic setup failure'); });
160
+ expect(() => createOrbitObject(kind, vi.fn())).toThrow('Synthetic setup failure');
161
+ expect(disposeGeometry).toHaveBeenCalled();
162
+ expect(gpu.renderers[0].dispose).toHaveBeenCalledOnce(); expect(gpu.renderers[0].forceContextLoss).toHaveBeenCalledOnce();
163
+ });
164
+ });
@@ -0,0 +1,66 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { SettingsDialogGallery } from '../preview/SettingsDialogGallery';
4
+ import { Button, Dialog, Tooltip } from '../components';
5
+
6
+ it('keeps host state and URL while changing settings sections and explicitly saving', async () => {
7
+ render(<SettingsDialogGallery />);
8
+ const url = location.href;
9
+ fireEvent.change(screen.getByRole('textbox', {name:'宿主查询'}), {target:{value:'已有查询'}});
10
+ fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
11
+ fireEvent.change(screen.getByRole('textbox', {name:'工作区名称'}), {target:{value:'修改后的名称'}});
12
+ fireEvent.click(screen.getByRole('link', {name:'用户管理与权限'}));
13
+ fireEvent.click(screen.getByRole('link', {name:'账号'}));
14
+ expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue('修改后的名称');
15
+ expect(location.href).toBe(url);
16
+ fireEvent.click(screen.getByRole('button', {name:'保存设置'}));
17
+ await screen.findByText('已保存演示草稿');
18
+ expect(screen.getByRole('dialog', {name:'设置'})).toBeVisible();
19
+ fireEvent.click(screen.getByRole('button', {name:'关闭对话框'}));
20
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
21
+ expect(screen.getByRole('textbox', {name:'宿主查询'})).toHaveValue('已有查询');
22
+ fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
23
+ expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue('修改后的名称');
24
+ fireEvent.change(screen.getByRole('textbox', {name:'工作区名称'}), {target:{value:'丢弃'}});
25
+ fireEvent.click(screen.getByRole('button', {name:'取消草稿'}));
26
+ expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue('修改后的名称');
27
+ });
28
+
29
+ it('closes only the focused nested dialog on an immediate Escape', async () => {
30
+ render(<SettingsDialogGallery />);
31
+ fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
32
+ fireEvent.click(screen.getByRole('link', {name:'用户管理与权限'}));
33
+ fireEvent.click(screen.getByRole('button', {name:'编辑成员 12'}));
34
+ const input = screen.getByRole('textbox', {name:'成员备注'});
35
+ input.focus(); fireEvent.keyDown(input, {key:'Escape'});
36
+ await waitFor(() => expect(screen.queryByRole('dialog', {name:'编辑成员 12'})).not.toBeInTheDocument());
37
+ expect(screen.getByRole('dialog', {name:'设置'})).toBeVisible();
38
+ await waitFor(() => expect(screen.getByRole('button', {name:'编辑成员 12'})).toHaveFocus());
39
+ });
40
+
41
+ it('lets the topmost tooltip consume Escape before closing its dialog', async () => {
42
+ render(<Dialog trigger={<Button>Open</Button>} title="Parent" description="Help inside dialog"><Tooltip content="Help text"><Button>Help</Button></Tooltip></Dialog>);
43
+ fireEvent.click(screen.getByRole('button', {name:'Open'}));
44
+ const help = screen.getByRole('button', {name:'Help'});
45
+ help.focus();
46
+ await screen.findByRole('tooltip');
47
+ fireEvent.keyDown(help, {key:'Escape'});
48
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
49
+ expect(screen.getByRole('dialog', {name:'Parent'})).toBeVisible();
50
+ fireEvent.keyDown(help, {key:'Escape'});
51
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
52
+ });
53
+
54
+ it.each([false, true])('reconciles pending saves on reopen without overwriting newer edits (edited=%s)', async edited => {
55
+ render(<SettingsDialogGallery />);
56
+ fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
57
+ fireEvent.change(screen.getByRole('textbox', {name:'工作区名称'}), {target:{value:'保存中的名称'}});
58
+ fireEvent.click(screen.getByRole('button', {name:'保存设置'}));
59
+ fireEvent.click(screen.getByRole('button', {name:'关闭对话框'}));
60
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
61
+ fireEvent.click(screen.getByRole('button', {name:'打开设置'}));
62
+ if (edited) fireEvent.change(screen.getByRole('textbox', {name:'工作区名称'}), {target:{value:'重新打开后编辑'}});
63
+ await screen.findByText('已保存演示草稿');
64
+ expect(screen.getByText('已保存名称:保存中的名称')).toBeVisible();
65
+ expect(screen.getByRole('textbox', {name:'工作区名称'})).toHaveValue(edited ? '重新打开后编辑' : '保存中的名称');
66
+ });
@@ -0,0 +1,63 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { StatusTimelineList, type StatusTimelineItem } from '../components';
4
+ import { ThemeProvider } from '../components/theme';
5
+
6
+ const items: StatusTimelineItem[] = ['资源 A', '资源 B'].map((title, i) => ({ id: String(i), title,
7
+ summary: <>剩余 <strong>{i ? '—' : '0%'}</strong></>,
8
+ timeline: { startLabel: '昨天', endLabel: '今天', segments: [
9
+ { id: 'first', label: '10:00', status: 'unknown' }, { id: 'last', label: '10:15', status: 'failed' },
10
+ ] },
11
+ }));
12
+
13
+ it('opens only one row detail and preserves keyboard focus on the newly focused row', async () => {
14
+ render(<ThemeProvider><StatusTimelineList label="资源历史" items={items} /></ThemeProvider>);
15
+ const [first, second] = screen.getAllByRole('slider');
16
+ act(() => first.focus());
17
+ await screen.findByRole('dialog', { name: '资源 A 历史时段时段详情' });
18
+ act(() => second.focus());
19
+ await screen.findByRole('dialog', { name: '资源 B 历史时段时段详情' });
20
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
21
+ expect(second).toHaveFocus();
22
+ fireEvent.keyDown(second, { key: 'Enter' });
23
+ expect(screen.getByRole('button', { name: '关闭时段详情' })).toHaveFocus();
24
+ fireEvent.keyDown(screen.getByRole('button', { name: '关闭时段详情' }), { key: 'Escape' });
25
+ await waitFor(() => expect(second).toHaveFocus());
26
+ expect(screen.queryByRole('dialog')).toBeNull();
27
+ });
28
+
29
+ it('does not move focus to the old row when pointer inspection switches rows', async () => {
30
+ render(<StatusTimelineList label="资源历史" items={items} />);
31
+ const [first, second] = screen.getAllByRole('slider');
32
+ fireEvent.pointerDown(first);
33
+ await screen.findByRole('dialog');
34
+ fireEvent.pointerDown(second);
35
+ await screen.findByRole('dialog', { name: '资源 B 历史时段时段详情' });
36
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: '资源 A 历史时段时段详情' })).toBeNull());
37
+ expect(document.body).toHaveFocus();
38
+ });
39
+
40
+ it('keeps stable row and segment identity on reorder and closes removed details', async () => {
41
+ const view = render(<StatusTimelineList label="资源历史" items={items} />);
42
+ const first = screen.getByRole('slider', { name: '资源 A 历史时段' });
43
+ fireEvent.change(first, { target: { value: '0' } });
44
+ view.rerender(<StatusTimelineList label="资源历史" items={[items[1], items[0]]} />);
45
+ expect(screen.getByRole('slider', { name: '资源 A 历史时段' })).toBe(first);
46
+ expect(first).toHaveAttribute('aria-valuetext', '10:00 · 未知 / 无数据');
47
+ view.rerender(<StatusTimelineList label="资源历史" items={[items[1]]} />);
48
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
49
+ view.rerender(<StatusTimelineList label="资源历史" items={items} />);
50
+ expect(screen.queryByRole('dialog')).toBeNull();
51
+ });
52
+
53
+ it('distinguishes unavailable history, empty records, zero summary and disabled inspection', () => {
54
+ const view = render(<StatusTimelineList label="资源历史" items={[{ ...items[0], timeline: null }, { ...items[1], timeline: { ...items[1].timeline!, disabled: true } }]} />);
55
+ expect(screen.getByText('0%')).toBeVisible();
56
+ expect(screen.getByText('历史记录暂不可用 · 未知')).toBeVisible();
57
+ expect(screen.getByRole('slider')).toBeDisabled();
58
+ fireEvent.pointerDown(screen.getByRole('slider'));
59
+ expect(screen.queryByRole('dialog')).toBeNull();
60
+ view.rerender(<StatusTimelineList label="资源历史" items={[]} emptyLabel="暂无资源" />);
61
+ expect(screen.getByText('暂无资源')).toBeVisible();
62
+ expect(screen.queryByRole('slider')).toBeNull();
63
+ });
@@ -0,0 +1,30 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { expect, it, vi } from 'vitest';
3
+ import { ThemePicker, ThemePreview } from '../components/ThemePicker';
4
+
5
+ it('keeps theme ownership with the host and does not apply on focus or hover', () => {
6
+ const change = vi.fn();
7
+ const { rerender } = render(<ThemePicker value="rhine" onValueChange={change} />);
8
+ const radio = screen.getByRole('radio', { name: '明日方舟 · 战术终端' });
9
+ radio.focus(); fireEvent.mouseOver(radio); expect(change).not.toHaveBeenCalled();
10
+ fireEvent.click(radio); expect(change).toHaveBeenCalledWith('arknights'); expect(radio).not.toBeChecked();
11
+ rerender(<ThemePicker value="arknights" onValueChange={change} />); expect(radio).toBeChecked();
12
+ });
13
+
14
+ it('keeps failed previews selectable and retries a replacement source', () => {
15
+ const change = vi.fn();
16
+ const { container, rerender } = render(<ThemePicker value="rhine" onValueChange={change} />);
17
+ fireEvent.error(container.querySelectorAll('img')[1]);
18
+ expect(screen.getByText('预览暂不可用')).toBeInTheDocument();
19
+ fireEvent.click(screen.getByRole('radio', { name: '明日方舟 · 战术终端' })); expect(change).toHaveBeenCalledWith('arknights');
20
+ rerender(<ThemePreview theme="rhine" src="replacement.png" />);
21
+ expect(container.querySelector('img')).toHaveAttribute('src', 'replacement.png');
22
+ fireEvent.load(container.querySelector('img')!); expect(container.querySelector('[data-state=ready]')).toBeInTheDocument();
23
+ });
24
+
25
+ it('uses independent radio groups and supports whole-group and per-option disabling', () => {
26
+ render(<><ThemePicker label="一" value="rhine" onValueChange={() => {}} disabled /><ThemePicker label="二" value="rhine" onValueChange={() => {}} options={[{ value: 'rhine' }, { value: 'arknights', disabled: true }]} /></>);
27
+ const radios = screen.getAllByRole('radio');
28
+ expect(radios[0]).toBeDisabled(); expect(radios[4]).toBeDisabled();
29
+ expect(radios[0].getAttribute('name')).not.toBe(radios[3].getAttribute('name'));
30
+ });
@@ -0,0 +1,60 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+ import { TimelineLegend, type TimelineLegendItem } from '../components';
4
+ import { ThemeProvider } from '../components/theme';
5
+
6
+ const items: TimelineLegendItem[] = [
7
+ { status: 'healthy', label: '有输出', description: '存在输出记录,当前证据未发现异常;不代表完整可用性。' },
8
+ { status: 'unknown', label: '未知', description: '数据缺失或不完整,不能解释为健康。' },
9
+ ];
10
+
11
+ it('preserves default labels and non-interactive presentation', () => {
12
+ render(<TimelineLegend />);
13
+ expect(screen.getByRole('list', { name: '健康记录图例' }).children).toHaveLength(5);
14
+ expect(screen.getByText('有输出·未见异常')).toBeVisible();
15
+ expect(screen.getByText('未知 / 无数据')).toBeVisible();
16
+ expect(screen.queryByRole('button')).toBeNull();
17
+ });
18
+
19
+ it('shows host rules on focus, keeps only one rule open, and restores focus after inspection', async () => {
20
+ const { container } = render(<ThemeProvider theme="neo-brutalism"><TimelineLegend label="历史状态图例" compact items={items} /></ThemeProvider>);
21
+ const output = screen.getByRole('button', { name: '有输出' });
22
+ const unknown = screen.getByRole('button', { name: '未知' });
23
+ act(() => output.focus());
24
+ await screen.findByRole('dialog', { name: '有输出规则' });
25
+ expect(output).toHaveFocus();
26
+ act(() => unknown.focus());
27
+ const dialog = await screen.findByRole('dialog', { name: '未知规则' });
28
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
29
+ expect(dialog.closest('.rhine-theme')).toBe(container.querySelector('.rhine-theme'));
30
+ expect(unknown).toHaveFocus();
31
+ fireEvent.keyDown(unknown, { key: 'Enter' });
32
+ const close = screen.getByRole('button', { name: '关闭未知规则' });
33
+ expect(close).toHaveFocus();
34
+ fireEvent.keyDown(close, { key: 'Escape' });
35
+ await waitFor(() => expect(unknown).toHaveFocus());
36
+ expect(screen.queryByRole('dialog')).toBeNull();
37
+ });
38
+
39
+ it('opens rules through pointer activation without changing the label or requiring hover', async () => {
40
+ render(<TimelineLegend compact items={items} />);
41
+ const trigger = screen.getByRole('button', { name: '有输出' });
42
+ fireEvent.click(trigger);
43
+ const dialog = await screen.findByRole('dialog', { name: '有输出规则' });
44
+ expect(dialog).toHaveTextContent(items[0].description!);
45
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
46
+ fireEvent.click(screen.getByRole('button', { name: '关闭有输出规则' }));
47
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
48
+ });
49
+
50
+ it('normalizes positioning under ancestor zoom while preserving enlarged content and runtime changes', async () => {
51
+ const { container } = render(<div style={{ zoom: 1.5 }}><ThemeProvider style={{ zoom: 2 }}><TimelineLegend compact items={items} /></ThemeProvider></div>);
52
+ fireEvent.click(screen.getByRole('button', { name: '有输出' }));
53
+ const dialog = await screen.findByRole('dialog', { name: '有输出规则' });
54
+ await waitFor(() => expect(dialog.style.zoom).toBe('3'));
55
+ expect(Number(dialog.parentElement?.style.zoom)).toBeCloseTo(1 / 3);
56
+ const theme = container.querySelector<HTMLElement>('.rhine-theme')!;
57
+ theme.style.zoom = '1';
58
+ await waitFor(() => expect(dialog.style.zoom).toBe('1.5'));
59
+ expect(Number(dialog.parentElement?.style.zoom)).toBeCloseTo(1 / 1.5);
60
+ });