The adversarial review confirmed three defects in this PR, all reproduced in the browser. 1. Superseded graphs queued up in front of routes. The worker answers messages one at a time and cannot drop one it has started. With the jump-link layer on, every pause on the range slider posted a full graph build, seconds of work at 6-8 pc. Answers no longer wanted were thrown away only once built. A route asked for afterwards waited behind every one of them: a one-jump route took 44 s. RoutingClient now holds requests and sends them one at a time. While one is out, only the latest of each kind waits: a newer graph replaces an older one before it is ever built, and the older promise is rejected with SupersededRequest. Routes go ahead of graphs. The same question asked again while outstanding shares the answer rather than being worked twice, as when the layer is turned off and on during a build. The same scenario in the browser (layer on, range stepped 5 -> 8 pc with 400 ms pauses, then Sol to Proxima): the route came back in 110 ms. The worker was sent "links 3, links 5, route, links 8"; 6 and 7 were never built. 2. A worker that failed left the panel stuck. With no error handling, a worker that failed to load (a 404 on its chunk after a redeploy) or threw left "Plotting…" and a disabled button for good, and a graph at a range could not be asked for again. The worker now answers an exception with a 'failed' message, which rejects that request. A worker that fails to load or dies is abandoned, and what it left outstanding, and everything asked afterwards, is answered in place. The scene releases the panel when a route fails, and forgets a graph range that was never drawn so it can be asked for again. 3. Nothing type-checked the worker. The application builder never reads webWorkerTsConfig, and bundles the worker with esbuild, which strips types without checking them. tsconfig.app.json leaves the file out. A type error in the worker shipped. `npm run worker:typecheck` (tsc -p tsconfig.worker.json) now runs in CI beside the other project checks. webWorkerTsConfig is removed from angular.json, since it only suggested that something checked the worker. Tests with a fake worker cover one request at a time, a waiting graph replaced and a route sent ahead of it, a question shared, a failure rejected and the next request sent, and a failed worker's requests answered in place. A scene test covers the panel released after a failed route. Negative controls, each caught: several requests sent at once, a waiting graph kept, graphs ahead of routes, a question asked twice, a failure answered as a success, a failed worker waited on, the panel left pending, and a type error in the worker (caught by worker:typecheck). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
472 lines
19 KiB
TypeScript
472 lines
19 KiB
TypeScript
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { Router } from '@angular/router';
|
|
import * as THREE from 'three/webgpu';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
|
import { EngineService, EngineTickCallback } from '../../core/engine/engine.service';
|
|
import { BodyRecord } from '../../shared/models/body.model';
|
|
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
|
import { StarRecord } from '../../shared/models/star.model';
|
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
|
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
|
import { StarFieldRenderer } from './star-field-renderer';
|
|
|
|
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
|
// layout changes, which never happen in this headless test.
|
|
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
|
|
observe(): void {}
|
|
unobserve(): void {}
|
|
disconnect(): void {}
|
|
};
|
|
|
|
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
|
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
|
|
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
|
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
|
|
|
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
|
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
|
|
|
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
|
id: 'NGC0224',
|
|
name: 'Andromeda Galaxy',
|
|
kind: 'galaxy',
|
|
x: 0,
|
|
y: 0,
|
|
z: 1,
|
|
angularSizeDeg: 2.96,
|
|
magnitude: 3.44,
|
|
distancePc: null,
|
|
distanceMethod: null,
|
|
constellation: 'And',
|
|
messier: 'M31'
|
|
};
|
|
|
|
const EARTH: BodyRecord = {
|
|
id: 'earth',
|
|
systemStarId: SUN.id,
|
|
name: 'Earth',
|
|
kind: 'planet',
|
|
radiusKm: 6371,
|
|
orbit: {
|
|
semiMajorAxisAu: 1,
|
|
eccentricity: 0.0167,
|
|
inclinationDeg: 0,
|
|
longitudeOfAscendingNodeDeg: 0,
|
|
argumentOfPeriapsisDeg: 0,
|
|
meanAnomalyAtEpochDeg: 0,
|
|
epochJd: 2451545.0
|
|
}
|
|
};
|
|
|
|
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
|
|
* while exposing the same tick-registration hook so tests can drive the render loop by hand. */
|
|
class FakeEngineService {
|
|
private readonly scene = new THREE.Scene();
|
|
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
|
private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
|
private readonly tickCallbacks = new Set<EngineTickCallback>();
|
|
projection: 'perspective' | 'orthographic' = 'perspective';
|
|
|
|
get isInitialized(): boolean {
|
|
return true;
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
// no-op: no real renderer/context is created in tests.
|
|
}
|
|
|
|
getScene(): THREE.Scene {
|
|
return this.scene;
|
|
}
|
|
|
|
getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
|
|
return this.projection === 'orthographic' ? this.orthographic : this.camera;
|
|
}
|
|
|
|
getPerspectiveCamera(): THREE.PerspectiveCamera {
|
|
return this.camera;
|
|
}
|
|
|
|
get currentProjection(): 'perspective' | 'orthographic' {
|
|
return this.projection;
|
|
}
|
|
|
|
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
|
|
this.projection = projection;
|
|
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
|
|
this.orthographic.top = halfHeight;
|
|
this.orthographic.bottom = -halfHeight;
|
|
this.orthographic.left = -halfHeight * this.camera.aspect;
|
|
this.orthographic.right = halfHeight * this.camera.aspect;
|
|
this.orthographic.zoom = 1;
|
|
this.orthographic.position.copy(this.camera.position);
|
|
this.orthographic.quaternion.copy(this.camera.quaternion);
|
|
this.orthographic.updateProjectionMatrix();
|
|
}
|
|
|
|
visibleHalfHeight(distanceToTarget: number): number {
|
|
return this.projection === 'orthographic'
|
|
? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom)
|
|
: distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360);
|
|
}
|
|
|
|
onTick(callback: EngineTickCallback): () => void {
|
|
this.tickCallbacks.add(callback);
|
|
return () => this.tickCallbacks.delete(callback);
|
|
}
|
|
|
|
start(): void {}
|
|
|
|
stop(): void {}
|
|
|
|
dispose(): void {}
|
|
|
|
resize(): void {}
|
|
|
|
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
|
|
tick(deltaSeconds: number): void {
|
|
for (const callback of this.tickCallbacks) {
|
|
callback(deltaSeconds, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeDataLoaderService {
|
|
loadStars(): Promise<StarField> {
|
|
return Promise.resolve({ stars: STARS, positions: STAR_POSITIONS });
|
|
}
|
|
|
|
loadBodies(): Promise<BodyRecord[]> {
|
|
return Promise.resolve([EARTH]);
|
|
}
|
|
|
|
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
|
return Promise.resolve([DEEP_SKY_OBJECT]);
|
|
}
|
|
}
|
|
|
|
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
|
|
async function flushAsync(turns = 8): Promise<void> {
|
|
for (let i = 0; i < turns; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
/** Advances the fake render loop (and therefore any in-flight `CameraRigController` tween)
|
|
* by repeatedly ticking a small fixed step, flushing microtasks between frames so any
|
|
* `onComplete` callback's own side effects (e.g. starting the next leg of the flight) run. */
|
|
async function advanceFrames(engine: FakeEngineService, totalSeconds: number, stepSeconds = 0.05): Promise<void> {
|
|
let elapsed = 0;
|
|
while (elapsed < totalSeconds) {
|
|
engine.tick(stepSeconds);
|
|
elapsed += stepSeconds;
|
|
await flushAsync(1);
|
|
}
|
|
}
|
|
|
|
describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|
let fixture: ComponentFixture<GalaxySystemSceneComponent>;
|
|
let engine: FakeEngineService;
|
|
let navigationStore: NavigationStore;
|
|
|
|
beforeEach(async () => {
|
|
engine = new FakeEngineService();
|
|
|
|
TestBed.configureTestingModule({
|
|
imports: [GalaxySystemSceneComponent],
|
|
providers: [
|
|
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
|
|
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
|
|
]
|
|
}).overrideComponent(GalaxySystemSceneComponent, {
|
|
set: { providers: [{ provide: EngineService, useValue: engine }] }
|
|
});
|
|
|
|
navigationStore = TestBed.inject(NavigationStore);
|
|
fixture = TestBed.createComponent(GalaxySystemSceneComponent);
|
|
fixture.detectChanges(); // triggers ngAfterViewInit -> bootstrap()
|
|
await flushAsync();
|
|
});
|
|
|
|
it('starts in the galaxy view with the system group hidden', () => {
|
|
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
|
expect(component.galaxyGroup.visible).toBe(true);
|
|
expect(component.systemGroup.visible).toBe(false);
|
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
|
});
|
|
|
|
it('clears a selection the catalogue no longer holds instead of chasing it', async () => {
|
|
// A bookmark saved against a Gaia row id that the next refresh renumbered. Before the guard,
|
|
// entering the missing system completed at once, completion re-read the same id, and the
|
|
// two recursed until the stack overflowed.
|
|
navigationStore.selectStar(987654321);
|
|
await flushAsync();
|
|
|
|
expect(navigationStore.selectedStarId()).toBeNull();
|
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
|
});
|
|
|
|
it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => {
|
|
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
|
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
|
|
|
component.controls.target.set(40, 0, 0);
|
|
await advanceFrames(engine, 0.3);
|
|
expect(refocus).toHaveBeenCalledTimes(1);
|
|
expect(refocus.mock.calls[0][0].centre).toMatchObject({ x: 40, y: 0, z: 0 });
|
|
|
|
component.controls.target.set(42, 0, 0);
|
|
await advanceFrames(engine, 0.3);
|
|
expect(refocus).toHaveBeenCalledTimes(1);
|
|
refocus.mockRestore();
|
|
});
|
|
|
|
it('does not choose the drawn stars again at load, where the renderer has just chosen them', async () => {
|
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
|
|
|
await advanceFrames(engine, 0.6);
|
|
|
|
expect(refocus).not.toHaveBeenCalled();
|
|
refocus.mockRestore();
|
|
});
|
|
|
|
it('leaves the drawn stars alone at galactic scale, however far the view centre sweeps', async () => {
|
|
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
|
|
const camera = engine.getCamera();
|
|
camera.position.set(0, 0, 30000);
|
|
await advanceFrames(engine, 0.3);
|
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
|
|
|
component.controls.target.set(500, 0, 0);
|
|
await advanceFrames(engine, 0.3);
|
|
component.controls.target.set(1500, 0, 0);
|
|
await advanceFrames(engine, 0.3);
|
|
|
|
expect(refocus).not.toHaveBeenCalled();
|
|
refocus.mockRestore();
|
|
});
|
|
|
|
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
|
|
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
|
await advanceFrames(engine, 0.3);
|
|
|
|
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
|
|
await advanceFrames(engine, 0.3);
|
|
|
|
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
|
expect(refocus.mock.calls.at(-1)![0].pinned).toEqual([0, 2]);
|
|
refocus.mockRestore();
|
|
});
|
|
|
|
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
|
|
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null };
|
|
const answers: Array<(answer: Answer) => void> = [];
|
|
const component = fixture.componentInstance as unknown as {
|
|
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
|
routePending(): boolean;
|
|
routeResult(): { stars: { id: number }[] } | null;
|
|
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
|
};
|
|
component.routing = {
|
|
route: () => new Promise<Answer>((resolve) => answers.push(resolve)),
|
|
links: () => Promise.resolve(new Float32Array(0)),
|
|
dispose: () => undefined
|
|
};
|
|
|
|
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 2 });
|
|
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
|
expect(component.routePending()).toBe(true);
|
|
|
|
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null });
|
|
await flushAsync();
|
|
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null });
|
|
await flushAsync();
|
|
|
|
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
|
|
expect(component.routePending()).toBe(false);
|
|
});
|
|
|
|
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
|
|
const component = fixture.componentInstance as unknown as {
|
|
routing: { route(): Promise<never>; links(): Promise<Float32Array>; dispose(): void };
|
|
routePending(): boolean;
|
|
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
|
};
|
|
const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
component.routing = { route: () => Promise.reject(new Error('worker gone')), links: () => Promise.resolve(new Float32Array(0)), dispose: () => undefined };
|
|
|
|
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
|
await flushAsync();
|
|
|
|
expect(component.routePending()).toBe(false);
|
|
expect(logged).toHaveBeenCalled();
|
|
logged.mockRestore();
|
|
});
|
|
|
|
it('asks for no more label candidates once the last label it will show is placed', () => {
|
|
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
|
|
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
|
const component = fixture.componentInstance as unknown as {
|
|
spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[];
|
|
};
|
|
const camera = engine.getCamera();
|
|
camera.updateMatrixWorld(true);
|
|
camera.updateProjectionMatrix();
|
|
let pulled = 0;
|
|
const grid = function* () {
|
|
for (let row = 0; row < 5; row++) {
|
|
for (let column = 0; column < 5; column++) {
|
|
pulled++;
|
|
const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera);
|
|
yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z };
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15);
|
|
expect(pulled).toBe(15);
|
|
});
|
|
|
|
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
|
|
navigationStore.selectStar(SUN.id);
|
|
await flushAsync();
|
|
|
|
// Approach leg (parsec space) + settle leg (AU space) with margin.
|
|
await advanceFrames(engine, 2.5);
|
|
|
|
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
|
expect(component.galaxyGroup.visible).toBe(false);
|
|
expect(component.systemGroup.visible).toBe(true);
|
|
expect(engine.getCamera().near).toBeCloseTo(0.002, 9);
|
|
expect(navigationStore.viewLevel()).toBe('system');
|
|
});
|
|
|
|
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
|
|
navigationStore.selectStar(ALPHA_CENTAURI.id);
|
|
await flushAsync();
|
|
await advanceFrames(engine, 2.5);
|
|
|
|
// Regardless of how far away (in parsecs) the star was, once we're in system space the
|
|
// camera must be within a few thousand AU of the origin -- never still out at the star's
|
|
// original parsec-scale distance from the Sun.
|
|
const distanceFromOrigin = engine.getCamera().position.length();
|
|
expect(distanceFromOrigin).toBeLessThan(1000);
|
|
expect(distanceFromOrigin).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('flies back out to the galaxy overview and restores parsec-scale near/far planes when the selection is cleared', async () => {
|
|
navigationStore.selectStar(SUN.id);
|
|
await flushAsync();
|
|
await advanceFrames(engine, 2.5);
|
|
expect(navigationStore.viewLevel()).toBe('system');
|
|
|
|
navigationStore.selectStar(null);
|
|
await flushAsync();
|
|
await advanceFrames(engine, 2.5);
|
|
|
|
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
|
expect(component.galaxyGroup.visible).toBe(true);
|
|
expect(component.systemGroup.visible).toBe(false);
|
|
// Parsec-scale rather than an exact figure: in galaxy space the depth range scales with how
|
|
// far the camera has pulled back, so what identifies it is the far plane it settles on
|
|
// (5000 pc) versus the AU-space one (20000 AU), not a fixed near plane.
|
|
expect(engine.getCamera().far).toBeCloseTo(5000, 6);
|
|
// The near plane tracks how far back the camera is rather than sitting at a constant, so
|
|
// what identifies galaxy space is that it is a small fraction of that far plane.
|
|
expect(engine.getCamera().near).toBeLessThan(engine.getCamera().far / 1000);
|
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
|
});
|
|
|
|
it('hopping directly from one system to another exits the first system before entering the second, without settling back in the galaxy view', async () => {
|
|
navigationStore.selectStar(SUN.id);
|
|
await flushAsync();
|
|
await advanceFrames(engine, 2.5);
|
|
expect(navigationStore.viewLevel()).toBe('system');
|
|
|
|
navigationStore.selectStar(ALPHA_CENTAURI.id);
|
|
await flushAsync();
|
|
await advanceFrames(engine, 3.5);
|
|
|
|
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
|
|
expect(navigationStore.viewLevel()).toBe('system');
|
|
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
|
|
});
|
|
|
|
it('reports the galactic scale once the camera has pulled back far enough, and comes back', async () => {
|
|
const camera = engine.getCamera();
|
|
|
|
camera.position.set(0, 0, 30000);
|
|
await advanceFrames(engine, 0.3);
|
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
|
|
|
camera.position.set(0, 15, 30);
|
|
await advanceFrames(engine, 0.3);
|
|
expect(navigationStore.viewLevel()).toBe('galaxy');
|
|
});
|
|
|
|
it('widens the depth range as the camera pulls back, instead of holding one range for both scales', async () => {
|
|
const camera = engine.getCamera();
|
|
|
|
await advanceFrames(engine, 0.3);
|
|
const localFar = camera.far;
|
|
|
|
camera.position.set(0, 0, 30000);
|
|
await advanceFrames(engine, 0.3);
|
|
|
|
expect(camera.far).toBeGreaterThan(localFar);
|
|
// A near plane a hundredth of a parsec out has no precision left to spare at this range.
|
|
expect(camera.near).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('flies out to the Galaxy when the scale ladder asks for it', async () => {
|
|
const camera = engine.getCamera();
|
|
fixture.componentInstance.goToLevel('galactic');
|
|
await advanceFrames(engine, 3);
|
|
|
|
expect(camera.position.length()).toBeGreaterThan(10000);
|
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
|
});
|
|
|
|
it('leaves the system first when the scale ladder is used from inside one', async () => {
|
|
navigationStore.selectStar(SUN.id);
|
|
await flushAsync();
|
|
await advanceFrames(engine, 2.5);
|
|
expect(navigationStore.viewLevel()).toBe('system');
|
|
|
|
fixture.componentInstance.goToLevel('galactic');
|
|
await flushAsync();
|
|
// Exit leg, then the return leg, then the galactic flight: the request has to wait out the
|
|
// unit-space unwind rather than firing a parsec-scale flight while the scene is in AU.
|
|
await advanceFrames(engine, 6);
|
|
|
|
const component = fixture.componentInstance as unknown as { currentStarId: number | null; systemGroup: THREE.Group };
|
|
expect(component.currentStarId).toBeNull();
|
|
expect(component.systemGroup.visible).toBe(false);
|
|
expect(navigationStore.viewLevel()).toBe('galactic');
|
|
});
|
|
|
|
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
|
navigationStore.selectStar(SUN.id);
|
|
await flushAsync();
|
|
|
|
// Fire a second selection mid-flight, before the first transition has settled.
|
|
await advanceFrames(engine, 0.3);
|
|
navigationStore.selectStar(PROXIMA.id);
|
|
await flushAsync();
|
|
|
|
await advanceFrames(engine, 6);
|
|
|
|
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
|
|
expect(component.currentStarId).toBe(PROXIMA.id);
|
|
expect(navigationStore.viewLevel()).toBe('system');
|
|
});
|
|
});
|