@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.
- package/CHANGELOG.md +32 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AppShell.tsx +46 -17
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +4 -2
- package/components/Overlays.tsx +37 -8
- package/components/README.md +27 -3
- 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 +11 -5
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +79 -1
- 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 +163 -46
- package/components/styles.css +3 -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 +38 -46
- 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-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 +35 -32
- 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 +16 -3
- 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 +13 -2
- package/skills/rhine-design/references/TASK_MAP.md +8 -2
- 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 +246 -9
- 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,85 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { DateRangeFilterExample } from '../preview/DateRangeFilterGallery';
|
|
4
|
+
|
|
5
|
+
function demo(props = {}) {
|
|
6
|
+
render(<div className="rhine-theme"><DateRangeFilterExample {...props} /></div>);
|
|
7
|
+
}
|
|
8
|
+
const trigger = () => screen.getByRole('button', { name: /时间范围:.+选择日期/ });
|
|
9
|
+
async function open() {
|
|
10
|
+
trigger().focus(); fireEvent.click(trigger());
|
|
11
|
+
return screen.findByRole('dialog', { name: '时间范围设置' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('date range filtering composition', () => {
|
|
15
|
+
it('applies a quick preset immediately and always displays its dates', () => {
|
|
16
|
+
demo();
|
|
17
|
+
fireEvent.click(screen.getByRole('radio', { name: '昨天' }));
|
|
18
|
+
expect(screen.getByRole('radio', { name: '昨天' })).toBeChecked();
|
|
19
|
+
expect(trigger()).toHaveTextContent('2026-10-23 — 2026-10-23');
|
|
20
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 1 次');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps edits as drafts, discards cancellation and restores trigger focus', async () => {
|
|
24
|
+
demo(); const dialog = await open();
|
|
25
|
+
fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '2026-10-01' } });
|
|
26
|
+
expect(screen.getByRole('status')).toHaveTextContent('2026-10-24 — 2026-10-24 · 本次操作 0 次');
|
|
27
|
+
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
|
28
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
29
|
+
await waitFor(() => expect(trigger()).toHaveFocus());
|
|
30
|
+
const reopened = await open();
|
|
31
|
+
expect(within(reopened).getByLabelText('开始日期')).toHaveValue('2026-10-24');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not commit on Escape or the overlay close button', async () => {
|
|
35
|
+
demo();
|
|
36
|
+
for (const close of ['escape', 'button']) {
|
|
37
|
+
const dialog = await open();
|
|
38
|
+
fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '2026-10-02' } });
|
|
39
|
+
if (close === 'escape') fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
|
40
|
+
else fireEvent.click(within(dialog).getByRole('button', { name: '关闭详情' }));
|
|
41
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
42
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 0 次');
|
|
43
|
+
await waitFor(() => expect(trigger()).toHaveFocus());
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('blocks incomplete, reversed and out-of-range dates before applying one valid range', async () => {
|
|
48
|
+
demo(); const dialog = await open();
|
|
49
|
+
const start = within(dialog).getByLabelText('开始日期'), end = within(dialog).getByLabelText('结束日期');
|
|
50
|
+
const apply = within(dialog).getByRole('button', { name: '应用日期' });
|
|
51
|
+
for (const [from, to] of [['', '2026-10-24'], ['2026-10-24', ''], ['2026-10-25', '2026-10-24'], ['2024-12-31', '2026-10-24'], ['2026-10-24', '2027-01-01']]) {
|
|
52
|
+
fireEvent.change(start, { target: { value: from } });
|
|
53
|
+
fireEvent.change(end, { target: { value: to } });
|
|
54
|
+
expect(apply).toBeDisabled();
|
|
55
|
+
fireEvent.submit(apply.closest('form')!);
|
|
56
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 0 次');
|
|
57
|
+
}
|
|
58
|
+
fireEvent.change(start, { target: { value: '2025-12-28' } });
|
|
59
|
+
fireEvent.change(end, { target: { value: '2026-01-06' } });
|
|
60
|
+
await waitFor(() => expect(apply).toBeEnabled());
|
|
61
|
+
fireEvent.click(apply);
|
|
62
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
63
|
+
expect(screen.getByRole('status')).toHaveTextContent('2025-12-28 — 2026-01-06 · 本次操作 1 次');
|
|
64
|
+
expect(screen.getAllByRole('radio').every(radio => !(radio as HTMLInputElement).checked)).toBe(true);
|
|
65
|
+
await waitFor(() => expect(trigger()).toHaveFocus());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('applies additional presets immediately instead of submitting an unfinished draft', async () => {
|
|
69
|
+
demo(); const dialog = await open();
|
|
70
|
+
fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '' } });
|
|
71
|
+
fireEvent.click(within(dialog).getByRole('button', { name: '最近 30 天' }));
|
|
72
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
73
|
+
expect(trigger()).toHaveTextContent('2026-09-25 — 2026-10-24');
|
|
74
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 1 次');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps a disabled range readable and prevents all entry points', () => {
|
|
78
|
+
demo({ disabled: true });
|
|
79
|
+
expect(trigger()).toBeDisabled();
|
|
80
|
+
expect(screen.getAllByRole('radio').every(radio => (radio as HTMLInputElement).disabled)).toBe(true);
|
|
81
|
+
fireEvent.click(trigger());
|
|
82
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
83
|
+
expect(screen.getByRole('status')).toHaveTextContent('2026-10-24 — 2026-10-24 · 本次操作 0 次');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { BufferGeometry, Material, ShaderMaterial, Vector3, type Scene, type PerspectiveCamera, type Object3D, type Mesh, type Points, type Line } from 'three';
|
|
6
|
+
import { NeutronStarBackdrop } from '../components/NeutronStarBackdrop';
|
|
7
|
+
import { mountNeutron } from '../components/neutronRenderer';
|
|
8
|
+
|
|
9
|
+
const gpu = vi.hoisted(() => ({ instances: [] as Array<{
|
|
10
|
+
domElement: HTMLCanvasElement; scene?: Scene; camera?: PerspectiveCamera; draws: number; time: number;
|
|
11
|
+
dispose: ReturnType<typeof vi.fn>; forceContextLoss: ReturnType<typeof vi.fn>;
|
|
12
|
+
}>, constructorFails: false, renderFails: false, materialFailsAt: 0, materialCount: 0 }));
|
|
13
|
+
vi.mock('three', async importOriginal => {
|
|
14
|
+
const actual = await importOriginal<typeof import('three')>();
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
ShaderMaterial: class extends actual.ShaderMaterial {
|
|
18
|
+
constructor(parameters: ConstructorParameters<typeof actual.ShaderMaterial>[0]) {
|
|
19
|
+
super(parameters);
|
|
20
|
+
if (++gpu.materialCount === gpu.materialFailsAt) throw new Error('Shader initialization failed');
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
WebGLRenderer: class {
|
|
24
|
+
domElement = document.createElement('canvas');
|
|
25
|
+
ratio = 1; draws = 0; time = 0;
|
|
26
|
+
scene?: Scene; camera?: PerspectiveCamera;
|
|
27
|
+
dispose = vi.fn(); forceContextLoss = vi.fn();
|
|
28
|
+
constructor() { if (gpu.constructorFails) throw new Error('WebGL unavailable'); gpu.instances.push(this); }
|
|
29
|
+
setClearColor() {}
|
|
30
|
+
setPixelRatio(value: number) { this.ratio = value; }
|
|
31
|
+
getPixelRatio() { return this.ratio; }
|
|
32
|
+
setSize(width: number, height: number) { this.domElement.width = Math.round(width * this.ratio); this.domElement.height = Math.round(height * this.ratio); }
|
|
33
|
+
render(scene: Scene, camera: PerspectiveCamera) {
|
|
34
|
+
if (gpu.renderFails) throw new Error('GPU lost');
|
|
35
|
+
this.scene = scene; this.camera = camera; this.draws++;
|
|
36
|
+
scene.updateMatrixWorld(); camera.updateMatrixWorld();
|
|
37
|
+
scene.traverse(object => { const value = (object as Mesh).material as ShaderMaterial; if (value?.uniforms?.time) this.time = value.uniforms.time.value; });
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
class Media extends EventTarget {
|
|
44
|
+
matches = false;
|
|
45
|
+
set(value: boolean) { this.matches = value; this.dispatchEvent(new Event('change')); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('NeutronStarBackdrop artwork and WebGL lifecycle', () => {
|
|
49
|
+
let motion: Media, hidden: boolean, bounds: { width: number; height: number }, zoom: number;
|
|
50
|
+
let frameId: number, frames: Map<number, FrameRequestCallback>;
|
|
51
|
+
let resize: () => void, intersect: (visible: boolean) => void;
|
|
52
|
+
let resizeDisconnect: () => void, intersectionDisconnect: () => void;
|
|
53
|
+
let disposedGeometry: BufferGeometry[], disposedMaterial: Material[];
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
gpu.instances = []; gpu.constructorFails = gpu.renderFails = false; gpu.materialCount = gpu.materialFailsAt = 0;
|
|
56
|
+
motion = new Media(); hidden = false; zoom = 1; bounds = { width: 880, height: 620 }; frames = new Map(); frameId = 0;
|
|
57
|
+
disposedGeometry = []; disposedMaterial = [];
|
|
58
|
+
resizeDisconnect = vi.fn(); intersectionDisconnect = vi.fn();
|
|
59
|
+
vi.spyOn(BufferGeometry.prototype, 'dispose').mockImplementation(function (this: BufferGeometry) { disposedGeometry.push(this); });
|
|
60
|
+
vi.spyOn(Material.prototype, 'dispose').mockImplementation(function (this: Material) { disposedMaterial.push(this); });
|
|
61
|
+
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
|
|
62
|
+
vi.spyOn(HTMLDivElement.prototype, 'clientWidth', 'get').mockImplementation(() => bounds.width);
|
|
63
|
+
vi.spyOn(HTMLDivElement.prototype, 'clientHeight', 'get').mockImplementation(() => bounds.height);
|
|
64
|
+
vi.spyOn(HTMLDivElement.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() {} }));
|
|
65
|
+
vi.stubGlobal('WebGL2RenderingContext', function () {});
|
|
66
|
+
vi.stubGlobal('matchMedia', () => motion);
|
|
67
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { const id = ++frameId; frames.set(id, callback); return id; });
|
|
68
|
+
vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id));
|
|
69
|
+
vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() { resizeDisconnect(); } });
|
|
70
|
+
vi.stubGlobal('IntersectionObserver', class {
|
|
71
|
+
constructor(private callback: (entries: Partial<IntersectionObserverEntry>[]) => void) {}
|
|
72
|
+
observe(target: Element) { intersect = value => this.callback([{ target, isIntersecting: value }]); }
|
|
73
|
+
disconnect() { intersectionDisconnect(); }
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
afterEach(() => { cleanup(); document.documentElement.removeAttribute('data-rhine-motion'); document.documentElement.removeAttribute('data-rhine-theme'); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
|
77
|
+
function advance(now: number) {
|
|
78
|
+
act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(now)); });
|
|
79
|
+
}
|
|
80
|
+
function mount(options: Parameters<typeof mountNeutron>[2] = {}) {
|
|
81
|
+
const host = document.createElement('div'); document.body.append(host);
|
|
82
|
+
const state = vi.fn(); const scene = mountNeutron(host, state, options);
|
|
83
|
+
return { scene, host, state, remove() { scene.dispose(); host.remove(); } };
|
|
84
|
+
}
|
|
85
|
+
async function ready() { await waitFor(() => expect(gpu.instances.length).toBe(1)); }
|
|
86
|
+
|
|
87
|
+
it('renders static server markup without browser globals or constructing a renderer', () => {
|
|
88
|
+
vi.stubGlobal('window', undefined);
|
|
89
|
+
const html = renderToString(<NeutronStarBackdrop><p>Readable immediately</p></NeutronStarBackdrop>);
|
|
90
|
+
expect(html).toContain('rhine-neutron-fallback'); expect(html).toContain('Readable immediately');
|
|
91
|
+
expect(gpu.instances).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('retains the star, 28 field lines, 560 streams, 700 dust points and the 28-second orbit', () => {
|
|
95
|
+
const view = mount({ appearance: 'original' }); const instance = gpu.instances[0];
|
|
96
|
+
const objects: Object3D[] = []; instance.scene!.traverse(value => objects.push(value));
|
|
97
|
+
expect(objects.filter(object => object.type === 'Line')).toHaveLength(28);
|
|
98
|
+
expect(objects.filter(object => object.type === 'Points').map(object => (object as Points).geometry.attributes.position.count)).toEqual([560, 700]);
|
|
99
|
+
expect(objects.filter(object => object.type === 'Mesh')).toHaveLength(7);
|
|
100
|
+
const orbit = instance.scene!.children[0]; const initial = orbit.position.toArray();
|
|
101
|
+
advance(0); for (let ms = 50; ms <= 14000; ms += 50) advance(ms);
|
|
102
|
+
expect(instance.time).toBeCloseTo(14, 6); expect(orbit.position.z).toBeCloseTo(-1.35, 6);
|
|
103
|
+
for (let ms = 14050; ms <= 28000; ms += 50) advance(ms);
|
|
104
|
+
expect(instance.time).toBeCloseTo(28, 6);
|
|
105
|
+
expect(orbit.position.x).toBeCloseTo(initial[0], 6); expect(orbit.position.z).toBeCloseTo(initial[2], 6);
|
|
106
|
+
view.remove();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keeps inner and outer stream particles on their corresponding magnetic field lines', () => {
|
|
110
|
+
const view = mount();
|
|
111
|
+
const objects: Object3D[] = []; gpu.instances[0].scene!.traverse(value => objects.push(value));
|
|
112
|
+
const lines = objects.filter(object => object.type === 'Line') as Line[];
|
|
113
|
+
const streams = objects.find(object => object.type === 'Points' && (object as Points).geometry.attributes.position.count === 560) as Points;
|
|
114
|
+
const seeds = streams.geometry.attributes.position;
|
|
115
|
+
const errors = [0, 2, 14, 280, 282, 294].map(index => {
|
|
116
|
+
// At time zero, these seeds coincide with sampled vertices on the dipole curves.
|
|
117
|
+
const theta = .24 + seeds.getY(index) * 2.66159;
|
|
118
|
+
const azimuth = seeds.getX(index) * 6.28318;
|
|
119
|
+
const radius = seeds.getZ(index) * Math.sin(theta) ** 2;
|
|
120
|
+
const particle = new Vector3(radius * Math.sin(theta) * Math.cos(azimuth), radius * Math.cos(theta), radius * Math.sin(theta) * Math.sin(azimuth));
|
|
121
|
+
const line = lines[index % 14 + (index < 280 ? 0 : 14)].geometry.attributes.position;
|
|
122
|
+
const fieldPoint = new Vector3().fromBufferAttribute(line, Math.round(seeds.getY(index) * 100));
|
|
123
|
+
return particle.distanceTo(fieldPoint);
|
|
124
|
+
});
|
|
125
|
+
view.remove();
|
|
126
|
+
errors.forEach(error => expect(error).toBeLessThan(.00001));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not cull shader-generated streams with bounds that exclude their field lines', () => {
|
|
130
|
+
const view = mount();
|
|
131
|
+
const objects: Object3D[] = []; gpu.instances[0].scene!.traverse(value => objects.push(value));
|
|
132
|
+
const streams = objects.find(object => object.type === 'Points' && (object as Points).geometry.attributes.position.count === 560) as Points;
|
|
133
|
+
streams.geometry.computeBoundingSphere();
|
|
134
|
+
const bound = streams.geometry.boundingSphere!;
|
|
135
|
+
const coversField = objects.filter(object => object.type === 'Line').every(object => {
|
|
136
|
+
const positions = (object as Line).geometry.attributes.position;
|
|
137
|
+
return Array.from({ length: positions.count }, (_, index) => new Vector3().fromBufferAttribute(positions, index))
|
|
138
|
+
.every(point => bound.containsPoint(point));
|
|
139
|
+
});
|
|
140
|
+
const safelyVisible = !streams.frustumCulled || coversField;
|
|
141
|
+
view.remove();
|
|
142
|
+
expect(safelyVisible).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('uses layout pixels at 200% CSS zoom, caps DPR, and redraws a paused resize without advancing time', () => {
|
|
146
|
+
vi.stubGlobal('devicePixelRatio', 3);
|
|
147
|
+
const view = mount(); const instance = gpu.instances[0];
|
|
148
|
+
expect(instance.domElement.width).toBe(1320); expect(instance.domElement.height).toBe(930);
|
|
149
|
+
advance(100); advance(200); view.scene.setPaused(true);
|
|
150
|
+
const frozen = instance.time; zoom = 2; resize();
|
|
151
|
+
expect(instance.domElement.width).toBe(1320); expect(instance.camera!.aspect).toBeCloseTo(880 / 620);
|
|
152
|
+
bounds.width = 400; resize(); expect(instance.domElement.width).toBe(600); expect(instance.time).toBe(frozen); expect(frames.size).toBe(0);
|
|
153
|
+
view.remove();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it.each([[400, 1008], [400, 620], [160, 620], [880, 740]])('keeps the complete core in view throughout its orbit at %s × %s', (width, height) => {
|
|
157
|
+
bounds = { width, height };
|
|
158
|
+
const view = mount({ appearance: 'original' }); const instance = gpu.instances[0];
|
|
159
|
+
let core!: Mesh;
|
|
160
|
+
instance.scene!.traverse(object => { if ((object as Mesh).geometry?.type === 'SphereGeometry') core ??= object as Mesh; });
|
|
161
|
+
function checkCore() {
|
|
162
|
+
const points = core.geometry.attributes.position, point = new Vector3();
|
|
163
|
+
for (let i = 0; i < points.count; i++) {
|
|
164
|
+
point.fromBufferAttribute(points, i).applyMatrix4(core.matrixWorld).project(instance.camera!);
|
|
165
|
+
expect(Math.abs(point.x)).toBeLessThan(.95); expect(Math.abs(point.y)).toBeLessThan(.95);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
advance(0); checkCore();
|
|
169
|
+
for (let ms = 50; ms <= 28000; ms += 50) { advance(ms); if (ms % 3500 === 0) checkCore(); }
|
|
170
|
+
expect(instance.time).toBeCloseTo(28, 6); view.remove();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('pauses and resumes the same scene without catching up or replacing entered content', async () => {
|
|
174
|
+
const { container } = render(<NeutronStarBackdrop><input aria-label="草稿" defaultValue="保留" /></NeutronStarBackdrop>); await ready();
|
|
175
|
+
const instance = gpu.instances[0], canvas = container.querySelector('canvas');
|
|
176
|
+
fireEvent.change(screen.getByLabelText('草稿'), { target: { value: '已修改' } });
|
|
177
|
+
advance(100); advance(200); const frozen = instance.time;
|
|
178
|
+
fireEvent.click(screen.getByRole('button', { name: '暂停动效' })); expect(frames.size).toBe(0);
|
|
179
|
+
expect(container.firstChild).toHaveAttribute('data-rhine-neutron-state', 'paused');
|
|
180
|
+
advance(9000); expect(instance.time).toBe(frozen);
|
|
181
|
+
fireEvent.click(screen.getByRole('button', { name: '继续动效' })); advance(10000); expect(instance.time).toBe(frozen);
|
|
182
|
+
advance(10100); expect(instance.time).toBeGreaterThan(frozen);
|
|
183
|
+
expect(container.querySelector('canvas')).toBe(canvas); expect(screen.getByLabelText('草稿')).toHaveValue('已修改');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('honors initial and changing system, explicit and ancestor reduced motion', async () => {
|
|
187
|
+
motion.matches = true;
|
|
188
|
+
const { container, rerender } = render(<div data-rhine-motion="system"><NeutronStarBackdrop reducedMotion={false} /></div>); await ready();
|
|
189
|
+
expect(frames.size).toBe(0); expect(screen.getByRole('button', { name: '已减少动态' })).toBeDisabled();
|
|
190
|
+
act(() => motion.set(false)); expect(frames.size).toBe(1);
|
|
191
|
+
rerender(<div data-rhine-motion="system"><NeutronStarBackdrop reducedMotion /></div>); expect(frames.size).toBe(0);
|
|
192
|
+
rerender(<div data-rhine-motion="system"><NeutronStarBackdrop reducedMotion={false} /></div>); expect(frames.size).toBe(1);
|
|
193
|
+
await act(async () => { document.documentElement.setAttribute('data-rhine-motion', 'reduced'); }); expect(frames.size).toBe(0);
|
|
194
|
+
await act(async () => { document.documentElement.removeAttribute('data-rhine-motion'); }); expect(frames.size).toBe(1);
|
|
195
|
+
advance(100); advance(200); const frozen = gpu.instances[0].time;
|
|
196
|
+
motion.matches = true; advance(300);
|
|
197
|
+
expect(container.querySelector('.rhine-neutron')).toHaveAttribute('data-rhine-neutron-state', 'reduced');
|
|
198
|
+
expect(frames.size).toBe(0); expect(gpu.instances[0].time).toBe(frozen);
|
|
199
|
+
act(() => motion.set(false)); advance(9000); expect(gpu.instances[0].time).toBe(frozen);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('stops when hidden, offscreen or zero sized, including delayed callbacks after disposal', () => {
|
|
203
|
+
const view = mount(); const instance = gpu.instances[0];
|
|
204
|
+
hidden = true; document.dispatchEvent(new Event('visibilitychange')); expect(frames.size).toBe(0); expect(view.state).toHaveBeenLastCalledWith('hidden');
|
|
205
|
+
hidden = false; document.dispatchEvent(new Event('visibilitychange')); expect(frames.size).toBe(1);
|
|
206
|
+
intersect(false); expect(frames.size).toBe(0); expect(view.state).toHaveBeenLastCalledWith('offscreen');
|
|
207
|
+
intersect(true); expect(frames.size).toBe(1);
|
|
208
|
+
bounds.width = 0; resize(); expect(frames.size).toBe(0); expect(view.state).toHaveBeenLastCalledWith('empty');
|
|
209
|
+
bounds.width = 400; resize(); expect(frames.size).toBe(1);
|
|
210
|
+
view.remove(); const finalDraws = instance.draws; resize(); intersect(true); advance(10000);
|
|
211
|
+
expect(instance.draws).toBe(finalDraws); expect(frames.size).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('updates theme and original uniforms in place, preserving frozen time and pause', async () => {
|
|
215
|
+
const view = mount(); const instance = gpu.instances[0]; advance(100); advance(200); view.scene.setPaused(true);
|
|
216
|
+
let core!: Mesh<BufferGeometry, ShaderMaterial>;
|
|
217
|
+
instance.scene!.traverse(object => { if ((object as Mesh).material instanceof ShaderMaterial && ((object as Mesh).material as ShaderMaterial).uniforms.cool) core ??= object as Mesh<BufferGeometry, ShaderMaterial>; });
|
|
218
|
+
const time = instance.time, initial = core.material.uniforms.cool.value.clone();
|
|
219
|
+
view.scene.setAppearance('original'); expect(instance.time).toBe(time); expect(frames.size).toBe(0);
|
|
220
|
+
expect(core.material.uniforms.cool.value.toArray()).toEqual([.12, .38, .8]);
|
|
221
|
+
view.scene.setAppearance('theme'); expect(core.material.uniforms.cool.value).toEqual(initial);
|
|
222
|
+
const actualComputed = window.getComputedStyle.bind(window);
|
|
223
|
+
vi.spyOn(window, 'getComputedStyle').mockImplementation(element => {
|
|
224
|
+
const css = actualComputed(element); const get = css.getPropertyValue.bind(css);
|
|
225
|
+
css.getPropertyValue = token => token === '--rhine-green' ? '#ff8811' : get(token); return css;
|
|
226
|
+
});
|
|
227
|
+
await act(async () => { document.documentElement.setAttribute('data-rhine-theme', 'neo-brutalism'); });
|
|
228
|
+
expect(core.material.uniforms.cool.value).not.toEqual(initial); expect(instance.time).toBe(time);
|
|
229
|
+
expect(gpu.instances).toHaveLength(1); expect(view.state).toHaveBeenLastCalledWith('paused');
|
|
230
|
+
view.remove();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('uses the static fallback on context loss and restores the same frozen scene', () => {
|
|
234
|
+
const view = mount(); const instance = gpu.instances[0]; advance(100); advance(200); const frozen = instance.time;
|
|
235
|
+
const lost = new Event('webglcontextlost', { cancelable: true }); instance.domElement.dispatchEvent(lost);
|
|
236
|
+
expect(lost.defaultPrevented).toBe(true); expect(frames.size).toBe(0); expect(view.state).toHaveBeenLastCalledWith('unavailable');
|
|
237
|
+
expect(view.host).not.toHaveAttribute('data-rhine-neutron-rendered');
|
|
238
|
+
instance.domElement.dispatchEvent(new Event('webglcontextrestored'));
|
|
239
|
+
expect(view.state).toHaveBeenLastCalledWith('playing'); expect(view.host).toHaveAttribute('data-rhine-neutron-rendered', 'true');
|
|
240
|
+
advance(9000); expect(instance.time).toBe(frozen); advance(9100); expect(instance.time).toBeGreaterThan(frozen);
|
|
241
|
+
expect(gpu.instances).toHaveLength(1); view.remove();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('disposes every unique geometry, shared material, renderer, observer and listener exactly once', () => {
|
|
245
|
+
const removeDocument = vi.spyOn(document, 'removeEventListener'), removeWindow = vi.spyOn(window, 'removeEventListener');
|
|
246
|
+
const removeMedia = vi.spyOn(motion, 'removeEventListener');
|
|
247
|
+
const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
|
|
248
|
+
const view = mount(); const instance = gpu.instances[0]; const removeCanvas = vi.spyOn(instance.domElement, 'removeEventListener');
|
|
249
|
+
view.remove(); view.scene.dispose();
|
|
250
|
+
expect(disposedGeometry).toHaveLength(37); expect(new Set(disposedGeometry).size).toBe(37);
|
|
251
|
+
expect(disposedMaterial).toHaveLength(34); expect(new Set(disposedMaterial).size).toBe(34);
|
|
252
|
+
expect(instance.dispose).toHaveBeenCalledTimes(1); expect(instance.forceContextLoss).toHaveBeenCalledTimes(1);
|
|
253
|
+
expect(resizeDisconnect).toHaveBeenCalledTimes(1); expect(intersectionDisconnect).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(mutationDisconnect).toHaveBeenCalledOnce();
|
|
255
|
+
expect(removeDocument).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
|
256
|
+
expect(removeWindow).toHaveBeenCalledWith('resize', expect.any(Function)); expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function));
|
|
257
|
+
expect(removeCanvas).toHaveBeenCalledWith('webglcontextlost', expect.any(Function));
|
|
258
|
+
expect(removeCanvas).toHaveBeenCalledWith('webglcontextrestored', expect.any(Function));
|
|
259
|
+
expect(instance.domElement.isConnected).toBe(false); expect(frames.size).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('cleans partial initialization allocations and falls back after a renderer error', () => {
|
|
263
|
+
gpu.materialFailsAt = 3; const partial = mount();
|
|
264
|
+
expect(partial.state).toHaveBeenLastCalledWith('unavailable'); expect(disposedGeometry).toHaveLength(3); expect(disposedMaterial).toHaveLength(2);
|
|
265
|
+
expect(gpu.instances[0].dispose).toHaveBeenCalledOnce(); expect(partial.host.querySelector('canvas')).toBeNull(); partial.remove();
|
|
266
|
+
gpu.materialFailsAt = 0; const view = mount(); gpu.renderFails = true; advance(100); advance(200);
|
|
267
|
+
expect(view.state).toHaveBeenLastCalledWith('unavailable'); expect(view.host.querySelector('canvas')).toBeNull(); expect(frames.size).toBe(0);
|
|
268
|
+
expect(resizeDisconnect).toHaveBeenCalledOnce(); view.remove();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('keeps content usable when WebGL is unsupported or context construction fails', async () => {
|
|
272
|
+
vi.stubGlobal('WebGL2RenderingContext', undefined);
|
|
273
|
+
const first = render(<NeutronStarBackdrop><button>继续工作</button></NeutronStarBackdrop>);
|
|
274
|
+
expect(first.container.firstChild).toHaveAttribute('data-rhine-neutron-state', 'unavailable'); expect(screen.getByRole('button', { name: '继续工作' })).toBeEnabled();
|
|
275
|
+
expect(gpu.instances).toHaveLength(0); first.unmount();
|
|
276
|
+
vi.stubGlobal('WebGL2RenderingContext', function () {}); gpu.constructorFails = true;
|
|
277
|
+
const second = render(<NeutronStarBackdrop><button>继续工作</button></NeutronStarBackdrop>);
|
|
278
|
+
await waitFor(() => expect(second.container.firstChild).toHaveAttribute('data-rhine-neutron-state', 'unavailable'));
|
|
279
|
+
expect(screen.getByRole('button', { name: '静态画面' })).toBeDisabled(); expect(frames.size).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('does not mount a late import after unmount, including Strict Mode effect replay', async () => {
|
|
283
|
+
const first = render(<NeutronStarBackdrop />); first.unmount();
|
|
284
|
+
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); expect(gpu.instances).toHaveLength(0);
|
|
285
|
+
const second = render(<StrictMode><NeutronStarBackdrop /></StrictMode>); await ready();
|
|
286
|
+
expect(frames.size).toBe(1); second.unmount(); expect(frames.size).toBe(0); expect(gpu.instances[0].dispose).toHaveBeenCalledOnce();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { NeutronStarGallery } from '../preview/NeutronStarGallery';
|
|
5
|
+
|
|
6
|
+
vi.mock('../components', async importOriginal => {
|
|
7
|
+
const actual = await importOriginal<typeof import('../components')>();
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
NeutronStarBackdrop: ({ appearance = 'theme', reducedMotion, ...props }: HTMLAttributes<HTMLDivElement> & { appearance?: 'theme' | 'original'; reducedMotion?: boolean }) =>
|
|
11
|
+
<div {...props} data-appearance={appearance} data-reduced={String(Boolean(reducedMotion))} />,
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('NeutronStarGallery composition', () => {
|
|
16
|
+
it('presents unobstructed artwork before collapsed controls and content', () => {
|
|
17
|
+
const { container } = render(<NeutronStarGallery />);
|
|
18
|
+
const artwork = screen.getByRole('group', { name: '中子星艺术' });
|
|
19
|
+
expect(artwork).toBeEmptyDOMElement();
|
|
20
|
+
expect(artwork).toHaveAttribute('data-appearance', 'theme');
|
|
21
|
+
expect(artwork).toHaveAttribute('data-neutron-example', 'artwork');
|
|
22
|
+
expect([...container.querySelectorAll('details')].map(item => item.open)).toEqual([false, false]);
|
|
23
|
+
expect(artwork.compareDocumentPosition(container.querySelector('details')!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('changes appearance and reduction without replacing the artwork or entered content', () => {
|
|
27
|
+
render(<NeutronStarGallery />);
|
|
28
|
+
fireEvent.click(screen.getByText('演示控制'));
|
|
29
|
+
fireEvent.click(screen.getByText('查看内容组合'));
|
|
30
|
+
const artwork = screen.getByRole('group', { name: '中子星艺术' });
|
|
31
|
+
const content = screen.getByRole('group', { name: '中子星内容组合' });
|
|
32
|
+
const input = screen.getByRole('textbox', { name: '观测名称' });
|
|
33
|
+
fireEvent.change(input, { target: { value: '保留这份草稿' } });
|
|
34
|
+
fireEvent.change(screen.getByRole('combobox', { name: '艺术配色' }), { target: { value: 'original' } });
|
|
35
|
+
fireEvent.click(screen.getByRole('switch', { name: '减少中子星动态' }));
|
|
36
|
+
expect(screen.getByRole('group', { name: '中子星艺术' })).toBe(artwork);
|
|
37
|
+
expect(screen.getByRole('group', { name: '中子星内容组合' })).toBe(content);
|
|
38
|
+
for (const example of [artwork, content]) {
|
|
39
|
+
expect(example).toHaveAttribute('data-appearance', 'original');
|
|
40
|
+
expect(example).toHaveAttribute('data-reduced', 'true');
|
|
41
|
+
}
|
|
42
|
+
expect(input).toHaveValue('保留这份草稿');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('replays only the main artwork while keeping the composition draft and replay focus', () => {
|
|
46
|
+
render(<NeutronStarGallery />);
|
|
47
|
+
fireEvent.click(screen.getByText('演示控制'));
|
|
48
|
+
fireEvent.click(screen.getByText('查看内容组合'));
|
|
49
|
+
const artwork = screen.getByRole('group', { name: '中子星艺术' });
|
|
50
|
+
const content = screen.getByRole('group', { name: '中子星内容组合' });
|
|
51
|
+
const input = screen.getByRole('textbox', { name: '观测名称' });
|
|
52
|
+
fireEvent.change(input, { target: { value: '不重建这份输入' } });
|
|
53
|
+
const replay = screen.getByRole('button', { name: '重播中子星' });
|
|
54
|
+
replay.focus();
|
|
55
|
+
fireEvent.click(replay);
|
|
56
|
+
expect(screen.getByRole('group', { name: '中子星艺术' })).not.toBe(artwork);
|
|
57
|
+
expect(screen.getByRole('group', { name: '中子星内容组合' })).toBe(content);
|
|
58
|
+
expect(screen.getByRole('textbox', { name: '观测名称' })).toBe(input);
|
|
59
|
+
expect(input).toHaveValue('不重建这份输入');
|
|
60
|
+
expect(replay).toHaveFocus();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { NeutronStarBackdrop } from '../components/NeutronStarBackdrop';
|
|
4
|
+
|
|
5
|
+
vi.mock('../components/neutronRenderer', () => { throw new Error('Lazy chunk unavailable'); });
|
|
6
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
7
|
+
|
|
8
|
+
it('preserves the static artwork and interactive content if the lazy renderer import fails', async () => {
|
|
9
|
+
vi.stubGlobal('WebGL2RenderingContext', function () {});
|
|
10
|
+
const { container } = render(<NeutronStarBackdrop><button>继续工作</button></NeutronStarBackdrop>);
|
|
11
|
+
await waitFor(() => expect(container.firstChild).toHaveAttribute('data-rhine-neutron-state', 'unavailable'));
|
|
12
|
+
expect(screen.getByRole('button', { name: '继续工作' })).toBeEnabled();
|
|
13
|
+
expect(screen.getByRole('button', { name: '静态画面' })).toBeDisabled();
|
|
14
|
+
expect(container.querySelector('.rhine-neutron-fallback')).not.toBeNull();
|
|
15
|
+
});
|