diff --git a/src/services/engine/engine.ts b/src/services/engine/engine.ts index 0309790..19b922d 100644 --- a/src/services/engine/engine.ts +++ b/src/services/engine/engine.ts @@ -106,14 +106,13 @@ import { cloudSourceFor } from '../../data/cloudSource'; import { createLoadProgressEmitter } from './loadProgressAggregator'; import type { AssetSlot } from '../loading/types'; import { createAssetSlot } from '../loading/AssetSlot'; -import { pointCloudFetcher } from '../loading/fetchers/pointCloudFetcher'; -import { syntheticPointFetcher } from '../loading/fetchers/syntheticPointFetcher'; import { filamentFetcher } from '../loading/fetchers/filamentFetcher'; import { famousMetaFetcher } from '../loading/fetchers/famousMetaFetcher'; import { pgcAliasFetcher, type PgcAliasMap } from '../loading/fetchers/pgcAliasFetcher'; import { TIER_TARGETS } from '../../data/tierTargets'; import { FOCUS_TWEEN_MS } from './focusTween'; import { tweenToGalaxy } from './tweenToGalaxy'; +import { POINT_SOURCE_REGISTRY, wirePointSourceSlot } from './pointSourceRegistry'; // ── Galaxy thumbnail subsystem ──────────────────────────────────────────── // @@ -162,33 +161,6 @@ import { buildSettersFromTable, type SettingsTableKey } from './settingsTable'; * * @throws Never — errors are reported via `onStatusChange({ kind: 'error' })`. */ -/** - * Lowercase short name for a Source — used as the stable prefix for - * asset-slot identifiers (e.g. `sdss-points`, `glade-points`). We keep - * a small dedicated helper rather than reusing `sourceLabel` because - * the latter returns the user-facing display string ('GLADE', - * 'Famous'), while slot names live in logs / progress keys / dev - * tooling and benefit from being lowercase + ASCII-clean. - * - * Defined at module scope so the per-source slot-construction loop - * inside `createEngine` can use it without re-declaring on every - * engine boot. - */ -function sourceName(source: Source): string { - switch (source) { - case Source.SDSS: - return 'sdss'; - case Source.TwoMRS: - return '2mrs'; - case Source.Glade: - return 'glade'; - case Source.Famous: - return 'famous'; - case Source.Synthetic: - return 'synthetic'; - } -} - export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): EngineHandle { // ── Mutable engine state ───────────────────────────────────────────────── // @@ -707,63 +679,18 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En // Naming: `-points` for survey clouds, `filaments` for // filaments. The progress aggregator keys on these strings, so // they double as the load-progress identifier. - for (const source of [ - Source.SDSS, - Source.TwoMRS, - Source.Glade, - Source.Famous, - Source.Synthetic, - ]) { - const slotName = `${sourceName(source)}-points`; - // Synthetic uses a different fetcher (procedural generator, - // ignores tier). All real surveys share `pointCloudFetcher`. - // The slot's commit body is identical across both — the only - // axis of variation here is "where do the bytes come from". - const fetch = source === Source.Synthetic ? syntheticPointFetcher : pointCloudFetcher; - const slot = createAssetSlot({ - name: slotName, - fetch, - commit: async (cloud) => { - // Renderer might have been destroyed mid-load (StrictMode - // unmount, hot-reload). Drop the upload silently in that - // case; the slot will still transition to `ready`, but no - // GPU buffer exists to consume it. - if (!state.gpu.renderer) return; - const t0 = performance.now(); - // eslint-disable-next-line no-console - console.log( - `[engine] upload start ${sourceName(source)} count=${cloud.count}`, - ); - await state.gpu.renderer.upload(source, cloud); - state.sources.clouds.set(source, cloud); - const dtMs = Math.round(performance.now() - t0); - // After upload, dump what the GPU actually has — the source - // of truth the draw loop reads from. If this disagrees with - // the slot's reported `cloud.count`, the upload landed on the - // renderer but something else (e.g. a parallel rebake or a - // concurrent upload for the same source) overwrote it. - const onGpu = Array.from(state.gpu.renderer.loadedSources()) - .map((e) => `${sourceName(e.source)}=${e.count}`) - .join(', '); - const total = state.gpu.renderer.totalCount(); - // eslint-disable-next-line no-console - console.log( - `[engine] upload done ${sourceName(source)} count=${cloud.count} (${dtMs} ms) | on-GPU: ${onGpu} | total=${total}`, - ); - }, - }); - slot.subscribe((s) => { - // Per-slot byte-count plumbing into the loading-bar aggregator - // is gone post-Task-12 — the new `createLoadProgressEmitter` - // recomputes from `aggregateRegistry(slots)` on every state - // change, so this subscriber only needs to fire the - // app-visible side effects (cb echo + render wake). - if (s.kind === 'ready') { - cb.onCloudReady?.(source, s.value.count); - state.subsystems.scheduler.requestRender(); - } - }); - state.assetSlots.points.set(source, slot); + // + // The 5 point-source slots are now constructed via the + // `POINT_SOURCE_REGISTRY` declarative table — see + // `pointSourceRegistry.ts` for the registry schema, the per-row + // fetcher choice, and the rationale for keeping sidecar slots + // (filaments, famous-meta, pgc-aliases) inline below rather than + // absorbing them into the registry. Each call mints the slot, + // attaches the upload-on-commit body + ready-state subscriber, + // and stores the slot in `state.assetSlots.points` keyed by + // `Source` — exactly what the pre-registry inline loop did. + for (const cfg of POINT_SOURCE_REGISTRY) { + wirePointSourceSlot(state, cfg, { cb }); } // ── Galaxy thumbnail subsystem ───────────────────────────────────── diff --git a/src/services/engine/pointSourceRegistry.ts b/src/services/engine/pointSourceRegistry.ts new file mode 100644 index 0000000..03a8301 --- /dev/null +++ b/src/services/engine/pointSourceRegistry.ts @@ -0,0 +1,290 @@ +/** + * pointSourceRegistry — declarative wiring for the engine's per-source + * point-cloud asset slots. + * + * ### Why a registry? + * + * Pre-Phase-4 the bootstrap IIFE in `engine.ts` had a single ~60-line + * loop that iterated `[Source.SDSS, Source.TwoMRS, Source.Glade, + * Source.Famous, Source.Synthetic]`, branching on + * `source === Source.Synthetic` to pick the fetcher and otherwise + * building each slot identically. That loop was already a dedupe of + * five copy-pasted blocks from earlier in the project's history — a + * mid-Spec-A cleanup — but it still mixed three concerns: + * + * 1. *Per-source variance* (which fetcher, which initial tier, which + * retry policy) — declarative data; + * 2. *Slot construction* (build the AssetSlot, attach the commit + * body, register subscribers) — uniform plumbing; + * 3. *Engine-state side effects* (mutate `state.sources.clouds`, + * fire `cb.onCloudReady`, wake the scheduler) — shared lifecycle. + * + * Pulling (1) into a `POINT_SOURCE_REGISTRY` table and (2)+(3) into a + * `wirePointSourceSlot` helper makes "what differs across sources" + * legible at a glance — anyone adding a new survey edits one row of + * the registry rather than tracing through a multi-arm conditional in + * the middle of a 1100-line bootstrap IIFE. And the engine.ts side + * collapses to: + * + * for (const cfg of POINT_SOURCE_REGISTRY) wirePointSourceSlot(state, cfg, deps); + * + * ### What the previous shape looked like (so the diff is auditable) + * + * The pre-registry loop fused the per-source switch into the slot's + * `fetch` field via a ternary: + * + * const fetch = source === Source.Synthetic ? syntheticPointFetcher + * : pointCloudFetcher; + * const slot = createAssetSlot({ name, fetch, commit: ... }); + * slot.subscribe((s) => { if (s.kind === 'ready') { cb.onCloudReady?.(...); requestRender(); } }); + * state.assetSlots.points.set(source, slot); + * + * The registry rephrases that ternary as data ("each row names its own + * fetcher") and the helper inlines the rest unchanged. Behaviour is + * byte-for-byte identical — the relocation is the win, not a rewrite. + * + * ### Why sidecar slots stay bespoke + * + * The bootstrap also constructs three *sidecar* slots that are NOT in + * this registry: + * + * - `filaments` — different fetcher (`filamentFetcher`), different + * payload shape (`FilamentCloud` not `PointCloud`), different + * renderer target (`FilamentRenderer.upload`), one-shot lifecycle + * (never reloaded on tier change). Forcing it through a + * "PointSourceConfig" would require parameterising the payload + * type, the commit target, AND the lifecycle hook on every row of + * the registry — a generic abstraction whose only consumer is the + * odd-one-out, paid for by the four normal rows. + * - `famous-meta` — pure metadata, no `commit` step, custom error + * handling that maps `kind: 'error'` to "feature off" by writing + * empty meta/xrefs (graceful degradation that the point-source + * subscriber doesn't have). + * - `pgc-aliases` — lazy load triggered by the public handle's + * `loadPgcAliases()` shim, not at boot. Wrong lifecycle for a + * boot-time registry loop. + * + * Each sidecar has materially divergent shape and a single inline + * construction site. Absorbing them into the registry would expand the + * config type to cover their differences and turn `wirePointSourceSlot` + * into a coordinator that branches on slot kind — exactly the smell the + * registry is meant to remove from engine.ts. They stay inline. + * + * ### Why `initialTier` lives on the config but isn't read by the + * helper + * + * The bootstrap separates *slot construction* (this helper) from *first + * load* (a later block in engine.ts that calls + * `state.assetSlots.points.get(source)?.load({ source, tier: state.sources.tier })` + * for each real source). Initial tier therefore comes from + * `state.sources.tier`, seeded at engine init from `opts.initialTier`, + * not from per-source config. + * + * The `initialTier` field nevertheless lives on `PointSourceConfig` for + * two reasons: + * + * 1. The spec (`docs/superpowers/specs/2026-05-08-engine-internal-restructure-design.md#3`) + * lists per-source initial tiers as part of the registry's + * declarative shape — making the future direction (per-source + * tier overrides) discoverable from the type without a re-spec. + * 2. Synthetic ignores tier altogether; documenting that with a + * placeholder value on the row keeps the type uniform across all + * five entries rather than introducing an `initialTier?: Tier` + * partial-shape mismatch. + * + * If a future caller needs per-source initial tiers, the helper grows + * one line; today it's correct to leave `state.sources.tier` as the + * single source of truth. + * + * ### Consumer pattern (in `engine.ts`) + * + * ```ts + * for (const cfg of POINT_SOURCE_REGISTRY) { + * wirePointSourceSlot(state, cfg, { cb }); + * } + * // ... sidecar slots constructed inline below ... + * // ... the post-loop allSlots aggregation runs unchanged ... + * ``` + * + * `state.assetSlots.points.set(source, slot)` happens inside the + * helper, so by the time the loop ends every `state.assetSlots.points` + * lookup the rest of the bootstrap relies on (the + * `allArrivalsPromise`, the synthetic-fallback gate, the post-loop + * `allSlots` registry population) sees the same Map it always did. + */ + +import type { EngineCallbacks, EngineState, PointCloud } from '../../@types'; +import type { Tier } from '../../@types/Tier'; +import { Source } from '../../data/sources'; +import type { Fetcher } from '../loading/types'; +import { createAssetSlot } from '../loading/AssetSlot'; +import { + pointCloudFetcher, + type PointCloudReq, +} from '../loading/fetchers/pointCloudFetcher'; +import { syntheticPointFetcher } from '../loading/fetchers/syntheticPointFetcher'; + +/** + * Lowercase short name for a Source — `sdss`, `2mrs`, `glade`, + * `famous`, `synthetic`. Used as the stable prefix for the slot's + * name (e.g. `sdss-points`, `glade-points`) and inside the upload-log + * line below. + * + * Lives here rather than next to `LABELS` in `data/sources.ts` because + * the only consumers today are this file's slot-name + log-line + * strings. Promote to `data/sources.ts` if a third unrelated caller + * appears. (Phase 4 moved this function out of `engine.ts` — there + * is no longer a duplicate to keep in sync.) + */ +function sourceName(source: Source): string { + switch (source) { + case Source.SDSS: + return 'sdss'; + case Source.TwoMRS: + return '2mrs'; + case Source.Glade: + return 'glade'; + case Source.Famous: + return 'famous'; + case Source.Synthetic: + return 'synthetic'; + } +} + +/** + * One row of the registry. + * + * The fields capture exactly the dimensions that vary across the five + * point-source slots; everything else (slot name shape, commit body, + * subscriber side effects) is uniform and lives in + * `wirePointSourceSlot`. + */ +export type PointSourceConfig = { + /** Which catalog this slot represents. */ + source: Source; + /** + * Fetcher used to materialise the slot's request into a PointCloud. + * The four real surveys share `pointCloudFetcher` (which dispatches + * on `req.source` to pick the right .bin URL); Synthetic uses + * `syntheticPointFetcher` (which procedurally generates a cloud and + * ignores `req.tier`). + */ + fetcher: Fetcher; + /** + * Declarative initial tier for the slot. See the module-header + * "Why initialTier lives on the config but isn't read by the helper" + * note — this field is for forward-uniformity with the spec; the + * actual first-load tier today comes from `state.sources.tier`. + */ + initialTier: Tier; +}; + +/** + * The full registry, in Source enum order so the boot-time arrival + * promise's `ALL_POINT_SOURCES` array (declared in engine.ts) keeps + * iterating in the same order it always did. + * + * Initial tiers per the spec sketch: + * - SDSS / TwoMRS / Famous → 'medium' + * - GLADE → 'small' (large catalog; medium is desktop-only) + * - Synthetic → 'small' (fetcher ignores tier; placeholder) + * + * Today these values are not consumed by `wirePointSourceSlot`; they + * are documentation that travels with the registry. See the module + * header for why. + */ +export const POINT_SOURCE_REGISTRY: readonly PointSourceConfig[] = [ + { source: Source.SDSS, fetcher: pointCloudFetcher, initialTier: 'medium' }, + { source: Source.TwoMRS, fetcher: pointCloudFetcher, initialTier: 'medium' }, + { source: Source.Glade, fetcher: pointCloudFetcher, initialTier: 'small' }, + { source: Source.Famous, fetcher: pointCloudFetcher, initialTier: 'medium' }, + { source: Source.Synthetic, fetcher: syntheticPointFetcher, initialTier: 'small' }, +]; + +/** + * Shared dependencies the helper needs that aren't on `EngineState`. + * + * `cb` is the engine's callback bag — used for the `onCloudReady` echo + * that runs on the slot's `ready` transition. Passing it as one + * named field (rather than threading individual callbacks through) + * keeps the call site at a single line and matches how the rest of + * the engine treats the `EngineCallbacks` value. + */ +export type WirePointSourceDeps = { + cb: EngineCallbacks; +}; + +/** + * Build the asset slot for one point-source survey, attach its commit + * body and ready-state subscriber, and register it in + * `state.assetSlots.points`. + * + * Idempotency / re-wire: not supported. Calling this twice for the + * same source overwrites the previous slot in `state.assetSlots.points` + * but leaves the old slot's subscribers attached (the slot itself has + * no destroy method); the caller is expected to wire each source + * exactly once during bootstrap. This matches the pre-registry loop's + * contract. + * + * Lifecycle ordering: this MUST run AFTER `state.gpu.renderer` is + * assigned — the commit step uploads to it. In engine.ts's bootstrap + * IIFE that ordering is preserved by calling the registry loop after + * `state.gpu.renderer = renderer`. See the bootstrap's "Why construct + * here, after the renderer exists?" note for the why. + */ +export function wirePointSourceSlot( + state: EngineState, + cfg: PointSourceConfig, + deps: WirePointSourceDeps, +): void { + const { source, fetcher } = cfg; + const { cb } = deps; + const slotName = `${sourceName(source)}-points`; + + const slot = createAssetSlot({ + name: slotName, + fetch: fetcher, + commit: async (cloud) => { + // Renderer might have been destroyed mid-load (StrictMode + // unmount, hot-reload). Drop the upload silently in that case; + // the slot will still transition to `ready`, but no GPU buffer + // exists to consume it. Same guard the pre-registry loop had. + if (!state.gpu.renderer) return; + const t0 = performance.now(); + // eslint-disable-next-line no-console + console.log( + `[engine] upload start ${sourceName(source)} count=${cloud.count}`, + ); + await state.gpu.renderer.upload(source, cloud); + state.sources.clouds.set(source, cloud); + const dtMs = Math.round(performance.now() - t0); + // After upload, dump what the GPU actually has — the source of + // truth the draw loop reads from. If this disagrees with the + // slot's reported `cloud.count`, the upload landed on the + // renderer but something else (e.g. a parallel rebake or a + // concurrent upload for the same source) overwrote it. + const onGpu = Array.from(state.gpu.renderer.loadedSources()) + .map((e) => `${sourceName(e.source)}=${e.count}`) + .join(', '); + const total = state.gpu.renderer.totalCount(); + // eslint-disable-next-line no-console + console.log( + `[engine] upload done ${sourceName(source)} count=${cloud.count} (${dtMs} ms) | on-GPU: ${onGpu} | total=${total}`, + ); + }, + }); + + slot.subscribe((s) => { + // Per-slot byte-count plumbing into the loading-bar aggregator is + // gone post-Task-12 — the new `createLoadProgressEmitter` + // recomputes from `aggregateRegistry(slots)` on every state + // change, so this subscriber only needs to fire the app-visible + // side effects (cb echo + render wake) on the `ready` transition. + if (s.kind === 'ready') { + cb.onCloudReady?.(source, s.value.count); + state.subsystems.scheduler.requestRender(); + } + }); + + state.assetSlots.points.set(source, slot); +} diff --git a/tests/services/engine/pointSourceRegistry.test.ts b/tests/services/engine/pointSourceRegistry.test.ts new file mode 100644 index 0000000..0e1300e --- /dev/null +++ b/tests/services/engine/pointSourceRegistry.test.ts @@ -0,0 +1,230 @@ +/** + * pointSourceRegistry — unit tests for the point-source slot wiring helper. + * + * The 5 point-source slots (SDSS, 2MRS, GLADE, Famous, Synthetic) all + * share one slot construction shape: name = `${sourceName}-points`, + * upload-on-commit, requestRender + `onCloudReady` echo on the `ready` + * transition. Pre-Phase-4 the body lived inline as a single 60-line + * loop in `engine.ts`'s bootstrap IIFE. Phase 4 lifts the per-source + * variance into a declarative `POINT_SOURCE_REGISTRY` and reduces the + * loop to one helper call per source. + * + * These tests verify the helper's contract without spinning up the full + * engine: + * - each `wirePointSourceSlot` call mints a slot, subscribes to it, + * and stores it in `state.assetSlots.points` keyed by `Source`; + * - the subscriber fires `cb.onCloudReady(source, count)` and + * `requestRender()` on the `ready` transition, and is silent on + * the loading / committing / error transitions; + * - the commit step routes through the shared + * `commitPointCloudToRenderer` helper (uploads to the renderer, + * mutates `state.sources.clouds`); + * - multiple sources wired in succession produce independent slots + * keyed correctly — no cross-talk between SDSS and GLADE; + * - `POINT_SOURCE_REGISTRY` declares exactly the 5 expected sources + * in the same Source enum order the engine has used since Spec A. + * + * We intentionally do NOT exercise the AssetSlot's full retry-policy or + * race-checking — `AssetSlot.test.ts` and the slot's own suite cover + * that. This suite is about the *plumbing* between the registry, the + * helper, and `state.assetSlots.points`. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { + POINT_SOURCE_REGISTRY, + wirePointSourceSlot, + type PointSourceConfig, + type WirePointSourceDeps, +} from '../../../src/services/engine/pointSourceRegistry'; +import { Source } from '../../../src/data/sources'; +import type { EngineCallbacks, EngineState, PointCloud } from '../../../src/@types'; + +/** + * Minimal-shape fixture for the `EngineState` slices the helper reads + * and writes: `gpu.renderer` (the upload target), `sources.clouds` + * (mutated on commit), `subsystems.scheduler.requestRender` (woken on + * ready), and the `assetSlots.points` Map (where the helper stores the + * minted slot). Casting through `unknown` keeps the test honest — any + * field the helper reaches for outside this set surfaces as a runtime + * undefined. + */ +function makeState(opts: { + rendererUpload: ReturnType; + loadedSources?: Iterable<{ source: Source; count: number }>; +}): EngineState { + const clouds = new Map(); + return { + gpu: { + renderer: { + upload: opts.rendererUpload, + loadedSources: () => opts.loadedSources ?? [], + totalCount: () => 0, + }, + }, + sources: { + clouds, + }, + subsystems: { + scheduler: { requestRender: vi.fn() }, + }, + assetSlots: { + points: new Map(), + }, + } as unknown as EngineState; +} + +/** + * Build a tiny `PointCloud`-shaped fixture. Only `count` is read by + * the subscriber's `onCloudReady` echo and by the upload log line. + */ +function fakeCloud(count: number): PointCloud { + return { count } as unknown as PointCloud; +} + +describe('POINT_SOURCE_REGISTRY', () => { + it('declares exactly the 5 expected sources in Source enum order', () => { + const sources = POINT_SOURCE_REGISTRY.map((c) => c.source); + expect(sources).toEqual([ + Source.SDSS, + Source.TwoMRS, + Source.Glade, + Source.Famous, + Source.Synthetic, + ]); + }); + + it('uses the shared pointCloudFetcher for the four real surveys and the dedicated synthetic fetcher for Synthetic', () => { + // We don't import the fetchers here to avoid coupling to their + // implementation — but we can verify the structural invariant + // "Synthetic's fetcher is not the same reference as the other four". + const real = POINT_SOURCE_REGISTRY.filter((c) => c.source !== Source.Synthetic); + const synthetic = POINT_SOURCE_REGISTRY.find((c) => c.source === Source.Synthetic); + expect(synthetic).toBeDefined(); + const realFetchers = new Set(real.map((c) => c.fetcher)); + expect(realFetchers.size).toBe(1); // all four real surveys share one fetcher + expect(synthetic!.fetcher).not.toBe(real[0]!.fetcher); + }); +}); + +describe('wirePointSourceSlot', () => { + function makeDeps(cb: Partial = {}): WirePointSourceDeps { + return { cb: cb as EngineCallbacks }; + } + + it('builds a slot and stores it in state.assetSlots.points keyed by Source', () => { + const state = makeState({ rendererUpload: vi.fn().mockResolvedValue(undefined) }); + const cfg: PointSourceConfig = POINT_SOURCE_REGISTRY.find( + (c) => c.source === Source.SDSS, + )!; + + wirePointSourceSlot(state, cfg, makeDeps()); + + const slot = state.assetSlots.points.get(Source.SDSS); + expect(slot).toBeDefined(); + expect(slot!.name).toBe('sdss-points'); + expect(slot!.state().kind).toBe('idle'); + }); + + it('produces independent slots for each source — no cross-talk', () => { + const state = makeState({ rendererUpload: vi.fn().mockResolvedValue(undefined) }); + const sdssCfg = POINT_SOURCE_REGISTRY.find((c) => c.source === Source.SDSS)!; + const gladeCfg = POINT_SOURCE_REGISTRY.find((c) => c.source === Source.Glade)!; + + wirePointSourceSlot(state, sdssCfg, makeDeps()); + wirePointSourceSlot(state, gladeCfg, makeDeps()); + + const sdssSlot = state.assetSlots.points.get(Source.SDSS); + const gladeSlot = state.assetSlots.points.get(Source.Glade); + expect(sdssSlot).toBeDefined(); + expect(gladeSlot).toBeDefined(); + expect(sdssSlot).not.toBe(gladeSlot); + expect(sdssSlot!.name).toBe('sdss-points'); + expect(gladeSlot!.name).toBe('glade-points'); + }); + + it('subscribes a handler that fires onCloudReady(source, count) and requestRender on the ready transition', async () => { + const upload = vi.fn().mockResolvedValue(undefined); + const state = makeState({ rendererUpload: upload }); + const onCloudReady = vi.fn(); + const cb: Partial = { onCloudReady }; + // Use a stub fetcher so we control when the slot transitions to ready. + const cfg: PointSourceConfig = { + source: Source.SDSS, + fetcher: async () => fakeCloud(42), + initialTier: 'medium', + }; + + wirePointSourceSlot(state, cfg, makeDeps(cb)); + + const slot = state.assetSlots.points.get(Source.SDSS)!; + slot.load({ source: Source.SDSS, tier: 'medium' }); + + // Drive microtasks so the slot's fetch + commit chain settles. + // The slot's commit awaits the renderer upload; once that resolves + // the state transitions to 'ready' and the subscriber fires. + await vi.waitFor(() => { + expect(slot.state().kind).toBe('ready'); + }); + + expect(onCloudReady).toHaveBeenCalledOnce(); + expect(onCloudReady).toHaveBeenCalledWith(Source.SDSS, 42); + // requestRender fires once on the ready transition. + expect(state.subsystems.scheduler.requestRender).toHaveBeenCalled(); + }); + + it("commit uploads the cloud to the renderer and writes it into state.sources.clouds", async () => { + const upload = vi.fn().mockResolvedValue(undefined); + const state = makeState({ rendererUpload: upload }); + const cloud = fakeCloud(7); + const cfg: PointSourceConfig = { + source: Source.Glade, + fetcher: async () => cloud, + initialTier: 'small', + }; + + wirePointSourceSlot(state, cfg, makeDeps()); + + const slot = state.assetSlots.points.get(Source.Glade)!; + slot.load({ source: Source.Glade, tier: 'small' }); + + await vi.waitFor(() => { + expect(slot.state().kind).toBe('ready'); + }); + + // Upload was called with (source, cloud) — the renderer's contract. + expect(upload).toHaveBeenCalledOnce(); + expect(upload).toHaveBeenCalledWith(Source.Glade, cloud); + // sources.clouds was populated post-upload. + expect(state.sources.clouds.get(Source.Glade)).toBe(cloud); + }); + + it('skips the upload silently when state.gpu.renderer is null (post-destroy / pre-init race)', async () => { + const state = makeState({ rendererUpload: vi.fn() }); + // Simulate the renderer having been torn down before commit fires. + (state.gpu as unknown as { renderer: null }).renderer = null; + + const cfg: PointSourceConfig = { + source: Source.TwoMRS, + fetcher: async () => fakeCloud(3), + initialTier: 'medium', + }; + + wirePointSourceSlot(state, cfg, makeDeps()); + + const slot = state.assetSlots.points.get(Source.TwoMRS)!; + slot.load({ source: Source.TwoMRS, tier: 'medium' }); + + // The commit body is async but runs to completion even with a null + // renderer — it just becomes a no-op. The slot still transitions + // to 'ready' afterward, which is the contract every other test + // path here relies on. + await vi.waitFor(() => { + expect(slot.state().kind).toBe('ready'); + }); + + // sources.clouds NOT populated — the upload was skipped. + expect(state.sources.clouds.has(Source.TwoMRS)).toBe(false); + }); +});