@celestia-island/hikari 0.55.253 → 0.55.255

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.253",
3
+ "version": "0.55.255",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -24,6 +24,8 @@ type ModelOpts = barrel.Board3DModelOptions;
24
24
  type CameraCfg = barrel.Board3DCameraConfig;
25
25
  type FocusOpts = barrel.Board3DFocusOptions;
26
26
  type ViewModeT = barrel.Board3DViewMode;
27
+ type StarBody = barrel.HkStarSystemBodyInput;
28
+ type StarSpec = barrel.HkStarSystemBodySpec;
27
29
 
28
30
  // Type-only probes: these lines exist so the checker resolves the three
29
31
  // exported types through the barrel itself.
@@ -34,6 +36,12 @@ const modelProbe: ModelOpts | null = null;
34
36
  const cameraProbe: CameraCfg = { fov: 45 };
35
37
  const focusProbe: FocusOpts = { padding: 1.6, lateralBias: 0 };
36
38
  const viewModeProbe: ViewModeT = "orbit";
39
+ const starBodyProbe: StarBody = {
40
+ id: "node-1", label: "Node One", role: "planet", status: "Online", online: true,
41
+ };
42
+ const starSpecProbe: StarSpec | null = null;
43
+ void starBodyProbe;
44
+ void starSpecProbe;
37
45
  void engineProbe;
38
46
  void defProbe;
39
47
  void vecProbe;
@@ -43,9 +51,11 @@ void focusProbe;
43
51
  void viewModeProbe;
44
52
 
45
53
  describe("hikari barrel — 3D board surface", () => {
46
- it("exports both 3D components", () => {
54
+ it("exports the board, minimap and star-system business component", () => {
47
55
  expect(barrel.HkBoard3D).toBeTruthy();
48
56
  expect(barrel.HkMinimap3D).toBeTruthy();
57
+ expect(barrel.HkStarSystem).toBeTruthy();
58
+ expect(barrel.HStarSystem).toBe(barrel.HkStarSystem);
49
59
  });
50
60
 
51
61
  it("exports the helper-layer constant consumers need for own decoration", () => {
@@ -251,9 +251,10 @@
251
251
  * comes from the registration list, not from here. */
252
252
  @include mi.item;
253
253
 
254
- &[data-disabled] {
255
- cursor: default;
256
- }
254
+ /* data-disabled rides the mixin's face whole (dim + not-allowed +
255
+ guarded hover/focus): the sibling tag-input catalog rows already
256
+ did, and a family-local cursor:default split the two choosers'
257
+ disabled language. */
257
258
 
258
259
  /* The "Use <query>" free-text row reads as an add affordance. Truncation
259
260
  * comes from the base .hk-affix-row-label — only the emphasis is new. */
@@ -1,37 +1,34 @@
1
- /* HkLocalePicker — the inline hand-rolled dropdown joins the pop motion
2
- * family (--hk-pop-* tokens in theme/theme.scss): it grows out of its
3
- * trigger (below-right) and retracts into it, matching the popover /
4
- * select-panel surfaces instead of appearing bare. */
5
- .hk-locale-picker-dropdown {
6
- transform-origin: top right;
7
- }
8
-
9
- .hk-locale-picker-enter-active {
10
- transition:
11
- opacity var(--hk-pop-in-duration, 0.2s)
12
- var(--hk-pop-in-opacity-ease, cubic-bezier(0.16, 1, 0.3, 1)),
13
- transform var(--hk-pop-in-duration, 0.2s)
14
- var(--hk-pop-in-transform-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
15
- }
1
+ /* HkLocalePicker — only the TRIGGER is component-owned now; the dropdown
2
+ * is HkLocalePickerPopup (HkMenu) and inherits the menu grammar + pop
3
+ * motion from there. The hand-rolled dropdown sheet (and its pop-motion
4
+ * classes) was removed with the 2026-10-31 sweep rewrite. */
5
+ .hk-locale-picker-trigger {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 0.25rem;
9
+ padding: 0.25rem 0.5rem;
10
+ border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
11
+ border-radius: var(--radius-sm, 4px);
12
+ background: transparent;
13
+ color: rgb(var(--color-muted));
14
+ font-size: 0.75rem;
15
+ font-weight: 500;
16
+ font-family: inherit;
17
+ cursor: pointer;
18
+ transition: background 0.15s;
16
19
 
17
- .hk-locale-picker-leave-active {
18
- transition:
19
- opacity var(--hk-pop-out-duration, 0.15s)
20
- var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1)),
21
- transform var(--hk-pop-out-duration, 0.15s)
22
- var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1));
23
- }
20
+ &:hover {
21
+ background: rgb(var(--color-primary) / 8%);
22
+ }
24
23
 
25
- .hk-locale-picker-enter-from,
26
- .hk-locale-picker-leave-to {
27
- opacity: 0;
28
- transform: translateY(calc(-1 * var(--hk-pop-offset, 4px)))
29
- scale(var(--hk-pop-scale, 0.97));
30
- }
24
+ &:focus-visible {
25
+ outline: 2px solid rgb(var(--color-primary) / 70%);
26
+ outline-offset: 1px;
27
+ }
31
28
 
32
- @media (prefers-reduced-motion: reduce) {
33
- .hk-locale-picker-enter-active,
34
- .hk-locale-picker-leave-active {
35
- transition: none;
29
+ /* The caret is furniture, not copy — half-strength ink, as the
30
+ pre-rewrite trigger drew it. */
31
+ svg {
32
+ opacity: 0.5;
36
33
  }
37
34
  }
@@ -1,6 +1,6 @@
1
- import { defineComponent, ref, Transition, type PropType } from "vue";
1
+ import { defineComponent, ref, type PropType } from "vue";
2
2
 
3
- import { useSurfaceTransition } from "../composables/useSurfaceTransition";
3
+ import HkLocalePickerPopup from "./HkLocalePickerPopup";
4
4
  import "./HkLocalePicker.scss";
5
5
 
6
6
  export interface LocaleOption {
@@ -8,6 +8,15 @@ export interface LocaleOption {
8
8
  label: string;
9
9
  }
10
10
 
11
+ /**
12
+ * HkLocalePicker — the compact trigger + dropdown locale picker. The
13
+ * dropdown hand-rolled its own absolutely-positioned stock (inline
14
+ * styles, JS-painted hover, no sheet, no roles — the 2026-10-31 sweep's
15
+ * finding #8); it now rides HkLocalePickerPopup (HkMenu), so the rows
16
+ * speak the menu-item grammar, the panel anchors/cascades/flips like
17
+ * every menu, and phones get the sheet family. The public surface is
18
+ * unchanged: locales / currentLocale props, `select` event.
19
+ */
11
20
  export const HkLocalePicker = defineComponent({
12
21
  name: "HkLocalePicker",
13
22
  props: {
@@ -23,110 +32,42 @@ export const HkLocalePicker = defineComponent({
23
32
  emits: ["select"],
24
33
  setup(props, { emit }) {
25
34
  const open = ref(false);
26
- // Open/close motion reported into the unified animation context —
27
- // this little dropdown moves like the rest of the pop family
28
- // instead of appearing bare.
29
- const anim = useSurfaceTransition(250).hooks();
35
+ const trigger = ref<HTMLElement | null>(null);
30
36
 
31
- function handleSelect(code: string) {
32
- emit("select", code);
37
+ function handleSelect(code: string | string[]) {
38
+ const code0 = Array.isArray(code) ? code[0] : code;
39
+ if (code0) emit("select", code0);
33
40
  open.value = false;
34
41
  }
35
42
 
36
43
  return () => (
37
- <div style={{ position: "relative" }}>
44
+ <div style={{ position: "relative" }} ref={trigger}>
38
45
  <button
39
46
  type="button"
40
- style={{
41
- display: "inline-flex",
42
- alignItems: "center",
43
- gap: "0.25rem",
44
- padding: "0.25rem 0.5rem",
45
- border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
46
- borderRadius: "var(--radius-sm, 4px)",
47
- background: "transparent",
48
- color: "rgb(var(--color-muted))",
49
- fontSize: "0.75rem",
50
- fontWeight: 500,
51
- cursor: "pointer",
52
- transition: "background 0.15s",
53
- }}
47
+ class="hk-locale-picker-trigger"
48
+ aria-haspopup="menu"
49
+ aria-expanded={open.value}
54
50
  onClick={() => (open.value = !open.value)}
55
- onBlur={() => setTimeout(() => (open.value = false), 150)}
56
51
  >
57
52
  {props.currentLocale.toUpperCase()}
58
- <svg width="10" height="10" viewBox="0 0 10 10" style={{ opacity: 0.5 }}>
59
- <path d="M2 3.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.2" />
53
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
54
+ <path
55
+ d="M2 3.5l3 3 3-3"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ stroke-width="1.2"
59
+ />
60
60
  </svg>
61
61
  </button>
62
- <Transition
63
- name="hk-locale-picker"
64
- appear
65
- onBeforeEnter={anim.onBeforeEnter}
66
- onAfterEnter={anim.onAfterEnter}
67
- onBeforeLeave={anim.onBeforeLeave}
68
- onAfterLeave={anim.onAfterLeave}
69
- >
70
- {open.value && (
71
- <div
72
- class="hk-locale-picker-dropdown"
73
- style={{
74
- position: "absolute",
75
- top: "calc(100% + 4px)",
76
- right: 0,
77
- minWidth: "120px",
78
- background: "rgb(var(--color-surface))",
79
- border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
80
- borderRadius: "var(--radius-md, 6px)",
81
- boxShadow: "0 4px 16px rgb(0 0 0 / 12%)",
82
- // Local stacking inside the header's own context — the
83
- // app-chrome header-popup band documents the intent (this
84
- // value is scoped, it never races the popup stack).
85
- zIndex: "var(--hk-z-float)",
86
- overflow: "hidden",
87
- padding: "4px",
88
- }}
89
- >
90
- {props.locales.map((loc) => (
91
- <button
92
- key={loc.code}
93
- type="button"
94
- style={{
95
- display: "flex",
96
- alignItems: "center",
97
- width: "100%",
98
- padding: "6px 10px",
99
- border: "none",
100
- borderRadius: "4px",
101
- background: loc.code === props.currentLocale
102
- ? "var(--c-primary-subtle, rgb(var(--color-primary) / 8%))"
103
- : "transparent",
104
- color: loc.code === props.currentLocale
105
- ? "var(--c-primary, rgb(var(--color-primary)))"
106
- : "rgb(var(--color-text))",
107
- fontSize: "0.75rem",
108
- cursor: "pointer",
109
- textAlign: "left",
110
- transition: "background 0.1s",
111
- }}
112
- onMouseenter={(e: MouseEvent) => {
113
- if (loc.code !== props.currentLocale) {
114
- (e.currentTarget as HTMLElement).style.background = "var(--c-primary-overlay, rgb(var(--color-primary) / 15%))";
115
- }
116
- }}
117
- onMouseleave={(e: MouseEvent) => {
118
- if (loc.code !== props.currentLocale) {
119
- (e.currentTarget as HTMLElement).style.background = "transparent";
120
- }
121
- }}
122
- onMousedown={() => handleSelect(loc.code)}
123
- >
124
- {loc.label}
125
- </button>
126
- ))}
127
- </div>
128
- )}
129
- </Transition>
62
+ <HkLocalePickerPopup
63
+ open={open.value}
64
+ triggerRef={trigger.value}
65
+ placement="bottom-end"
66
+ locales={props.locales.map((loc) => ({ code: loc.code, label: loc.label }))}
67
+ currentLocale={props.currentLocale}
68
+ onUpdate:open={(v: boolean) => (open.value = v)}
69
+ onSelect={handleSelect}
70
+ />
130
71
  </div>
131
72
  );
132
73
  },
@@ -38,7 +38,9 @@ const HkLocalePickerPopup = defineComponent({
38
38
  open={props.open}
39
39
  anchorRef={anchor}
40
40
  placement={
41
- props.placement === "left-start" || props.placement === "right-start"
41
+ props.placement === "left-start" ||
42
+ props.placement === "right-start" ||
43
+ props.placement === "bottom-end"
42
44
  ? props.placement
43
45
  : "right-start"
44
46
  }
@@ -1,4 +1,4 @@
1
- import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
1
+ import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
2
2
  import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
3
3
 
4
4
  import HSlider from "./HkSlider";
@@ -407,7 +407,7 @@ export default defineComponent({
407
407
  aria-label={t("hikari::zoomToolbar.reset", "Reset zoom")}
408
408
  title={t("hikari::zoomToolbar.reset", "Reset zoom")}
409
409
  >
410
- <Maximize2 size={11} />
410
+ <RotateCcw size={11} />
411
411
  </button>
412
412
  )}
413
413
  {props.collapsible && (
@@ -12,12 +12,15 @@ const H = vi.hoisted(() => {
12
12
  static instances: FakeWebGLRenderer[] = [];
13
13
  renders: Array<{ scene: unknown; camera: THREE.PerspectiveCamera }> = [];
14
14
  disposed = false;
15
- constructor(_opts: { canvas: HTMLCanvasElement }) {
15
+ readonly domElement: HTMLCanvasElement;
16
+ constructor(opts: { canvas: HTMLCanvasElement }) {
17
+ this.domElement = opts.canvas;
16
18
  FakeWebGLRenderer.instances.push(this);
17
19
  }
18
20
  setPixelRatio(): void {}
19
21
  setSize(): void {}
20
22
  setClearColor(): void {}
23
+ forceContextLoss(): void { this.disposed = true; }
21
24
  render(scene: unknown, camera: THREE.PerspectiveCamera): void {
22
25
  // A real renderer maintains world matrices every pass.
23
26
  (scene as THREE.Scene).updateMatrixWorld(true);
@@ -424,4 +427,43 @@ describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
424
427
  expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
425
428
  expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
426
429
  });
430
+ });
431
+ describe("HkMinimap3D reopen cycle (the canvas element is rebuilt by v-if)", () => {
432
+ it("re-binds the renderer to the LIVE canvas; helpers never duplicate", async () => {
433
+ const { engine, scene, firePostRender } = fakeEngine();
434
+ const { container } = mountMap(engine, { collapsible: true });
435
+ await nextTick();
436
+ firePostRender();
437
+ expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
438
+
439
+ const helpersOnLayer = () =>
440
+ scene.children.filter((c) => (c.layers.mask & (1 << BOARD3D_HELPERS_LAYER)) !== 0).length;
441
+ expect(helpersOnLayer()).toBe(2); // glyph group + view-plane frame
442
+
443
+ // fold -> unfold: Vue rebuilds the canvas element; the renderer must
444
+ // follow it or the reopened map paints into the detached element.
445
+ container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
446
+ await nextTick();
447
+ container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
448
+ await nextTick();
449
+ await nextTick();
450
+
451
+ const canvasNow = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
452
+ expect(canvasNow, "canvas is back").toBeTruthy();
453
+ const latest = H.FakeWebGLRenderer.instances.at(-1)!;
454
+ expect(latest.domElement).toBe(canvasNow);
455
+ expect(helpersOnLayer()).toBe(2); // rebuilt, NOT duplicated
456
+ firePostRender();
457
+ expect(latest.renders.length).toBe(1);
458
+
459
+ // a second cycle stays correct
460
+ container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
461
+ await nextTick();
462
+ container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
463
+ await nextTick();
464
+ await nextTick();
465
+ const canvas2 = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
466
+ expect(H.FakeWebGLRenderer.instances.at(-1)!.domElement).toBe(canvas2);
467
+ expect(helpersOnLayer()).toBe(2);
468
+ });
427
469
  });
@@ -24,7 +24,7 @@
24
24
  * mount.
25
25
  */
26
26
 
27
- import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
27
+ import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
28
28
  import {
29
29
  computed,
30
30
  defineComponent,
@@ -381,11 +381,36 @@ export default defineComponent({
381
381
  }
382
382
  });
383
383
 
384
- /** Idempotent GL-surface init. Called from onMounted (expanded
385
- * mounts) and from the expand transition (a defaultCollapsed map
386
- * has no canvas at mount — the renderer builds on first expand). */
384
+ /** GL-surface init, safe to call repeatedly. Called from onMounted
385
+ * (expanded mounts) and from the expand transition (a
386
+ * defaultCollapsed map has no canvas at mount; a fold/unfold cycle
387
+ * REBUILDS the canvas element, so a surviving renderer would keep
388
+ * painting into the detached element and the reopened map would
389
+ * stay empty — detect the swap and rebuild against the live canvas,
390
+ * helpers and post-render hook included). */
387
391
  function initRenderer(): void {
388
- if (renderer) return;
392
+ if (renderer && renderer.domElement === canvasRef.value) return;
393
+ removePostRender?.();
394
+ removePostRender = null;
395
+ if (renderer) {
396
+ renderer.dispose();
397
+ renderer.forceContextLoss?.();
398
+ renderer = null;
399
+ }
400
+ const s = scene.value;
401
+ if (s) {
402
+ if (glyph) {
403
+ s.remove(glyph);
404
+ disposeNode(glyph);
405
+ glyph = null;
406
+ }
407
+ if (planeFrame) {
408
+ s.remove(planeFrame);
409
+ planeFrame.geometry.dispose();
410
+ (planeFrame.material as THREE.Material).dispose();
411
+ planeFrame = null;
412
+ }
413
+ }
389
414
  const canvas = canvasRef.value;
390
415
  const el = rootRef.value;
391
416
  if (!canvas || !el) return;
@@ -519,7 +544,7 @@ export default defineComponent({
519
544
  aria-label={t("hikari::board3d.resetView", "Reset view")}
520
545
  title={t("hikari::board3d.resetView", "Reset view")}
521
546
  >
522
- <Maximize2 size={11} />
547
+ <RotateCcw size={11} />
523
548
  </button>
524
549
  {props.collapsible && (
525
550
  <button
@@ -0,0 +1,29 @@
1
+ @use "../styles/polyfill" as *;
2
+
3
+ /* HkStarSystem's optional built-in CSS2D chip. Host products can replace
4
+ * it through makeLabel without inheriting any product-specific styling. */
5
+ .hk-star-system-chip {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ max-width: 240px;
9
+ padding: 3px 8px;
10
+ overflow: hidden;
11
+ border: 1px solid rgb(var(--color-border, 100 116 139) / 0.38);
12
+ border-radius: 9999px;
13
+ background: rgb(var(--color-background, 11 16 32) / 0.8);
14
+ color: rgb(var(--color-text, 226 232 240));
15
+ font-size: 11px;
16
+ line-height: 1.4;
17
+ text-overflow: ellipsis;
18
+ white-space: nowrap;
19
+ cursor: pointer;
20
+ @include user-select(none);
21
+ pointer-events: auto;
22
+ @include backdrop-filter(blur(6px));
23
+ }
24
+
25
+ .hk-star-system-chip.is-core {
26
+ border-style: dashed;
27
+ cursor: default;
28
+ pointer-events: none;
29
+ }
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ const sheet = readFileSync(join(import.meta.dirname, "HkStarSystem.scss"), "utf8");
6
+
7
+ describe("HkStarSystem chip theme channels", () => {
8
+ it("uses RGB channel tokens through rgb(), never as complete CSS colors", () => {
9
+ expect(sheet).toContain("color: rgb(var(--color-text,");
10
+ expect(sheet).toContain("background: rgb(var(--color-background,");
11
+ expect(sheet).toContain("border: 1px solid rgb(var(--color-border,");
12
+ expect(sheet).not.toContain("color-mix");
13
+ });
14
+ });
@@ -0,0 +1,163 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick, reactive, type App } from "vue";
3
+
4
+ import type { StarSystemBodyInput } from "../scene3d/holographicFleetSpec";
5
+ import HkStarSystem from "./HkStarSystem";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ engine: {},
9
+ ready: undefined as undefined | (() => void),
10
+ build: vi.fn(),
11
+ handle: {
12
+ update: vi.fn(),
13
+ setSelected: vi.fn(),
14
+ focusBody: vi.fn(),
15
+ frameFleet: vi.fn(() => true),
16
+ dispose: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ vi.mock("../scene3d/holographicFleetScene", () => ({
21
+ buildFleetScene: mocks.build.mockImplementation(() => mocks.handle),
22
+ }));
23
+
24
+ vi.mock("./HkBoard3D", async () => {
25
+ const { defineComponent, h: vueH, onMounted } = await import("vue");
26
+ return {
27
+ default: defineComponent({
28
+ name: "HkBoard3D",
29
+ emits: ["ready", "objectClick"],
30
+ setup(_props, { emit, slots }) {
31
+ mocks.ready = () => emit("ready", mocks.engine);
32
+ onMounted(() => mocks.ready?.());
33
+ return () => vueH("div", { class: "board-stub" }, slots.default?.());
34
+ },
35
+ }),
36
+ };
37
+ });
38
+
39
+ const BODIES: readonly StarSystemBodyInput[] = [
40
+ {
41
+ id: "hub-a", label: "Hub A", role: "star", status: "Online",
42
+ online: true, endpointCount: 2, detail: "2 endpoints",
43
+ },
44
+ {
45
+ id: "node-1", label: "Node One", role: "planet", status: "Offline",
46
+ online: false, endpointCount: 0,
47
+ },
48
+ ];
49
+
50
+ const mounts: Array<{ app: App; container: HTMLElement }> = [];
51
+
52
+ async function settle(): Promise<void> {
53
+ await nextTick();
54
+ await new Promise((resolve) => setTimeout(resolve, 0));
55
+ await nextTick();
56
+ }
57
+
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ mocks.engine = {};
61
+ mocks.ready = undefined;
62
+ });
63
+
64
+ afterEach(() => {
65
+ for (const { app, container } of mounts.splice(0)) {
66
+ app.unmount();
67
+ container.remove();
68
+ }
69
+ });
70
+
71
+ function mountSystem(initialSelection: string | null = "node-1") {
72
+ const state = reactive({
73
+ selectedId: initialSelection as string | null,
74
+ bodies: [...BODIES],
75
+ });
76
+ const emitted: Array<string | null> = [];
77
+ const container = document.createElement("div");
78
+ document.body.appendChild(container);
79
+ const app = createApp({
80
+ render: () => h(HkStarSystem, {
81
+ bodies: state.bodies,
82
+ selectedId: state.selectedId,
83
+ "onUpdate:selectedId": (id: string | null) => emitted.push(id),
84
+ }),
85
+ });
86
+ app.mount(container);
87
+ mounts.push({ app, container });
88
+ return { state, emitted };
89
+ }
90
+
91
+ describe("HkStarSystem controlled scene lifecycle", () => {
92
+ it("passes real body data and restores selection without flying", async () => {
93
+ mountSystem("node-1");
94
+ await settle();
95
+
96
+ expect(mocks.handle.update).toHaveBeenCalled();
97
+ const [spec, offline] = mocks.handle.update.mock.calls[0]!;
98
+ expect(spec.stars[0]).toMatchObject({
99
+ id: "hub-a", label: "Hub A", status: "Online", detail: "2 endpoints",
100
+ });
101
+ expect(spec.planets[0]).toMatchObject({
102
+ id: "node-1", label: "Node One", status: "Offline", online: false,
103
+ });
104
+ expect(offline).toEqual({ "hub-a": false, "node-1": true });
105
+ expect(mocks.handle.setSelected).toHaveBeenCalledWith("node-1");
106
+ expect(mocks.handle.focusBody).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it("focuses an explicit controlled selection change", async () => {
110
+ const { state } = mountSystem("node-1");
111
+ await settle();
112
+ state.selectedId = "hub-a";
113
+ await settle();
114
+ expect(mocks.handle.focusBody).toHaveBeenCalledWith("hub-a");
115
+
116
+ state.selectedId = null;
117
+ await settle();
118
+ expect(mocks.handle.setSelected).toHaveBeenCalledWith(null);
119
+ expect(mocks.handle.frameFleet).toHaveBeenCalledWith(600);
120
+ });
121
+
122
+ it("updates before clearing a selection whose body disappeared", async () => {
123
+ const { state, emitted } = mountSystem("hub-a");
124
+ await settle();
125
+ mocks.handle.update.mockClear();
126
+ mocks.handle.setSelected.mockClear();
127
+
128
+ state.bodies = state.bodies.filter((body) => body.id !== "hub-a");
129
+ await settle();
130
+ expect(mocks.handle.update).toHaveBeenCalledTimes(1);
131
+ expect(mocks.handle.setSelected).toHaveBeenCalledWith(null);
132
+ expect(emitted).toContain(null);
133
+ expect(mocks.handle.update.mock.invocationCallOrder[0])
134
+ .toBeLessThan(mocks.handle.setSelected.mock.invocationCallOrder[0]!);
135
+ });
136
+
137
+ it("disposes engine A before restoring the selection on engine B", async () => {
138
+ mountSystem("node-1");
139
+ await settle();
140
+ mocks.build.mockClear();
141
+ mocks.handle.dispose.mockClear();
142
+ mocks.handle.focusBody.mockClear();
143
+
144
+ mocks.engine = {};
145
+ mocks.ready?.();
146
+ await settle();
147
+ expect(mocks.handle.dispose).toHaveBeenCalledTimes(1);
148
+ expect(mocks.build).toHaveBeenCalledTimes(1);
149
+ expect(mocks.handle.dispose.mock.invocationCallOrder[0])
150
+ .toBeLessThan(mocks.build.mock.invocationCallOrder[0]!);
151
+ expect(mocks.handle.setSelected).toHaveBeenCalledWith("node-1");
152
+ expect(mocks.handle.focusBody).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("disposes the builder on unmount", async () => {
156
+ mountSystem();
157
+ await settle();
158
+ const [{ app }] = mounts;
159
+ app.unmount();
160
+ mounts.splice(0, 1);
161
+ expect(mocks.handle.dispose).toHaveBeenCalledTimes(1);
162
+ });
163
+ });