@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.
- package/CHANGELOG.md +39 -0
- package/README.md +26 -8
- package/components/Actions.tsx +18 -1
- package/components/AppShell.tsx +46 -17
- package/components/MOTION.md +9 -1
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +35 -0
- package/components/Overlays.tsx +37 -8
- package/components/README.md +30 -2
- package/components/StatusTimeline.tsx +72 -6
- package/components/StatusTimelineList.tsx +38 -0
- package/components/ThemePicker.tsx +49 -0
- package/components/assets/orbit-bird.svg +578 -0
- package/components/assets/orbit-crystal.svg +11 -0
- package/components/assets/orbit-donut.svg +16 -0
- package/components/assets/theme-previews/arknights.png +0 -0
- package/components/assets/theme-previews/manifest.json +24 -0
- package/components/assets/theme-previews/neo-brutalism.png +0 -0
- package/components/assets/theme-previews/rhine.png +0 -0
- package/components/common.css +3 -3
- package/components/components.tsx +3 -3
- package/components/dialog-settings.css +9 -0
- package/components/extensions.css +8 -32
- package/components/index.ts +13 -5
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +120 -0
- package/components/orbitBirdFlight.ts +34 -0
- package/components/orbitBirdGeometry.ts +158 -0
- package/components/orbitDonut.ts +134 -0
- package/components/orbitDonutThree.ts +208 -0
- package/components/orbitObjectMaterials.ts +76 -0
- package/components/orbitObjects.ts +71 -0
- package/components/orbitObjectsThree.ts +215 -0
- package/components/orbitProjection.ts +21 -0
- package/components/orbitRenderer.ts +347 -0
- package/components/styles.css +4 -1
- package/components/theme-neo-brutalism.css +3 -2
- package/components/theme-picker.css +17 -0
- package/components/theme.tsx +2 -8
- package/components/themeMetadata.ts +7 -0
- package/components/timeline.css +17 -6
- package/components/useFloatingScale.ts +96 -0
- package/docs/APPLICATION_COMPONENTS.md +52 -3
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +7 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +73 -0
- package/docs/SETTINGS_DIALOG.md +21 -0
- package/docs/THEME_PICKER.md +26 -0
- package/docs/THEMING.md +2 -0
- package/docs/assets/compact-account-neo.png +0 -0
- package/docs/assets/neutron-star-original.png +0 -0
- package/docs/assets/neutron-star-theme.png +0 -0
- package/docs/assets/orbit-backdrop.png +0 -0
- package/docs/assets/orbit-bird.png +0 -0
- package/docs/assets/orbit-crystal.png +0 -0
- package/docs/assets/orbit-strawberry.png +0 -0
- package/package.json +8 -3
- package/preview/CompactAccountGallery.tsx +178 -0
- package/preview/DateRangeFilterGallery.tsx +109 -0
- package/preview/ExtensionsGallery.tsx +27 -18
- package/preview/GalleryShell.tsx +3 -3
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +50 -0
- package/preview/OverlayBoundsGallery.tsx +49 -0
- package/preview/SettingsDialogGallery.tsx +33 -0
- package/preview/ThemePickerGallery.tsx +24 -0
- package/preview/ThemeSample.tsx +14 -0
- package/preview/TimelineListGallery.tsx +36 -0
- package/preview/extensions.css +9 -0
- package/preview/main.tsx +19 -4
- package/preview/preview.css +8 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-overlay-bounds.js +76 -0
- package/scripts/check-settings-theme.js +88 -0
- package/skills/rhine-design/SKILL.md +3 -2
- package/skills/rhine-design/references/API_MAP.md +14 -1
- package/skills/rhine-design/references/TASK_MAP.md +8 -1
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/AppShellControls.test.tsx +82 -0
- package/tests/ButtonLink.test.tsx +94 -0
- package/tests/CompactAccountGallery.test.tsx +116 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/NeutronStarBackdrop.test.tsx +288 -0
- package/tests/NeutronStarGallery.test.tsx +62 -0
- package/tests/NeutronStarImport.test.tsx +15 -0
- package/tests/OrbitBackdrop.test.tsx +420 -0
- package/tests/OrbitDonutThree.test.tsx +91 -0
- package/tests/OrbitObjectsThree.test.tsx +164 -0
- package/tests/SettingsDialog.test.tsx +66 -0
- package/tests/StatusTimelineList.test.tsx +63 -0
- package/tests/ThemePicker.test.tsx +30 -0
- package/tests/TimelineLegend.test.tsx +60 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
|
4
|
+
import { OrbitBackdrop } from '../components';
|
|
5
|
+
import { mountOrbit } from '../components/orbitRenderer';
|
|
6
|
+
import { projectOrbitPoint, rotateOrbitPoint } from '../components/orbitProjection';
|
|
7
|
+
import { OrbitGallery } from '../preview/OrbitGallery';
|
|
8
|
+
|
|
9
|
+
class Media extends EventTarget {
|
|
10
|
+
matches = false;
|
|
11
|
+
set(value: boolean) { this.matches = value; this.dispatchEvent(new Event('change')); }
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('OrbitBackdrop artwork and lifecycle', () => {
|
|
20
|
+
let reduced: Media, compact: Media, hidden: boolean, bounds: { width: number; height: number };
|
|
21
|
+
let frameId: number, frames: Map<number, FrameRequestCallback>;
|
|
22
|
+
let resize: () => void, intersect: (value: boolean) => void;
|
|
23
|
+
let resizeDisconnect: () => void, intersectionDisconnect: () => void;
|
|
24
|
+
let draws: number, lines: number, trails: number, glows: number, firstPoint: number[], firstRadius: number, zoom: number;
|
|
25
|
+
let hasPoint: boolean;
|
|
26
|
+
let context: CanvasRenderingContext2D;
|
|
27
|
+
let paletteStyles: HTMLStyleElement;
|
|
28
|
+
let gradientColors: Set<string>;
|
|
29
|
+
let recordPaint: boolean, painted: Paint[], dashes: number[];
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
reduced = new Media(); compact = new Media(); hidden = false;
|
|
33
|
+
bounds = { width: 600, height: 680 }; frameId = 0; frames = new Map();
|
|
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);
|
|
49
|
+
resizeDisconnect = vi.fn(); intersectionDisconnect = vi.fn();
|
|
50
|
+
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
|
|
51
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'clientWidth', 'get').mockImplementation(() => bounds.width);
|
|
52
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'clientHeight', 'get').mockImplementation(() => bounds.height);
|
|
53
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width: bounds.width * zoom, height: bounds.height * zoom, x: 0, y: 0, top: 0, left: 0, right: bounds.width * zoom, bottom: bounds.height * zoom, toJSON() {} }));
|
|
54
|
+
vi.stubGlobal('matchMedia', (query: string) => query.includes('prefers-reduced') ? reduced : compact);
|
|
55
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { const id = ++frameId; frames.set(id, callback); return id; });
|
|
56
|
+
vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id));
|
|
57
|
+
vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() { resizeDisconnect(); } });
|
|
58
|
+
vi.stubGlobal('IntersectionObserver', class {
|
|
59
|
+
constructor(private callback: (entries: Partial<IntersectionObserverEntry>[]) => void) {}
|
|
60
|
+
observe(target: Element) { intersect = value => this.callback([{ target, isIntersecting: value }]); }
|
|
61
|
+
disconnect() { intersectionDisconnect(); }
|
|
62
|
+
});
|
|
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
|
+
};
|
|
71
|
+
context = {
|
|
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]; },
|
|
93
|
+
createLinearGradient() { trails++; return gradient; },
|
|
94
|
+
createRadialGradient() { glows++; return gradient; },
|
|
95
|
+
} as unknown as CanvasRenderingContext2D;
|
|
96
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => { cleanup(); paletteStyles.remove(); document.documentElement.removeAttribute('data-rhine-motion'); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
|
99
|
+
|
|
100
|
+
function advance(now: number) {
|
|
101
|
+
act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(now)); });
|
|
102
|
+
}
|
|
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
|
+
|
|
117
|
+
it('retains the complete seeded artwork, caps pixel density and pauses without catching up', () => {
|
|
118
|
+
vi.stubGlobal('devicePixelRatio', 3);
|
|
119
|
+
const canvas = document.createElement('canvas'); document.body.append(canvas);
|
|
120
|
+
const scene = mountOrbit(canvas, () => {});
|
|
121
|
+
expect(canvas.width).toBe(1200); expect(canvas.height).toBe(1360);
|
|
122
|
+
expect(trails).toBe(420); expect(glows).toBe(140); expect(lines).toBeGreaterThan(185 * 80);
|
|
123
|
+
expect(gradientColors).toEqual(new Set(['#addaff', '#addaff00', '#e1a147', '#e1a14700']));
|
|
124
|
+
advance(100); const initial = [...firstPoint]; advance(200); const moving = [...firstPoint];
|
|
125
|
+
expect(moving).not.toEqual(initial);
|
|
126
|
+
scene.setPaused(true); const stoppedDraws = draws;
|
|
127
|
+
expect(frames.size).toBe(0); advance(9000); expect(draws).toBe(stoppedDraws);
|
|
128
|
+
scene.setPaused(false); advance(10000); expect(firstPoint).toEqual(moving);
|
|
129
|
+
advance(10100); expect(firstPoint).not.toEqual(moving);
|
|
130
|
+
scene.dispose(); canvas.remove();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('keeps children and keyboard-capable pause control while preserving the current canvas', () => {
|
|
134
|
+
const { container } = render(<OrbitBackdrop><input aria-label="草稿" defaultValue="保留内容" /></OrbitBackdrop>);
|
|
135
|
+
const canvas = container.querySelector('canvas'); expect(canvas).toHaveAttribute('aria-hidden', 'true');
|
|
136
|
+
const input = screen.getByLabelText('草稿'); fireEvent.change(input, { target: { value: '已经输入' } });
|
|
137
|
+
fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
|
|
138
|
+
expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '继续动效' })).not.toHaveAttribute('aria-pressed');
|
|
139
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'paused');
|
|
140
|
+
fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
|
|
141
|
+
expect(frames.size).toBe(1); expect(container.querySelector('canvas')).toBe(canvas); expect(input).toHaveValue('已经输入');
|
|
142
|
+
expect(screen.getByRole('button', { name: '暂停动效' })).not.toHaveAttribute('aria-pressed');
|
|
143
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'playing');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('keeps geometry and stroke proportions stable when CSS zoom changes before a pause or resize', () => {
|
|
147
|
+
const { container } = render(<OrbitBackdrop />);
|
|
148
|
+
const canvas = container.querySelector('canvas')!;
|
|
149
|
+
const snapshot = () => ({ width: canvas.width, height: canvas.height, point: [...firstPoint], lineWidth: context.lineWidth, radius: firstRadius });
|
|
150
|
+
const before = snapshot();
|
|
151
|
+
// CSS zoom changes the visual rectangle without changing layout dimensions or issuing RO.
|
|
152
|
+
zoom = 2;
|
|
153
|
+
expect(canvas.getBoundingClientRect().width).toBe(canvas.clientWidth * 2);
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
|
|
155
|
+
expect(snapshot()).toEqual(before); expect(frames.size).toBe(0);
|
|
156
|
+
act(() => resize()); expect(snapshot()).toEqual(before);
|
|
157
|
+
fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
|
|
158
|
+
advance(100); expect(snapshot()).toEqual(before); expect(frames.size).toBe(1);
|
|
159
|
+
});
|
|
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
|
+
|
|
260
|
+
it('honors system and explicit reduction, and never lets false bypass the system', () => {
|
|
261
|
+
reduced.matches = true;
|
|
262
|
+
const { rerender } = render(<OrbitBackdrop reducedMotion={false} />);
|
|
263
|
+
expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
|
|
264
|
+
act(() => reduced.set(false)); expect(frames.size).toBe(1);
|
|
265
|
+
rerender(<OrbitBackdrop reducedMotion />); expect(frames.size).toBe(0);
|
|
266
|
+
rerender(<OrbitBackdrop reducedMotion={false} />); expect(frames.size).toBe(1);
|
|
267
|
+
act(() => reduced.set(true)); expect(frames.size).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('synchronizes a media change observed by a frame before the change event arrives', () => {
|
|
271
|
+
const { container } = render(<OrbitBackdrop />);
|
|
272
|
+
advance(100); advance(200); const frozen = [...firstPoint];
|
|
273
|
+
reduced.matches = true;
|
|
274
|
+
advance(300);
|
|
275
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'reduced');
|
|
276
|
+
expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
|
|
277
|
+
expect(frames.size).toBe(0); expect(firstPoint).toEqual(frozen);
|
|
278
|
+
const stoppedDraws = draws; advance(9000); expect(draws).toBe(stoppedDraws);
|
|
279
|
+
act(() => reduced.set(false));
|
|
280
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'playing');
|
|
281
|
+
expect(screen.getByRole('button', { name: '暂停动效' })).toBeEnabled();
|
|
282
|
+
advance(10000); expect(firstPoint).toEqual(frozen);
|
|
283
|
+
advance(10100); expect(firstPoint).not.toEqual(frozen);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('observes ancestor motion changes even through a local system preference', async () => {
|
|
287
|
+
render(<div data-rhine-motion="system"><OrbitBackdrop /></div>);
|
|
288
|
+
expect(frames.size).toBe(1);
|
|
289
|
+
await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); });
|
|
290
|
+
expect(frames.size).toBe(0);
|
|
291
|
+
await act(async () => { document.documentElement.removeAttribute('data-rhine-motion'); });
|
|
292
|
+
expect(frames.size).toBe(1);
|
|
293
|
+
});
|
|
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
|
+
|
|
359
|
+
it('stops while hidden, offscreen, compact or empty and redraws a paused resize', () => {
|
|
360
|
+
const { container } = render(<OrbitBackdrop />);
|
|
361
|
+
act(() => { hidden = true; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(0);
|
|
362
|
+
act(() => { hidden = false; document.dispatchEvent(new Event('visibilitychange')); }); expect(frames.size).toBe(1);
|
|
363
|
+
act(() => intersect(false)); expect(frames.size).toBe(0);
|
|
364
|
+
act(() => intersect(true)); expect(frames.size).toBe(1);
|
|
365
|
+
act(() => compact.set(true)); expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '窄屏静态画面' })).toBeDisabled();
|
|
366
|
+
act(() => compact.set(false)); expect(frames.size).toBe(1);
|
|
367
|
+
act(() => { bounds.width = 0; resize(); }); expect(frames.size).toBe(0);
|
|
368
|
+
act(() => { bounds.width = 400; resize(); }); expect(frames.size).toBe(1);
|
|
369
|
+
fireEvent.click(screen.getByRole('button', { name: '暂停动效' })); const beforeResize = draws;
|
|
370
|
+
act(() => { bounds.height = 500; resize(); }); expect(draws).toBeGreaterThan(beforeResize); expect(frames.size).toBe(0);
|
|
371
|
+
expect(container.querySelector('canvas')).toHaveAttribute('height', '500');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('cleans up all scheduling and observers, including Strict Mode mount replay', async () => {
|
|
375
|
+
const visibilitySpy = vi.spyOn(document, 'removeEventListener');
|
|
376
|
+
const resizeSpy = vi.spyOn(window, 'removeEventListener');
|
|
377
|
+
const reducedSpy = vi.spyOn(reduced, 'removeEventListener');
|
|
378
|
+
const compactSpy = vi.spyOn(compact, 'removeEventListener');
|
|
379
|
+
const { unmount } = render(<StrictMode><OrbitBackdrop /></StrictMode>);
|
|
380
|
+
expect(frames.size).toBe(1); unmount(); const finalDraws = draws;
|
|
381
|
+
expect(frames.size).toBe(0); expect(resizeDisconnect).toHaveBeenCalledTimes(2); expect(intersectionDisconnect).toHaveBeenCalledTimes(2);
|
|
382
|
+
expect(visibilitySpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
|
383
|
+
expect(resizeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
|
|
384
|
+
expect(reducedSpy).toHaveBeenCalledWith('change', expect.any(Function)); expect(compactSpy).toHaveBeenCalledWith('change', expect.any(Function));
|
|
385
|
+
await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); reduced.set(true); resize(); intersect(true); });
|
|
386
|
+
expect(draws).toBe(finalDraws); expect(frames.size).toBe(0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it.each(['null', 'throw'])('keeps content usable when the canvas context is %s', mode => {
|
|
390
|
+
vi.mocked(HTMLCanvasElement.prototype.getContext).mockImplementation(() => { if (mode === 'throw') throw new Error('Canvas unavailable'); return null; });
|
|
391
|
+
const { container } = render(<OrbitBackdrop><button>继续工作</button></OrbitBackdrop>);
|
|
392
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'unavailable');
|
|
393
|
+
expect(screen.getByRole('button', { name: '继续工作' })).toBeEnabled();
|
|
394
|
+
expect(screen.getByRole('button', { name: '静态画面' })).toBeDisabled(); expect(frames.size).toBe(0);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('keeps the gallery artwork clear and preserves paused state and drafts across appearance changes and replay', async () => {
|
|
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);
|
|
405
|
+
fireEvent.change(screen.getByRole('textbox', { name: '直接叠加的输入框' }), { target: { value: '新的轨道名称' } });
|
|
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();
|
|
414
|
+
const replay = screen.getByRole('button', { name: '重播轨道' }); replay.focus(); fireEvent.click(replay);
|
|
415
|
+
expect(container.querySelector('canvas')).not.toBe(canvas); expect(replay).toHaveFocus(); expect(frames.size).toBe(2);
|
|
416
|
+
expect(screen.getByRole('textbox', { name: '直接叠加的输入框' })).toHaveValue('新的轨道名称');
|
|
417
|
+
expect(screen.getByRole('textbox', { name: '工作空间名称' })).toHaveValue('新的工作空间');
|
|
418
|
+
fireEvent.click(screen.getByRole('switch', { name: '减少轨道动态' })); expect(frames.size).toBe(0);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -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
|
+
});
|