@linhey/rhine-design 0.6.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 (98) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -8
  3. package/components/Actions.tsx +18 -1
  4. package/components/AppShell.tsx +46 -17
  5. package/components/Media.tsx +7 -7
  6. package/components/NeutronStarBackdrop.tsx +43 -0
  7. package/components/OrbitBackdrop.tsx +4 -2
  8. package/components/Overlays.tsx +37 -8
  9. package/components/README.md +27 -3
  10. package/components/StatusTimeline.tsx +72 -6
  11. package/components/StatusTimelineList.tsx +38 -0
  12. package/components/ThemePicker.tsx +49 -0
  13. package/components/assets/orbit-bird.svg +578 -0
  14. package/components/assets/orbit-crystal.svg +11 -0
  15. package/components/assets/orbit-donut.svg +16 -0
  16. package/components/assets/theme-previews/arknights.png +0 -0
  17. package/components/assets/theme-previews/manifest.json +24 -0
  18. package/components/assets/theme-previews/neo-brutalism.png +0 -0
  19. package/components/assets/theme-previews/rhine.png +0 -0
  20. package/components/common.css +3 -3
  21. package/components/components.tsx +3 -3
  22. package/components/dialog-settings.css +9 -0
  23. package/components/extensions.css +8 -32
  24. package/components/index.ts +11 -5
  25. package/components/media-parity.css +55 -2
  26. package/components/neutron.css +53 -0
  27. package/components/neutronRenderer.ts +327 -0
  28. package/components/orbit.css +79 -1
  29. package/components/orbitBirdFlight.ts +34 -0
  30. package/components/orbitBirdGeometry.ts +158 -0
  31. package/components/orbitDonut.ts +134 -0
  32. package/components/orbitDonutThree.ts +208 -0
  33. package/components/orbitObjectMaterials.ts +76 -0
  34. package/components/orbitObjects.ts +71 -0
  35. package/components/orbitObjectsThree.ts +215 -0
  36. package/components/orbitProjection.ts +21 -0
  37. package/components/orbitRenderer.ts +163 -46
  38. package/components/styles.css +3 -1
  39. package/components/theme-neo-brutalism.css +3 -2
  40. package/components/theme-picker.css +17 -0
  41. package/components/theme.tsx +2 -8
  42. package/components/themeMetadata.ts +7 -0
  43. package/components/timeline.css +17 -6
  44. package/components/useFloatingScale.ts +96 -0
  45. package/docs/APPLICATION_COMPONENTS.md +52 -3
  46. package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
  47. package/docs/DATE_RANGE_FILTER.md +60 -0
  48. package/docs/DESIGN_SYSTEM.md +7 -0
  49. package/docs/FLOATING_OVERLAYS.md +15 -0
  50. package/docs/INTERACTION_PATTERNS.md +5 -1
  51. package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
  52. package/docs/ORBIT_BACKDROP.md +38 -46
  53. package/docs/SETTINGS_DIALOG.md +21 -0
  54. package/docs/THEME_PICKER.md +26 -0
  55. package/docs/THEMING.md +2 -0
  56. package/docs/assets/compact-account-neo.png +0 -0
  57. package/docs/assets/neutron-star-original.png +0 -0
  58. package/docs/assets/neutron-star-theme.png +0 -0
  59. package/docs/assets/orbit-bird.png +0 -0
  60. package/docs/assets/orbit-crystal.png +0 -0
  61. package/docs/assets/orbit-strawberry.png +0 -0
  62. package/package.json +8 -3
  63. package/preview/CompactAccountGallery.tsx +178 -0
  64. package/preview/DateRangeFilterGallery.tsx +109 -0
  65. package/preview/ExtensionsGallery.tsx +27 -18
  66. package/preview/GalleryShell.tsx +3 -3
  67. package/preview/MonitoringGallery.tsx +2 -0
  68. package/preview/NeutronStarGallery.tsx +56 -0
  69. package/preview/OrbitGallery.tsx +35 -32
  70. package/preview/OverlayBoundsGallery.tsx +49 -0
  71. package/preview/SettingsDialogGallery.tsx +33 -0
  72. package/preview/ThemePickerGallery.tsx +24 -0
  73. package/preview/ThemeSample.tsx +14 -0
  74. package/preview/TimelineListGallery.tsx +36 -0
  75. package/preview/extensions.css +9 -0
  76. package/preview/main.tsx +16 -3
  77. package/preview/preview.css +8 -0
  78. package/preview/theme-sample.html +1 -0
  79. package/scripts/check-overlay-bounds.js +76 -0
  80. package/scripts/check-settings-theme.js +88 -0
  81. package/skills/rhine-design/SKILL.md +3 -2
  82. package/skills/rhine-design/references/API_MAP.md +13 -2
  83. package/skills/rhine-design/references/TASK_MAP.md +8 -2
  84. package/skills/rhine-design/references/integration.md +1 -1
  85. package/tests/AppShellControls.test.tsx +82 -0
  86. package/tests/ButtonLink.test.tsx +94 -0
  87. package/tests/CompactAccountGallery.test.tsx +116 -0
  88. package/tests/DateRangeFilterGallery.test.tsx +85 -0
  89. package/tests/NeutronStarBackdrop.test.tsx +288 -0
  90. package/tests/NeutronStarGallery.test.tsx +62 -0
  91. package/tests/NeutronStarImport.test.tsx +15 -0
  92. package/tests/OrbitBackdrop.test.tsx +246 -9
  93. package/tests/OrbitDonutThree.test.tsx +91 -0
  94. package/tests/OrbitObjectsThree.test.tsx +164 -0
  95. package/tests/SettingsDialog.test.tsx +66 -0
  96. package/tests/StatusTimelineList.test.tsx +63 -0
  97. package/tests/ThemePicker.test.tsx +30 -0
  98. package/tests/TimelineLegend.test.tsx +60 -0
@@ -1,14 +1,20 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
4
4
  import { OrbitBackdrop } from '../components';
5
5
  import { mountOrbit } from '../components/orbitRenderer';
6
+ import { projectOrbitPoint, rotateOrbitPoint } from '../components/orbitProjection';
6
7
  import { OrbitGallery } from '../preview/OrbitGallery';
7
8
 
8
9
  class Media extends EventTarget {
9
10
  matches = false;
10
11
  set(value: boolean) { this.matches = value; this.dispatchEvent(new Event('change')); }
11
12
  }
13
+ type PathCommand = [kind: 'move' | 'line' | 'arc' | 'rect' | 'close', ...coordinates: number[]];
14
+ type Paint = {
15
+ kind: 'stroke' | 'fill'; path: PathCommand[]; color: string | CanvasGradient | CanvasPattern;
16
+ width: number; dash: number[]; dashOffset: number; cap: CanvasLineCap; join: CanvasLineJoin;
17
+ };
12
18
 
13
19
  describe('OrbitBackdrop artwork and lifecycle', () => {
14
20
  let reduced: Media, compact: Media, hidden: boolean, bounds: { width: number; height: number };
@@ -18,11 +24,28 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
18
24
  let draws: number, lines: number, trails: number, glows: number, firstPoint: number[], firstRadius: number, zoom: number;
19
25
  let hasPoint: boolean;
20
26
  let context: CanvasRenderingContext2D;
27
+ let paletteStyles: HTMLStyleElement;
28
+ let gradientColors: Set<string>;
29
+ let recordPaint: boolean, painted: Paint[], dashes: number[];
21
30
 
22
31
  beforeEach(() => {
23
32
  reduced = new Media(); compact = new Media(); hidden = false;
24
33
  bounds = { width: 600, height: 680 }; frameId = 0; frames = new Map();
25
34
  draws = lines = trails = glows = firstRadius = 0; firstPoint = []; hasPoint = false; zoom = 1;
35
+ gradientColors = new Set();
36
+ recordPaint = false; painted = []; dashes = [];
37
+ paletteStyles = document.createElement('style');
38
+ paletteStyles.textContent = `
39
+ .orbit-palette-fixture { --rhine-orbit-highlight: #165f49; }
40
+ .orbit-palette-fixture[data-rhine-theme="arknights"] { --rhine-orbit-highlight: #d9b477; }
41
+ .orbit-palette-fixture.alternate-palette { --rhine-orbit-highlight: #ed7020; }
42
+ .orbit-palette-fixture [data-rhine-orbit-appearance="original"] { --rhine-orbit-highlight: #ff658c; }
43
+ .orbit-linework-fixture { --rhine-orbit-linework: paper; }
44
+ .orbit-linework-fixture[data-rhine-theme="arknights"] { --rhine-orbit-linework: technical; }
45
+ .orbit-linework-fixture[data-rhine-theme="neo-brutalism"] { --rhine-orbit-linework: donut; }
46
+ .orbit-linework-fixture [data-rhine-orbit-appearance="original"] { --rhine-orbit-linework: original; }
47
+ `;
48
+ document.head.append(paletteStyles);
26
49
  resizeDisconnect = vi.fn(); intersectionDisconnect = vi.fn();
27
50
  vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
28
51
  vi.spyOn(HTMLCanvasElement.prototype, 'clientWidth', 'get').mockImplementation(() => bounds.width);
@@ -37,29 +60,67 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
37
60
  observe(target: Element) { intersect = value => this.callback([{ target, isIntersecting: value }]); }
38
61
  disconnect() { intersectionDisconnect(); }
39
62
  });
40
- const gradient = { addColorStop() {} };
63
+ const gradient = { addColorStop(_offset: number, color: string) { gradientColors.add(color); } };
64
+ const states: Partial<CanvasRenderingContext2D>[] = [], savedDashes: number[][] = [];
65
+ let path: PathCommand[] = [];
66
+ const record = (command: PathCommand) => { if (recordPaint) path.push(command); };
67
+ const paint = (kind: Paint['kind'], commands = path) => {
68
+ if (recordPaint) painted.push({ kind, path: [...commands], color: kind === 'stroke' ? context.strokeStyle : context.fillStyle,
69
+ width: context.lineWidth, dash: [...dashes], dashOffset: context.lineDashOffset, cap: context.lineCap, join: context.lineJoin });
70
+ };
41
71
  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++; },
72
+ lineWidth: 1, globalAlpha: 1, shadowOffsetX: 0, shadowOffsetY: 0, shadowBlur: 0, lineDashOffset: 0, lineCap: 'butt', lineJoin: 'miter',
73
+ save() {
74
+ states.push({ lineWidth: context.lineWidth, globalAlpha: context.globalAlpha, strokeStyle: context.strokeStyle, fillStyle: context.fillStyle,
75
+ lineCap: context.lineCap, lineJoin: context.lineJoin, lineDashOffset: context.lineDashOffset, shadowBlur: context.shadowBlur, shadowColor: context.shadowColor, shadowOffsetX: context.shadowOffsetX, shadowOffsetY: context.shadowOffsetY });
76
+ savedDashes.push([...dashes]);
77
+ },
78
+ restore() { Object.assign(context, states.pop()); dashes = savedDashes.pop() ?? []; },
79
+ setTransform() {},
80
+ clearRect() {
81
+ draws++; lines = trails = glows = firstRadius = 0; hasPoint = false;
82
+ painted = []; path = [];
83
+ },
84
+ beginPath() { path = []; }, closePath() { record(['close']); },
85
+ stroke() { paint('stroke'); }, fill() { paint('fill'); },
86
+ arc(x: number, y: number, radius: number, start: number, end: number) { if (!firstRadius) firstRadius = radius; record(['arc', x, y, radius, start, end]); },
87
+ rect(x: number, y: number, width: number, height: number) { record(['rect', x, y, width, height]); },
88
+ fillRect(x: number, y: number, width: number, height: number) { paint('fill', [['rect', x, y, width, height]]); },
89
+ strokeRect(x: number, y: number, width: number, height: number) { paint('stroke', [['rect', x, y, width, height]]); },
90
+ moveTo(x: number, y: number) { if (!hasPoint) { hasPoint = true; firstPoint = [x, y]; } record(['move', x, y]); },
91
+ lineTo(x: number, y: number) { lines++; record(['line', x, y]); },
92
+ setLineDash(segments: number[]) { dashes = [...segments]; }, getLineDash() { return [...dashes]; },
46
93
  createLinearGradient() { trails++; return gradient; },
47
94
  createRadialGradient() { glows++; return gradient; },
48
95
  } as unknown as CanvasRenderingContext2D;
49
96
  vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
50
97
  });
51
- afterEach(() => { cleanup(); document.documentElement.removeAttribute('data-rhine-motion'); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
98
+ afterEach(() => { cleanup(); paletteStyles.remove(); document.documentElement.removeAttribute('data-rhine-motion'); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
52
99
 
53
100
  function advance(now: number) {
54
101
  act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(now)); });
55
102
  }
56
103
 
104
+ it('keeps the shared camera aligned with the original orbit reference axes', () => {
105
+ const references = [
106
+ { point: { x: 1, y: 0, z: 0 }, x: 86.76980178489757, y: 33.1491730947802, depth: .6593846719714731 },
107
+ { point: { x: 0, y: 1, z: 0 }, x: -48.312703948795914, y: 98.67789420213471, depth: .16406628090045566 },
108
+ { point: { x: 0, y: 0, z: 1 }, x: -67.05339595630653, y: -57.01591387412629, depth: .7336852934607355 },
109
+ ];
110
+ for (const reference of references) {
111
+ const actual = projectOrbitPoint(rotateOrbitPoint(reference.point), 100, 0, 0);
112
+ expect(actual.x).toBeCloseTo(reference.x, 10); expect(actual.y).toBeCloseTo(reference.y, 10);
113
+ expect(actual.depth).toBeCloseTo(reference.depth, 10);
114
+ }
115
+ });
116
+
57
117
  it('retains the complete seeded artwork, caps pixel density and pauses without catching up', () => {
58
118
  vi.stubGlobal('devicePixelRatio', 3);
59
119
  const canvas = document.createElement('canvas'); document.body.append(canvas);
60
120
  const scene = mountOrbit(canvas, () => {});
61
121
  expect(canvas.width).toBe(1200); expect(canvas.height).toBe(1360);
62
122
  expect(trails).toBe(420); expect(glows).toBe(140); expect(lines).toBeGreaterThan(185 * 80);
123
+ expect(gradientColors).toEqual(new Set(['#addaff', '#addaff00', '#e1a147', '#e1a14700']));
63
124
  advance(100); const initial = [...firstPoint]; advance(200); const moving = [...firstPoint];
64
125
  expect(moving).not.toEqual(initial);
65
126
  scene.setPaused(true); const stoppedDraws = draws;
@@ -97,6 +158,105 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
97
158
  advance(100); expect(snapshot()).toEqual(before); expect(frames.size).toBe(1);
98
159
  });
99
160
 
161
+ it('recolors a paused frame when its theme or appearance changes without remounting or losing content', async () => {
162
+ const view = (theme: string, appearance: 'theme' | 'original' = 'theme') => <div className="orbit-palette-fixture" data-rhine-theme={theme}>
163
+ <OrbitBackdrop appearance={appearance}><input aria-label="轨道草稿" defaultValue="起点" /></OrbitBackdrop>
164
+ </div>;
165
+ const { container, rerender } = render(view('default'));
166
+ const canvas = container.querySelector('canvas');
167
+ expect(context.fillStyle).toBe('#165f49');
168
+ expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-appearance', 'theme');
169
+ advance(100); advance(200);
170
+ fireEvent.change(screen.getByLabelText('轨道草稿'), { target: { value: '保留草稿' } });
171
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
172
+ const frozen = [...firstPoint], before = draws;
173
+ await act(async () => { rerender(view('arknights')); });
174
+ expect(draws).toBeGreaterThan(before); expect(context.fillStyle).toBe('#d9b477');
175
+ expect(firstPoint).toEqual(frozen); expect(frames.size).toBe(0);
176
+ await act(async () => { rerender(view('arknights', 'original')); });
177
+ expect(context.fillStyle).toBe('#ff658c'); expect(firstPoint).toEqual(frozen); expect(frames.size).toBe(0);
178
+ await act(async () => { rerender(view('default', 'original')); });
179
+ expect(context.fillStyle).toBe('#ff658c');
180
+ await act(async () => { rerender(view('default')); });
181
+ expect(context.fillStyle).toBe('#165f49'); expect(firstPoint).toEqual(frozen);
182
+ expect(container.querySelector('canvas')).toBe(canvas);
183
+ expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1);
184
+ expect(screen.getByLabelText('轨道草稿')).toHaveValue('保留草稿');
185
+ expect(screen.getByRole('button', { name: '继续动效' })).toBeEnabled();
186
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
187
+ advance(10000); expect(firstPoint).toEqual(frozen);
188
+ advance(10100); expect(firstPoint).not.toEqual(frozen); expect(frames.size).toBe(1);
189
+ });
190
+
191
+ it('responds to CSS-only root classes and token overrides, without reading styles on every frame', async () => {
192
+ const styleReads = vi.spyOn(window, 'getComputedStyle');
193
+ const { container, unmount } = render(<div className="orbit-palette-fixture"><OrbitBackdrop /></div>);
194
+ const root = container.firstElementChild as HTMLElement;
195
+ expect(context.fillStyle).toBe('#165f49');
196
+ const readCount = styleReads.mock.calls.length;
197
+ advance(100); advance(200);
198
+ expect(styleReads.mock.calls.length).toBe(readCount);
199
+ const frozen = [...firstPoint];
200
+ await act(async () => { root.classList.add('alternate-palette'); });
201
+ expect(context.fillStyle).toBe('#ed7020'); expect(firstPoint).toEqual(frozen); expect(frames.size).toBe(1);
202
+ advance(10000); expect(firstPoint).toEqual(frozen);
203
+ advance(10100); expect(firstPoint).not.toEqual(frozen);
204
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
205
+ const pausedPoint = [...firstPoint];
206
+ await act(async () => { root.style.setProperty('--rhine-orbit-highlight', 'rgb(32, 114, 153)'); });
207
+ expect(context.fillStyle).toBe('rgb(32, 114, 153)'); expect(firstPoint).toEqual(pausedPoint); expect(frames.size).toBe(0);
208
+ unmount(); const finalDraws = draws;
209
+ await act(async () => { root.style.setProperty('--rhine-orbit-highlight', '#abcdef'); });
210
+ expect(draws).toBe(finalDraws); expect(frames.size).toBe(0);
211
+ });
212
+
213
+ it('preserves color channels in transparent gradient stops for normalized RGB and wide-gamut colors', async () => {
214
+ const { container } = render(<OrbitBackdrop reducedMotion />);
215
+ const root = container.firstElementChild as HTMLElement;
216
+ gradientColors.clear();
217
+ await act(async () => {
218
+ root.style.setProperty('--rhine-orbit-trail-near', 'rgb(32, 114, 153)');
219
+ root.style.setProperty('--rhine-orbit-trail-far', 'color(display-p3 0.7 0.3 0.1 / 0.8)');
220
+ });
221
+ expect(gradientColors).toEqual(new Set(['rgb(32, 114, 153)', 'rgba(32,114,153,0)', 'color(display-p3 0.7 0.3 0.1 / 0.8)', 'color(display-p3 0.7 0.3 0.1 / 0)']));
222
+ expect(frames.size).toBe(0);
223
+ });
224
+
225
+ it('preserves the original geometry across host themes while object themes keep the same frozen clock and content', async () => {
226
+ recordPaint = true;
227
+ const view = (theme: string, appearance: 'theme' | 'original' = 'theme') => <div className="orbit-linework-fixture" data-rhine-theme={theme}>
228
+ <OrbitBackdrop appearance={appearance}><input aria-label="主题草稿" defaultValue="保留内容" /></OrbitBackdrop>
229
+ </div>;
230
+ const { container, rerender } = render(view('default', 'original'));
231
+ const canvas = container.querySelector('canvas');
232
+ advance(100); advance(200);
233
+ fireEvent.change(screen.getByLabelText('主题草稿'), { target: { value: '保留我的输入' } });
234
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
235
+ const original = painted;
236
+ expect(trails).toBe(420); expect(glows).toBe(140);
237
+ for (const theme of ['arknights', 'neo-brutalism', 'default']) {
238
+ await act(async () => { rerender(view(theme, 'original')); });
239
+ expect(painted).toEqual(original); expect(frames.size).toBe(0);
240
+ }
241
+ const paintings: Paint[][] = [];
242
+ for (const theme of ['default', 'arknights']) {
243
+ await act(async () => { rerender(view(theme)); });
244
+ const frozen = painted; paintings.push(frozen);
245
+ expect(trails).toBe(0); expect(glows).toBe(0); expect(frozen.length).toBeGreaterThan(100);
246
+ advance(5000); expect(painted).toEqual(frozen);
247
+ await act(async () => { rerender(view(theme, 'original')); });
248
+ expect(painted).toEqual(original);
249
+ await act(async () => { rerender(view(theme)); });
250
+ expect(painted).toEqual(frozen); expect(frames.size).toBe(0);
251
+ }
252
+ expect(paintings[0]).not.toEqual(paintings[1]);
253
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
254
+ advance(10000); expect(painted).toEqual(paintings[1]);
255
+ advance(10100); expect(painted).not.toEqual(paintings[1]);
256
+ expect(container.querySelector('canvas')).toBe(canvas);
257
+ expect(screen.getByLabelText('主题草稿')).toHaveValue('保留我的输入');
258
+ });
259
+
100
260
  it('honors system and explicit reduction, and never lets false bypass the system', () => {
101
261
  reduced.matches = true;
102
262
  const { rerender } = render(<OrbitBackdrop reducedMotion={false} />);
@@ -132,6 +292,70 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
132
292
  expect(frames.size).toBe(1);
133
293
  });
134
294
 
295
+ it('rotates a strawberry donut on compact Neo screens and preserves its frozen frame across appearance changes', async () => {
296
+ recordPaint = true; compact.matches = true;
297
+ const view = (appearance: 'theme' | 'original' = 'theme', theme = 'neo-brutalism') => <div className="orbit-linework-fixture" data-rhine-theme={theme}>
298
+ <OrbitBackdrop appearance={appearance}><input aria-label="甜甜圈草稿" defaultValue="保留" /></OrbitBackdrop>
299
+ </div>;
300
+ const { container, rerender } = render(view());
301
+ const canvas = container.querySelector('canvas');
302
+ expect(screen.getByRole('button', { name: '暂停动效' })).toBeEnabled();
303
+ const pinkFaces = painted.filter(paint => {
304
+ if (paint.kind !== 'fill') return false;
305
+ const rgb = String(paint.color).match(/^rgb\((\d+),(\d+),(\d+)\)$/);
306
+ return rgb && Number(rgb[1]) > Number(rgb[3]) && Number(rgb[3]) > Number(rgb[2]);
307
+ });
308
+ expect(pinkFaces.length).toBeGreaterThan(100);
309
+ expect(trails).toBe(0); expect(glows).toBe(0);
310
+ advance(100); const firstFrame = painted;
311
+ advance(200); expect(painted).not.toEqual(firstFrame);
312
+ fireEvent.change(screen.getByLabelText('甜甜圈草稿'), { target: { value: '保留我的输入' } });
313
+ fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
314
+ const frozen = painted;
315
+ expect(frames.size).toBe(0);
316
+ advance(5000); expect(painted).toEqual(frozen);
317
+ await act(async () => { rerender(view('original')); });
318
+ expect(screen.getByRole('button', { name: '窄屏静态画面' })).toBeDisabled();
319
+ expect(trails).toBe(420); expect(glows).toBe(140);
320
+ await act(async () => { rerender(view('theme', 'arknights')); });
321
+ expect(screen.getByRole('button', { name: '继续动效' })).toBeEnabled();
322
+ await act(async () => { rerender(view()); });
323
+ expect(screen.getByRole('button', { name: '继续动效' })).toBeEnabled();
324
+ expect(painted).toEqual(frozen); expect(frames.size).toBe(0);
325
+ expect(container.querySelector('canvas')).toBe(canvas);
326
+ expect(screen.getByLabelText('甜甜圈草稿')).toHaveValue('保留我的输入');
327
+ fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
328
+ advance(10000); expect(painted).toEqual(frozen);
329
+ advance(10100); expect(painted).not.toEqual(frozen);
330
+ });
331
+
332
+ it.each(['default', 'arknights', 'neo-brutalism'])('stops the %s object for every motion preference, visibility change and disposal', async theme => {
333
+ const view = (explicit = false) => <div className="orbit-linework-fixture" data-rhine-theme={theme}>
334
+ <OrbitBackdrop reducedMotion={explicit} />
335
+ </div>;
336
+ compact.matches = true;
337
+ const { rerender, unmount } = render(view());
338
+ expect(frames.size).toBe(1);
339
+ act(() => reduced.set(true)); expect(frames.size).toBe(0);
340
+ rerender(view(false)); expect(frames.size).toBe(0);
341
+ expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
342
+ act(() => reduced.set(false)); expect(frames.size).toBe(1);
343
+ rerender(view(true)); expect(frames.size).toBe(0);
344
+ rerender(view(false)); expect(frames.size).toBe(1);
345
+ await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); });
346
+ expect(frames.size).toBe(0);
347
+ await act(async () => { document.documentElement.removeAttribute('data-rhine-motion'); });
348
+ expect(frames.size).toBe(1);
349
+ act(() => { hidden = true; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(0);
350
+ act(() => { hidden = false; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(1);
351
+ act(() => intersect(false)); expect(frames.size).toBe(0);
352
+ act(() => intersect(true)); expect(frames.size).toBe(1);
353
+ unmount(); const finalDraws = draws;
354
+ expect(frames.size).toBe(0);
355
+ await act(async () => { resize(); intersect(true); reduced.set(true); });
356
+ expect(draws).toBe(finalDraws);
357
+ });
358
+
135
359
  it('stops while hidden, offscreen, compact or empty and redraws a paused resize', () => {
136
360
  const { container } = render(<OrbitBackdrop />);
137
361
  act(() => { hidden = true; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(0);
@@ -170,12 +394,25 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
170
394
  expect(screen.getByRole('button', { name: '静态画面' })).toBeDisabled(); expect(frames.size).toBe(0);
171
395
  });
172
396
 
173
- it('replays the gallery artwork while preserving entered content and external replay focus', () => {
397
+ it('keeps the gallery artwork clear and preserves paused state and drafts across appearance changes and replay', async () => {
174
398
  const { container } = render(<OrbitGallery />); const canvas = container.querySelector('canvas');
399
+ const main = screen.getByRole('region', { name: '轨道背景预览' });
400
+ expect(within(main).queryByRole('textbox')).not.toBeInTheDocument();
401
+ expect(within(main).getAllByRole('button')).toHaveLength(1);
402
+ const disclosure = screen.getByText('查看组合控件示例');
403
+ expect(disclosure.closest('details')).not.toHaveAttribute('open');
404
+ fireEvent.click(disclosure);
175
405
  fireEvent.change(screen.getByRole('textbox', { name: '直接叠加的输入框' }), { target: { value: '新的轨道名称' } });
176
406
  fireEvent.change(screen.getByRole('textbox', { name: '工作空间名称' }), { target: { value: '新的工作空间' } });
407
+ fireEvent.click(within(main).getByRole('button', { name: '暂停动效' }));
408
+ await act(async () => { fireEvent.click(screen.getByRole('radio', { name: '原始深色' })); });
409
+ expect(container.querySelector('canvas')).toBe(canvas);
410
+ expect(main).toHaveAttribute('data-rhine-orbit-appearance', 'original');
411
+ expect(within(main).getByRole('button', { name: '继续动效' })).toBeEnabled();
412
+ await act(async () => { fireEvent.click(screen.getByRole('radio', { name: '跟随主题' })); });
413
+ expect(within(main).getByRole('button', { name: '继续动效' })).toBeEnabled();
177
414
  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);
415
+ expect(container.querySelector('canvas')).not.toBe(canvas); expect(replay).toHaveFocus(); expect(frames.size).toBe(2);
179
416
  expect(screen.getByRole('textbox', { name: '直接叠加的输入框' })).toHaveValue('新的轨道名称');
180
417
  expect(screen.getByRole('textbox', { name: '工作空间名称' })).toHaveValue('新的工作空间');
181
418
  fireEvent.click(screen.getByRole('switch', { name: '减少轨道动态' })); expect(frames.size).toBe(0);
@@ -0,0 +1,91 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { BufferGeometry, InstancedMesh, Material, Texture, Vector3, type Mesh, type PerspectiveCamera, type Scene } from 'three';
3
+ import { createDonutArtwork } from '../components/orbitDonutThree';
4
+ import { projectOrbitPoint, rotateOrbitPoint } from '../components/orbitProjection';
5
+
6
+ const gpu = vi.hoisted(() => ({ renderers: [] as Array<{
7
+ scene?: Scene; camera?: PerspectiveCamera; draws: number; dispose: ReturnType<typeof vi.fn>; forceContextLoss: ReturnType<typeof vi.fn>;
8
+ }> }));
9
+ vi.mock('three', async importOriginal => {
10
+ const actual = await importOriginal<typeof import('three')>();
11
+ return { ...actual, WebGLRenderer: class {
12
+ ratio = 1; draws = 0; scene?: Scene; camera?: PerspectiveCamera;
13
+ dispose = vi.fn(); forceContextLoss = vi.fn();
14
+ constructor(private options: { canvas: HTMLCanvasElement }) { gpu.renderers.push(this); }
15
+ setClearColor() {} setPixelRatio(value: number) { this.ratio = value; }
16
+ setSize(width: number, height: number) { this.options.canvas.width = Math.round(width * this.ratio); this.options.canvas.height = Math.round(height * this.ratio); }
17
+ render(scene: Scene, camera: PerspectiveCamera) { this.scene = scene; this.camera = camera; this.draws++; scene.updateMatrixWorld(); camera.updateMatrixWorld(); }
18
+ } };
19
+ });
20
+
21
+ describe('Three.js strawberry artwork', () => {
22
+ beforeEach(() => {
23
+ gpu.renderers = [];
24
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
25
+ createLinearGradient: () => ({ addColorStop() {} }), fillRect() {}, beginPath() {}, ellipse() {}, fill() {},
26
+ } as unknown as CanvasRenderingContext2D);
27
+ vi.stubGlobal('requestAnimationFrame', vi.fn());
28
+ });
29
+ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
30
+
31
+ it('renders detailed geometry and deterministic particles using only the supplied clock', () => {
32
+ vi.stubGlobal('devicePixelRatio', 3);
33
+ const art = createDonutArtwork(vi.fn()); art.draw(400, 620, 1);
34
+ const renderer = gpu.renderers[0], scene = renderer.scene!;
35
+ expect(art.canvas.width).toBe(600); expect(art.canvas.height).toBe(930);
36
+ expect((scene.getObjectByName('icing-sprinkles') as InstancedMesh).count).toBe(280);
37
+ expect((scene.getObjectByName('sugar-pearls') as InstancedMesh).count).toBe(110);
38
+ const icing = (scene.getObjectByName('strawberry-icing') as Mesh).geometry.getAttribute('position');
39
+ const radii = Array.from({ length: icing.count }, (_, i) => Math.hypot(icing.getX(i), icing.getY(i)));
40
+ // The ring should occupy the original artwork's envelope, even with real tube thickness.
41
+ expect(Math.max(...radii)).toBeGreaterThan(1.19); expect(Math.max(...radii)).toBeLessThan(1.22);
42
+ expect(Math.min(...radii)).toBeGreaterThan(.46); expect(Math.min(...radii)).toBeLessThan(.49);
43
+ const confetti = scene.getObjectByName('floating-confetti') as InstancedMesh;
44
+ expect(confetti.count).toBe(100);
45
+ const capture = () => ({ ring: scene.getObjectByName('strawberry-donut')!.matrixWorld.toArray(), particles: [...confetti.instanceMatrix.array] });
46
+ const frozen = capture(); art.draw(400, 620, 1); expect(capture()).toEqual(frozen);
47
+ art.draw(400, 620, 2); expect(capture().ring).not.toEqual(frozen.ring); expect(capture().particles).not.toEqual(frozen.particles);
48
+ expect(requestAnimationFrame).not.toHaveBeenCalled(); art.dispose();
49
+ });
50
+
51
+ it.each([[400, 620], [520, 360], [880, 526]])('matches the original scale, center and tilt at %s × %s over time', (width, height) => {
52
+ const art = createDonutArtwork(vi.fn());
53
+ for (const time of [0, 1, 4, 10]) {
54
+ art.draw(width, height, time);
55
+ const renderer = gpu.renderers[0], donut = renderer.scene!.getObjectByName('strawberry-donut')!;
56
+ for (const p of [new Vector3(0, 0, 0), new Vector3(1.2, 0, .2), new Vector3(0, 1.1, -.1), new Vector3(-.8, -.3, .4)]) {
57
+ const spin = time * .28 - .35;
58
+ const rotated = rotateOrbitPoint({ x: p.x * Math.cos(spin) - p.y * Math.sin(spin), y: p.x * Math.sin(spin) + p.y * Math.cos(spin), z: p.z });
59
+ const expected = projectOrbitPoint(rotated, Math.min(width * .66, height * .39), width * .62, height * .57);
60
+ const projected = p.clone().applyMatrix4(donut.matrixWorld).project(renderer.camera!);
61
+ expect((projected.x + 1) * width / 2).toBeCloseTo(expected.x, 6);
62
+ expect((1 - projected.y) * height / 2).toBeCloseTo(expected.y, 6);
63
+ }
64
+ }
65
+ art.dispose();
66
+ });
67
+
68
+ it('falls back during context loss and releases geometry, textures and instance resources once', () => {
69
+ const disposeGeometry = vi.spyOn(BufferGeometry.prototype, 'dispose'), disposeMaterial = vi.spyOn(Material.prototype, 'dispose');
70
+ const disposeTexture = vi.spyOn(Texture.prototype, 'dispose'), disposeInstance = vi.spyOn(InstancedMesh.prototype, 'dispose');
71
+ const invalidate = vi.fn(), art = createDonutArtwork(invalidate); art.draw(880, 526, 3);
72
+ const renderer = gpu.renderers[0], lost = new Event('webglcontextlost', { cancelable: true }); art.canvas.dispatchEvent(lost);
73
+ expect(lost.defaultPrevented).toBe(true); expect(art.draw(880, 526, 8)).toBe(false);
74
+ art.canvas.dispatchEvent(new Event('webglcontextrestored')); expect(invalidate).toHaveBeenCalledTimes(2);
75
+ expect(art.draw(880, 526, 3)).toBe(true);
76
+ art.dispose(); art.dispose();
77
+ expect(disposeGeometry).toHaveBeenCalledTimes(8); expect(disposeMaterial).toHaveBeenCalledTimes(9);
78
+ expect(disposeTexture).toHaveBeenCalledOnce(); expect(disposeInstance).toHaveBeenCalledTimes(4);
79
+ expect(renderer.dispose).toHaveBeenCalledOnce(); expect(renderer.forceContextLoss).toHaveBeenCalledOnce();
80
+ art.canvas.dispatchEvent(new Event('webglcontextrestored')); expect(invalidate).toHaveBeenCalledTimes(2);
81
+ expect(art.draw(880, 526, 9)).toBe(false);
82
+ });
83
+
84
+ it('cleans allocations if initialization fails before the first frame', () => {
85
+ const disposeGeometry = vi.spyOn(BufferGeometry.prototype, 'dispose'), disposeTexture = vi.spyOn(Texture.prototype, 'dispose');
86
+ vi.spyOn(InstancedMesh.prototype, 'setMatrixAt').mockImplementationOnce(() => { throw new Error('Synthetic GPU setup failure'); });
87
+ expect(() => createDonutArtwork(vi.fn())).toThrow('Synthetic GPU setup failure');
88
+ expect(disposeGeometry).toHaveBeenCalled(); expect(disposeTexture).toHaveBeenCalledOnce();
89
+ expect(gpu.renderers[0].dispose).toHaveBeenCalledOnce(); expect(gpu.renderers[0].forceContextLoss).toHaveBeenCalledOnce();
90
+ });
91
+ });
@@ -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
+ });