@celestia-island/hikari 0.55.254 → 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.254",
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", () => {
@@ -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
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * HkStarSystem — a reusable star / planet / satellite business scene.
3
+ *
4
+ * The host owns real node data and selection; this component owns the
5
+ * deterministic layout, holographic bodies, data-bound HUD bands, camera
6
+ * follow, labels, minimap markers and every GPU resource it creates. RPC,
7
+ * permissions and node actions deliberately stay in the host product.
8
+ */
9
+
10
+ import {
11
+ defineComponent,
12
+ onBeforeUnmount,
13
+ shallowRef,
14
+ watch,
15
+ type PropType,
16
+ } from "vue";
17
+
18
+ import HkBoard3D, { type Board3DEngine } from "./HkBoard3D";
19
+ import {
20
+ buildFleetScene,
21
+ type FleetSceneHandle,
22
+ } from "../scene3d/holographicFleetScene";
23
+ import {
24
+ buildHolographicFleetSpec,
25
+ type FleetBodySpec,
26
+ type StarSystemBodyInput,
27
+ } from "../scene3d/holographicFleetSpec";
28
+ import "./HkStarSystem.scss";
29
+
30
+ export default defineComponent({
31
+ name: "HkStarSystem",
32
+ props: {
33
+ bodies: {
34
+ type: Array as PropType<readonly StarSystemBodyInput[]>,
35
+ required: true,
36
+ },
37
+ /** Controlled selection. Remounts restore the cursor without an
38
+ * unsolicited camera flight; an explicit change focuses the body. */
39
+ selectedId: { type: String as PropType<string | null>, default: null },
40
+ /** CSS2D needs real elements. Host products may localize and enrich
41
+ * these chips while the HUD band still uses the body's real fields. */
42
+ makeLabel: {
43
+ type: Function as PropType<(body: FleetBodySpec) => HTMLElement>,
44
+ default: undefined,
45
+ },
46
+ makeCoreLabel: {
47
+ type: Function as PropType<() => HTMLElement>,
48
+ default: undefined,
49
+ },
50
+ },
51
+ emits: {
52
+ "update:selectedId": (_id: string | null) => true,
53
+ },
54
+ setup(props, { emit, expose, slots }) {
55
+ const engineRef = shallowRef<Board3DEngine | null>(null);
56
+ let sceneHandle: FleetSceneHandle | null = null;
57
+ let handleEngine: Board3DEngine | null = null;
58
+ let currentSelection: string | null = null;
59
+
60
+ function dropScene(): void {
61
+ sceneHandle?.dispose();
62
+ sceneHandle = null;
63
+ handleEngine = null;
64
+ }
65
+
66
+ function defaultLabel(body: FleetBodySpec): HTMLElement {
67
+ const chip = document.createElement("div");
68
+ chip.className = "hk-star-system-chip";
69
+ chip.textContent = [body.label, body.status, body.detail]
70
+ .filter((part) => part.trim().length > 0)
71
+ .join(" · ");
72
+ chip.addEventListener("click", (event) => {
73
+ event.stopPropagation();
74
+ selectBody(body.id);
75
+ });
76
+ return chip;
77
+ }
78
+
79
+ function defaultCoreLabel(): HTMLElement {
80
+ const chip = document.createElement("div");
81
+ chip.className = "hk-star-system-chip is-core";
82
+ chip.textContent = "Core";
83
+ return chip;
84
+ }
85
+
86
+ function selectBody(id: string | null): void {
87
+ const changed = currentSelection !== id;
88
+ currentSelection = id;
89
+ sceneHandle?.setSelected(id);
90
+ // An explicit click on the ALREADY selected body still re-centres it,
91
+ // matching the original fleet scene; only deselect is idempotent.
92
+ if (id) sceneHandle?.focusBody(id);
93
+ else if (changed) sceneHandle?.frameFleet(600);
94
+ emit("update:selectedId", id);
95
+ }
96
+
97
+ watch(() => props.selectedId, (id) => {
98
+ if (id === currentSelection) return;
99
+ currentSelection = id;
100
+ sceneHandle?.setSelected(id);
101
+ if (id) sceneHandle?.focusBody(id);
102
+ else sceneHandle?.frameFleet(600);
103
+ });
104
+
105
+ watch(
106
+ [engineRef, () => props.bodies],
107
+ ([engine]) => {
108
+ if (!engine) return;
109
+ if (!sceneHandle || handleEngine !== engine) {
110
+ dropScene();
111
+ sceneHandle = buildFleetScene(engine, {
112
+ makeLabel: (body) => props.makeLabel?.(body) ?? defaultLabel(body),
113
+ registryCoreLabel: () => props.makeCoreLabel?.() ?? defaultCoreLabel(),
114
+ });
115
+ handleEngine = engine;
116
+ // A fresh board restores only the cursor. Focusing here would
117
+ // hijack a scene → list → scene round trip with a new flight.
118
+ currentSelection = props.selectedId;
119
+ }
120
+
121
+ const spec = buildHolographicFleetSpec(props.bodies);
122
+ sceneHandle.update(
123
+ spec,
124
+ Object.fromEntries(props.bodies.map((body) => [body.id, !body.online])),
125
+ );
126
+
127
+ const selected = props.selectedId;
128
+ const stillExists = selected !== null && props.bodies.some((body) => body.id === selected);
129
+ if (selected && !stillExists) {
130
+ // update() has already re-anchored/stopped the old follow.
131
+ currentSelection = null;
132
+ sceneHandle.setSelected(null);
133
+ emit("update:selectedId", null);
134
+ } else {
135
+ currentSelection = selected;
136
+ sceneHandle.setSelected(selected);
137
+ }
138
+ },
139
+ { immediate: true, deep: true },
140
+ );
141
+
142
+ onBeforeUnmount(dropScene);
143
+
144
+ expose({
145
+ focusBody(id: string) {
146
+ selectBody(id);
147
+ },
148
+ frameFleet(durationMs?: number): boolean {
149
+ return sceneHandle?.frameFleet(durationMs) ?? false;
150
+ },
151
+ });
152
+
153
+ return () => (
154
+ <HkBoard3D
155
+ fov={45}
156
+ minDistance={3}
157
+ maxDistance={420}
158
+ transparentBackground
159
+ initialPosition={[22, -19, 34]}
160
+ onReady={(engine: Board3DEngine) => {
161
+ engineRef.value = engine;
162
+ }}
163
+ onObjectClick={selectBody}
164
+ >
165
+ {{
166
+ default: () => slots.default?.(),
167
+ ...(slots.fallback ? { fallback: () => slots.fallback?.() } : {}),
168
+ }}
169
+ </HkBoard3D>
170
+ );
171
+ },
172
+ });
@@ -18,16 +18,17 @@
18
18
  --hk-tb-focus: rgb(var(--color-primary, 122 162 247) / 0.6);
19
19
  --hk-tb-close-hover: #e81123;
20
20
  --hk-tb-close-active: #f1707a;
21
- /* The close fill is a FIXED danger red, so its ink is fixed too. Deriving
22
- it from the theme's on-solid choice (the default dark preset picks
23
- polar-night ink for its pale primary fills) painted a dark X on dark
24
- red at roughly 1:1 - the host-visible bug both wowsp and momoi filed.
25
- Hosts may override the token as a pair with --hk-tb-close-hover. */
26
- --hk-tb-close-ink: #ffffff;
21
+ /* --hk-tb-close-ink and --hk-tb-icon-size are HOST-CONFIGURABLE, so they
22
+ are read with fallbacks at their use sites instead of being declared
23
+ here: a declaration on .hk-titlebar itself would out-rank a value the
24
+ host sets on :root (local declarations beat inherited ones), which
25
+ made the tokens look configurable while only the component could win.
26
+ The close fill is a FIXED danger red, so its ink default is fixed too:
27
+ deriving it from the theme's on-solid choice (the default dark preset
28
+ picks polar-night ink for its pale primary fills) painted a dark X on
29
+ dark red at roughly 1:1 - the host-visible bug both wowsp and momoi
30
+ filed. */
27
31
  --hk-tb-radius-close: 0 8px 0 0;
28
- /* Caption glyph size; hosts that want a larger brand mark set the token
29
- instead of overriding the icon rule. */
30
- --hk-tb-icon-size: 16px;
31
32
 
32
33
  position: fixed;
33
34
  top: 0;
@@ -56,8 +57,8 @@
56
57
  }
57
58
 
58
59
  .hk-titlebar-icon {
59
- width: var(--hk-tb-icon-size);
60
- height: var(--hk-tb-icon-size);
60
+ width: var(--hk-tb-icon-size, 16px);
61
+ height: var(--hk-tb-icon-size, 16px);
61
62
  display: block;
62
63
  pointer-events: none;
63
64
  }
@@ -142,12 +143,12 @@
142
143
 
143
144
  .hk-titlebar .hk-titlebar-btn-close:hover {
144
145
  background: var(--hk-tb-close-hover);
145
- color: var(--hk-tb-close-ink);
146
+ color: var(--hk-tb-close-ink, #ffffff);
146
147
  }
147
148
 
148
149
  .hk-titlebar .hk-titlebar-btn-close:active {
149
150
  background: var(--hk-tb-close-active);
150
- color: var(--hk-tb-close-ink);
151
+ color: var(--hk-tb-close-ink, #ffffff);
151
152
  }
152
153
 
153
154
  .hk-titlebar .hk-titlebar-btn:focus-visible {
@@ -217,17 +217,20 @@ describe("HkTitleBar stylesheet contract", () => {
217
217
  expect(body).toContain("border-radius: var(--hk-tb-radius-close)");
218
218
  });
219
219
 
220
- it("inks the close fill from its own token, never the theme's on-solid", () => {
221
- // The fill is a fixed danger red; the ink must be fixed with it. The
220
+ it("inks the close fill from its own host-configurable token, never the theme's on-solid", () => {
221
+ // The fill is a fixed danger red; the ink is fixed with it. The
222
222
  // theme-derived --hi-color-text-on-danger painted a dark X on dark red
223
- // under the default dark preset (the wowsp/momoi field report).
224
- const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
225
- expect(scss).toContain("--hk-tb-close-ink:");
223
+ // under the default dark preset (the wowsp/momoi field report). The
224
+ // token is read with its fallback at the use site rather than declared
225
+ // on .hk-titlebar, so a host's :root assignment actually wins - a local
226
+ // declaration would beat the inherited value (the momoi follow-up).
226
227
  const hover = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:hover");
227
- expect(hover).toContain("color: var(--hk-tb-close-ink)");
228
+ expect(hover).toContain("color: var(--hk-tb-close-ink, #ffffff)");
228
229
  expect(hover).not.toContain("--hi-color-text-on-danger");
229
230
  const active = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:active");
230
- expect(active).toContain("color: var(--hk-tb-close-ink)");
231
+ expect(active).toContain("color: var(--hk-tb-close-ink, #ffffff)");
232
+ const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
233
+ expect(scss).not.toMatch(/^\s*--hk-tb-close-ink:/m);
231
234
  });
232
235
 
233
236
  it("outranks host element rules with caption-scoped buttons", () => {
@@ -240,11 +243,11 @@ describe("HkTitleBar stylesheet contract", () => {
240
243
  expect(closeHoverAt).toBeGreaterThan(hoverAt);
241
244
  });
242
245
 
243
- it("sizes the caption icon through its token", () => {
244
- const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
245
- expect(scss).toContain("--hk-tb-icon-size:");
246
+ it("sizes the caption icon through a host-configurable token", () => {
246
247
  const body = ruleBody(css, ".hk-titlebar-icon");
247
- expect(body).toContain("width: var(--hk-tb-icon-size)");
248
- expect(body).toContain("height: var(--hk-tb-icon-size)");
248
+ expect(body).toContain("width: var(--hk-tb-icon-size, 16px)");
249
+ expect(body).toContain("height: var(--hk-tb-icon-size, 16px)");
250
+ const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
251
+ expect(scss).not.toMatch(/^\s*--hk-tb-icon-size:/m);
249
252
  });
250
253
  });
package/src/index.ts CHANGED
@@ -822,6 +822,12 @@ export {
822
822
  BOARD3D_HELPERS_LAYER,
823
823
  BOARD3D_MAIN_LAYER,
824
824
  } from "./components/HkBoard3D";
825
+ export { default as HkStarSystem } from "./components/HkStarSystem";
826
+ export { default as HStarSystem } from "./components/HkStarSystem";
827
+ export type {
828
+ StarSystemBodyInput as HkStarSystemBodyInput,
829
+ FleetBodySpec as HkStarSystemBodySpec,
830
+ } from "./scene3d/holographicFleetSpec";
825
831
  export { default as HkMinimap3D } from "./components/HkMinimap3D";
826
832
  export { default as HkTrendChart } from "./components/HkTrendChart";
827
833
  export { default as HkTreemap } from "./components/HkTreemap";
@@ -0,0 +1,89 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import * as THREE from "three";
3
+
4
+ import { fakeAs } from "../test-utils/fakes";
5
+ import {
6
+ hologramHudText,
7
+ makeFleetHologram,
8
+ poseFleetHologram,
9
+ type FleetHologramInput,
10
+ } from "./holographicFleetBody";
11
+
12
+ const INPUT: FleetHologramInput = {
13
+ id: "node-1",
14
+ label: "node-1 engine stack",
15
+ status: "Online",
16
+ detail: "3 endpoints",
17
+ role: "planet",
18
+ radius: 1,
19
+ };
20
+
21
+ function mockCanvas(): ReturnType<typeof vi.fn> {
22
+ const fillText = vi.fn();
23
+ const gradient = fakeAs<CanvasGradient>({ addColorStop: vi.fn() });
24
+ const context = fakeAs<CanvasRenderingContext2D>({
25
+ clearRect: vi.fn(),
26
+ fillRect: vi.fn(),
27
+ fillText,
28
+ measureText: (text: string) => fakeAs<TextMetrics>({ width: text.length * 24 }),
29
+ createRadialGradient: () => gradient,
30
+ });
31
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context);
32
+ return fillText;
33
+ }
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ describe("hologramHudText", () => {
40
+ it("contains only the real label, current status and supplied detail", () => {
41
+ expect(hologramHudText(INPUT)).toBe("node-1 engine stack · Online · 3 endpoints");
42
+ expect(hologramHudText({ ...INPUT, status: "Offline", detail: "" }))
43
+ .toBe("node-1 engine stack · Offline");
44
+ expect(hologramHudText({ ...INPUT, label: "", status: "", detail: "" }))
45
+ .toBe("node-1");
46
+ });
47
+ });
48
+
49
+ describe("makeFleetHologram", () => {
50
+ it("draws the real data on the cylindrical band and animates each layer", () => {
51
+ const fillText = mockCanvas();
52
+ const hologram = makeFleetHologram(INPUT, new THREE.Color(0x7fb8dd), false);
53
+
54
+ expect(hologram.hudTexture).not.toBeNull();
55
+ const band = hologram.group.getObjectByName("fleet-hologram-hud-band");
56
+ expect(band).toBeInstanceOf(THREE.Mesh);
57
+ expect((band as THREE.Mesh).geometry).toBeInstanceOf(THREE.CylinderGeometry);
58
+ expect(fillText).toHaveBeenCalled();
59
+ for (const call of fillText.mock.calls) {
60
+ expect(call[0]).toBe("node-1 engine stack · Online · 3 endpoints");
61
+ }
62
+
63
+ // Decorative rings and the text band never widen the body's pointer
64
+ // target. Only the central polyhedron keeps the default raycaster.
65
+ const hits: THREE.Intersection[] = [];
66
+ band?.raycast(new THREE.Raycaster(), hits);
67
+ expect(hits).toHaveLength(0);
68
+
69
+ const initialRingY = hologram.rings.map((ring) => ring.pivot.rotation.y);
70
+ poseFleetHologram(hologram, 5, 20);
71
+ expect(hologram.mesh.rotation.y).toBeCloseTo(Math.PI / 2, 6);
72
+ hologram.rings.forEach((ring, index) => {
73
+ expect(ring.pivot.rotation.y).not.toBe(initialRingY[index]);
74
+ });
75
+ expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.09, 6);
76
+ poseFleetHologram(hologram, 10, 20);
77
+ expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.18, 6);
78
+ });
79
+
80
+ it("uses three rings for a hub and two for a standard node", () => {
81
+ mockCanvas();
82
+ const hub = makeFleetHologram({ ...INPUT, role: "star" }, new THREE.Color(), false);
83
+ const planet = makeFleetHologram(INPUT, new THREE.Color(), false);
84
+ expect(hub.rings).toHaveLength(3);
85
+ expect(planet.rings).toHaveLength(2);
86
+ expect(hub.mesh.geometry).toBeInstanceOf(THREE.IcosahedronGeometry);
87
+ expect(planet.mesh.geometry).toBeInstanceOf(THREE.DodecahedronGeometry);
88
+ });
89
+ });