@ksp-gonogo/kerbcast-react 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1608 @@
1
+ /**
2
+ * Tests for the `CameraFeed` component.
3
+ *
4
+ * Two halves:
5
+ * - the "SIGNAL LOST" overlay + zoom / pan / resize feedback controls;
6
+ * - the camera-selection layer (picker, Next/Previous buttons, handle API,
7
+ * status indicator and empty state).
8
+ *
9
+ * Everything drives the real `KerbcastClient` + real hooks through the SDK's
10
+ * canonical `MockSidecar`. The only thing faked is the WebRTC transport,
11
+ * because jsdom can't produce a real `MediaStream`. Multi-camera scenarios
12
+ * are expressed by populating the sidecar's registry (`addCamera` /
13
+ * `setCameras`); state changes go through `updateCamera` / `destroyCamera`;
14
+ * client commands are inspected via the parsed `commands` array.
15
+ *
16
+ * Tests intentionally left behind from gonogo:
17
+ * - serial-input action tests (nextCamera/prevCamera/zoomIn/zoomOut/panYaw/
18
+ * panPitch via dispatchAction) -- the public surface is now the
19
+ * CameraFeedHandle ref; handle-based equivalents cover the same paths.
20
+ * - CameraFeedConfigPanel -- not part of this package.
21
+ * - CommNet degrade -- gonogo-only data source integration.
22
+ * - Station (brokered) mode -- gonogo-only KerbcastDataSource.attachBroker.
23
+ */
24
+
25
+ import { KerbcastClient, QualityPreset } from "@ksp-gonogo/kerbcast";
26
+ import type { CameraLifecycle, ClientMessage } from "@ksp-gonogo/kerbcast";
27
+ import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
28
+ import {
29
+ act,
30
+ cleanup,
31
+ fireEvent,
32
+ render,
33
+ screen,
34
+ waitFor,
35
+ } from "@testing-library/react";
36
+ import { createRef } from "react";
37
+ import { useState } from "react";
38
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
39
+ import {
40
+ CameraFeed,
41
+ type CameraFeedHandle,
42
+ type CameraStreamHook,
43
+ } from "./CameraFeed";
44
+ import { KerbcastProvider } from "./context";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Camera-state fixture factory
48
+ // ---------------------------------------------------------------------------
49
+
50
+ type CameraStateLike = Record<string, unknown> & { flightId: number };
51
+
52
+ function makeCamera(overrides: CameraStateLike): CameraStateLike {
53
+ return {
54
+ lifecycle: "active",
55
+ partName: "mumech.MuMechModuleHullCamera",
56
+ partTitle: "Hullcam Mk1",
57
+ cameraName: "Camera",
58
+ vesselName: "Kerbal X",
59
+ layers: ["NEAR", "SCALED"],
60
+ operatorLayers: ["NEAR", "SCALED"],
61
+ renderWidth: 384,
62
+ renderHeight: 384,
63
+ operatorWidth: 384,
64
+ operatorHeight: 384,
65
+ supportsZoom: false,
66
+ fov: 60,
67
+ fovMin: 10,
68
+ fovMax: 90,
69
+ supportsPan: false,
70
+ panYaw: 0,
71
+ panPitch: 0,
72
+ panYawMin: 0,
73
+ panYawMax: 0,
74
+ panPitchMin: 0,
75
+ panPitchMax: 0,
76
+ encoderBitrateBps: 1_500_000,
77
+ targetBitrateBps: 0,
78
+ degradeLevel: 0,
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ function toInit(c: CameraStateLike): MockCameraInit {
84
+ return {
85
+ flightId: c.flightId,
86
+ lifecycle: c.lifecycle as CameraLifecycle | undefined,
87
+ partName: c.partName as string | undefined,
88
+ partTitle: c.partTitle as string | undefined,
89
+ cameraName: c.cameraName as string | undefined,
90
+ vesselName: c.vesselName as string | undefined,
91
+ renderWidth: c.renderWidth as number | undefined,
92
+ renderHeight: c.renderHeight as number | undefined,
93
+ operatorWidth: c.operatorWidth as number | undefined,
94
+ operatorHeight: c.operatorHeight as number | undefined,
95
+ supportsZoom: c.supportsZoom as boolean | undefined,
96
+ fov: c.fov as number | undefined,
97
+ fovMin: c.fovMin as number | undefined,
98
+ fovMax: c.fovMax as number | undefined,
99
+ supportsPan: c.supportsPan as boolean | undefined,
100
+ panYaw: c.panYaw as number | undefined,
101
+ panPitch: c.panPitch as number | undefined,
102
+ panYawMin: c.panYawMin as number | undefined,
103
+ panYawMax: c.panYawMax as number | undefined,
104
+ panPitchMin: c.panPitchMin as number | undefined,
105
+ panPitchMax: c.panPitchMax as number | undefined,
106
+ encoderBitrateBps: c.encoderBitrateBps as number | undefined,
107
+ targetBitrateBps: c.targetBitrateBps as number | undefined,
108
+ degradeLevel: c.degradeLevel as number | undefined,
109
+ viewerQuality: c.viewerQuality as QualityPreset | undefined,
110
+ qualityLimitedBy: c.qualityLimitedBy as string | undefined,
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Fixture: connected KerbcastClient + MockSidecar
116
+ // ---------------------------------------------------------------------------
117
+
118
+ const createdClients: KerbcastClient[] = [];
119
+
120
+ async function buildConnectedSource(
121
+ cameras: CameraStateLike[] = [
122
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
123
+ ],
124
+ ): Promise<{ client: KerbcastClient; sidecar: MockSidecar }> {
125
+ const sidecar = new MockSidecar();
126
+ for (const c of cameras) {
127
+ sidecar.addCamera(toInit(c));
128
+ }
129
+ const client = new KerbcastClient(
130
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
131
+ sidecar.createTransport(),
132
+ );
133
+ createdClients.push(client);
134
+
135
+ await act(async () => {
136
+ await client.connect([], { slots: 4 });
137
+ });
138
+ await act(async () => {
139
+ sidecar.open();
140
+ sidecar.setConnectionState("connected");
141
+ });
142
+ return { client, sidecar };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Render helpers
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function renderFeed(
150
+ client: KerbcastClient,
151
+ props: {
152
+ flightId: number | null;
153
+ onSelectCamera?: (id: number) => void;
154
+ showDebugInfo?: boolean;
155
+ renderSize?: "auto" | "none";
156
+ enableQualityControl?: boolean;
157
+ showStatic?: boolean;
158
+ ref?: React.Ref<CameraFeedHandle>;
159
+ },
160
+ ) {
161
+ const { ref, ...rest } = props;
162
+ return render(
163
+ <KerbcastProvider client={client}>
164
+ <CameraFeed ref={ref ?? null} {...rest} />
165
+ </KerbcastProvider>,
166
+ );
167
+ }
168
+
169
+ // A stateful wrapper: holds `flightId` in state, feeds its own setter as
170
+ // `onSelectCamera`. Lets selection tests exercise the real round-trip.
171
+ function renderStatefulFeed(
172
+ client: KerbcastClient,
173
+ initial: number | null,
174
+ ref?: React.Ref<CameraFeedHandle>,
175
+ ) {
176
+ function Harness() {
177
+ const [flightId, setFlightId] = useState<number | null>(initial);
178
+ return (
179
+ <KerbcastProvider client={client}>
180
+ <CameraFeed
181
+ ref={ref ?? null}
182
+ flightId={flightId}
183
+ onSelectCamera={setFlightId}
184
+ />
185
+ </KerbcastProvider>
186
+ );
187
+ }
188
+ return render(<Harness />);
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Global ResizeObserver stub for tests that don't need the controllable one.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ if (typeof globalThis.ResizeObserver === "undefined") {
196
+ globalThis.ResizeObserver = class {
197
+ observe() {}
198
+ unobserve() {}
199
+ disconnect() {}
200
+ } as unknown as typeof ResizeObserver;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Setup / teardown
205
+ // ---------------------------------------------------------------------------
206
+
207
+ afterEach(() => {
208
+ cleanup();
209
+ // Disconnect tracked clients AFTER cleanup so the widget is unmounted first.
210
+ for (const c of createdClients) {
211
+ try { c.disconnect(); } catch { /* ignore */ }
212
+ }
213
+ createdClients.length = 0;
214
+ vi.restoreAllMocks();
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Camera selection
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("CameraFeed - camera selection", () => {
222
+ const THREE_CAMERAS = [
223
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam", vesselName: "Kerbal X" }),
224
+ makeCamera({ flightId: 43, cameraName: "Nose Cam", vesselName: "Kerbal X" }),
225
+ makeCamera({ flightId: 44, cameraName: "Tail Cam", vesselName: "Kerbal X" }),
226
+ ];
227
+
228
+ it("lists every available camera in the menu", async () => {
229
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
230
+
231
+ renderFeed(client, { flightId: null });
232
+
233
+ fireEvent.click(screen.getByRole("button", { name: /starboard cam/i }));
234
+
235
+ const labels = screen
236
+ .getAllByRole("menuitemradio")
237
+ .map((item) => item.textContent);
238
+ expect(labels).toEqual([
239
+ "Starboard Cam (Kerbal X)",
240
+ "Nose Cam (Kerbal X)",
241
+ "Tail Cam (Kerbal X)",
242
+ ]);
243
+ });
244
+
245
+ it("caps the menu height and scrolls instead of overflowing", async () => {
246
+ const many = Array.from({ length: 20 }, (_, i) =>
247
+ makeCamera({ flightId: 100 + i, cameraName: `Cam ${String(i + 1).padStart(2, "0")}` }),
248
+ );
249
+ const { client } = await buildConnectedSource(many);
250
+
251
+ renderFeed(client, { flightId: null });
252
+
253
+ fireEvent.click(screen.getByRole("button", { name: /cam 01/i }));
254
+
255
+ const style = getComputedStyle(screen.getByRole("menu"));
256
+ expect(style.overflowY).toBe("auto");
257
+ expect(style.maxHeight).toBe("min(40vh, 300px)");
258
+ });
259
+
260
+ it("portals the open menu to document.body so tile clipping cannot cut it off", async () => {
261
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
262
+
263
+ const { container } = renderFeed(client, { flightId: null });
264
+
265
+ fireEvent.click(screen.getByRole("button", { name: /starboard cam/i }));
266
+
267
+ const menu = screen.getByRole("menu");
268
+ // The menu lives on document.body, outside the tile's DOM subtree...
269
+ expect(menu.parentElement).toBe(document.body);
270
+ expect(container.contains(menu)).toBe(false);
271
+ // ...with fixed positioning so no ancestor overflow can clip it.
272
+ expect(getComputedStyle(menu).position).toBe("fixed");
273
+ });
274
+
275
+ it("pointer-down inside the portaled menu keeps it open; outside dismisses it", async () => {
276
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
277
+
278
+ renderFeed(client, { flightId: null });
279
+
280
+ fireEvent.click(screen.getByRole("button", { name: /starboard cam/i }));
281
+ expect(screen.getByRole("menu")).toBeTruthy();
282
+
283
+ // A press inside the portaled menu is not "outside" even though it is
284
+ // outside the tile's subtree.
285
+ fireEvent.pointerDown(
286
+ screen.getByRole("menuitemradio", { name: /nose cam/i }),
287
+ );
288
+ expect(screen.getByRole("menu")).toBeTruthy();
289
+
290
+ // A press anywhere else dismisses.
291
+ fireEvent.pointerDown(document.body);
292
+ expect(screen.queryByRole("menu")).toBeNull();
293
+ });
294
+
295
+ it("disambiguates same-named cameras by part title", async () => {
296
+ const { client } = await buildConnectedSource([
297
+ makeCamera({ flightId: 42, cameraName: "NavCam", vesselName: "Kerbal X", partTitle: "NavCam" }),
298
+ makeCamera({ flightId: 43, cameraName: "NavCam", vesselName: "Kerbal X", partTitle: "Clamp-O-Tron Docking Port Jr." }),
299
+ makeCamera({ flightId: 44, cameraName: "Tail Cam", vesselName: "Kerbal X", partTitle: "Some Other Part" }),
300
+ ]);
301
+
302
+ renderFeed(client, { flightId: null });
303
+
304
+ fireEvent.click(screen.getByRole("button", { name: /navcam/i }));
305
+ const labels = screen
306
+ .getAllByRole("menuitemradio")
307
+ .map((item) => item.textContent);
308
+ expect(labels).toEqual([
309
+ "NavCam (Kerbal X)",
310
+ "NavCam - Clamp-O-Tron Docking Port Jr. (Kerbal X)",
311
+ "Tail Cam (Kerbal X)",
312
+ ]);
313
+ });
314
+
315
+ it("auto-selects the first available camera when flightId is null", async () => {
316
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
317
+
318
+ renderFeed(client, { flightId: null });
319
+
320
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
321
+ fireEvent.click(screen.getByRole("button", { name: /starboard cam/i }));
322
+ const checked = screen.getByRole("menuitemradio", { checked: true });
323
+ expect(checked.textContent).toBe("Starboard Cam (Kerbal X)");
324
+ });
325
+
326
+ it("auto-latch does NOT fire onSelectCamera", async () => {
327
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
328
+
329
+ const onSelect = vi.fn();
330
+ renderFeed(client, { flightId: null, onSelectCamera: onSelect });
331
+
332
+ // Auto-latch picks Starboard Cam but must never call onSelectCamera.
333
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
334
+ expect(onSelect).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it("honours an explicitly-configured non-top camera", async () => {
338
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
339
+
340
+ renderFeed(client, { flightId: 44 });
341
+
342
+ expect(screen.getByRole("heading", { name: "Tail Cam" })).toBeTruthy();
343
+ fireEvent.click(screen.getByRole("button", { name: /tail cam/i }));
344
+ const checked = screen.getByRole("menuitemradio", { checked: true });
345
+ expect(checked.textContent).toBe("Tail Cam (Kerbal X)");
346
+ });
347
+
348
+ it("selecting a different camera in the menu persists the choice and switches", async () => {
349
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
350
+
351
+ renderStatefulFeed(client, 42);
352
+
353
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
354
+
355
+ await act(async () => {
356
+ fireEvent.click(screen.getByRole("button", { name: /starboard cam/i }));
357
+ });
358
+ await act(async () => {
359
+ fireEvent.click(screen.getByRole("menuitemradio", { name: /nose cam/i }));
360
+ });
361
+
362
+ expect(screen.getByRole("heading", { name: "Nose Cam" })).toBeTruthy();
363
+ expect(screen.queryByRole("menu")).toBeNull();
364
+
365
+ await act(async () => {
366
+ fireEvent.click(screen.getByRole("button", { name: /nose cam/i }));
367
+ });
368
+ expect(
369
+ screen.getByRole("menuitemradio", { checked: true }).textContent,
370
+ ).toBe("Nose Cam (Kerbal X)");
371
+ });
372
+
373
+ it("Next button advances to the next camera and wraps round", async () => {
374
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
375
+
376
+ renderStatefulFeed(client, 42);
377
+
378
+ const next = screen.getByRole("button", { name: /next camera/i });
379
+
380
+ await act(async () => { fireEvent.click(next); });
381
+ expect(screen.getByRole("heading", { name: "Nose Cam" })).toBeTruthy();
382
+
383
+ await act(async () => { fireEvent.click(next); });
384
+ expect(screen.getByRole("heading", { name: "Tail Cam" })).toBeTruthy();
385
+
386
+ // Wrap: third -> first.
387
+ await act(async () => { fireEvent.click(next); });
388
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
389
+ });
390
+
391
+ it("Previous button steps backward and wraps round", async () => {
392
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
393
+
394
+ renderStatefulFeed(client, 42);
395
+
396
+ const prev = screen.getByRole("button", { name: /previous camera/i });
397
+
398
+ await act(async () => { fireEvent.click(prev); });
399
+ expect(screen.getByRole("heading", { name: "Tail Cam" })).toBeTruthy();
400
+ });
401
+
402
+ it("handle.stepCamera(1) advances camera", async () => {
403
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
404
+
405
+ const handleRef = createRef<CameraFeedHandle>();
406
+ renderStatefulFeed(client, 42, handleRef);
407
+
408
+ await act(async () => {
409
+ handleRef.current?.stepCamera(1);
410
+ });
411
+ expect(screen.getByRole("heading", { name: "Nose Cam" })).toBeTruthy();
412
+
413
+ await act(async () => {
414
+ handleRef.current?.stepCamera(-1);
415
+ });
416
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
417
+ });
418
+
419
+ it("falls back to the first camera when the configured one disappears", async () => {
420
+ const { client, sidecar } = await buildConnectedSource(THREE_CAMERAS);
421
+
422
+ renderFeed(client, { flightId: 44 });
423
+ expect(screen.getByRole("heading", { name: "Tail Cam" })).toBeTruthy();
424
+
425
+ await act(async () => {
426
+ sidecar.setCameras([
427
+ toInit(makeCamera({ flightId: 42, cameraName: "Starboard Cam" })),
428
+ ]);
429
+ });
430
+
431
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
432
+ });
433
+
434
+ it("step buttons are disabled when only one camera is available", async () => {
435
+ const { client } = await buildConnectedSource();
436
+
437
+ renderFeed(client, { flightId: 42 });
438
+
439
+ expect(
440
+ screen.getByRole<HTMLButtonElement>("button", { name: /next camera/i }).disabled,
441
+ ).toBe(true);
442
+ expect(
443
+ screen.getByRole<HTMLButtonElement>("button", { name: /previous camera/i }).disabled,
444
+ ).toBe(true);
445
+ });
446
+
447
+ it("Escape closes the open camera menu", async () => {
448
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
449
+
450
+ renderFeed(client, { flightId: 42 });
451
+
452
+ fireEvent.click(screen.getByRole("button", { name: /starboard cam/i }));
453
+ expect(screen.getByRole("menu")).toBeTruthy();
454
+
455
+ fireEvent.keyDown(document, { key: "Escape" });
456
+ expect(screen.queryByRole("menu")).toBeNull();
457
+ });
458
+ });
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Debug-info toggle
462
+ // ---------------------------------------------------------------------------
463
+
464
+ describe("CameraFeed - debug info toggle", () => {
465
+ it("hides the resolution/bitrate readout by default", async () => {
466
+ const { client } = await buildConnectedSource([
467
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam", vesselName: "Kerbal X", renderWidth: 640, renderHeight: 360 }),
468
+ ]);
469
+
470
+ renderFeed(client, { flightId: 42 });
471
+
472
+ expect(screen.queryByText(/640×360/)).toBeNull();
473
+ });
474
+
475
+ it("shows the resolution/bitrate readout when showDebugInfo is true", async () => {
476
+ const { client } = await buildConnectedSource([
477
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam", vesselName: "Kerbal X", renderWidth: 640, renderHeight: 360 }),
478
+ ]);
479
+
480
+ renderFeed(client, { flightId: 42, showDebugInfo: true });
481
+
482
+ expect(screen.getByText(/640×360/)).toBeTruthy();
483
+ });
484
+ });
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Empty state and status
488
+ // ---------------------------------------------------------------------------
489
+
490
+ describe("CameraFeed - empty state and status", () => {
491
+ it("shows the no-cameras empty state and hides the menu trigger when connected with no cameras", async () => {
492
+ const { client } = await buildConnectedSource([]);
493
+
494
+ renderFeed(client, { flightId: null });
495
+
496
+ expect(screen.queryByRole("button", { name: /camera feed/i })).toBeNull();
497
+ expect(document.querySelector("video")).toBeNull();
498
+ expect(screen.getByText(/start a vessel with hullcam parts/i)).toBeTruthy();
499
+ });
500
+
501
+ it("renders the empty state gracefully when the source is disconnected", async () => {
502
+ const sidecar = new MockSidecar();
503
+ const client = new KerbcastClient(
504
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
505
+ sidecar.createTransport(),
506
+ );
507
+ createdClients.push(client);
508
+
509
+ render(
510
+ <KerbcastProvider client={client}>
511
+ <CameraFeed flightId={null} />
512
+ </KerbcastProvider>,
513
+ );
514
+
515
+ expect(screen.getByText(/start a vessel with hullcam parts/i)).toBeTruthy();
516
+ expect(
517
+ screen.queryByRole("status", { name: /connected|disconnected/i }),
518
+ ).toBeNull();
519
+ });
520
+
521
+ it("shows a custom emptyMessage when provided", async () => {
522
+ const { client } = await buildConnectedSource([]);
523
+
524
+ renderFeed(client, { flightId: null, emptyMessage: "Custom empty message" });
525
+
526
+ expect(screen.getByText("Custom empty message")).toBeTruthy();
527
+ });
528
+ });
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // SIGNAL LOST overlay
532
+ // ---------------------------------------------------------------------------
533
+
534
+ describe("CameraFeed - SIGNAL LOST overlay", () => {
535
+ it("does not render SIGNAL LOST overlay when camera is active", async () => {
536
+ const { client } = await buildConnectedSource();
537
+
538
+ renderFeed(client, { flightId: 42 });
539
+
540
+ expect(screen.queryByRole("status", { name: /signal lost/i })).toBeNull();
541
+ });
542
+
543
+ it("renders SIGNAL LOST overlay when camera lifecycle transitions to destroyed", async () => {
544
+ const { client, sidecar } = await buildConnectedSource();
545
+
546
+ renderFeed(client, { flightId: 42 });
547
+
548
+ expect(screen.queryByRole("status", { name: /signal lost/i })).toBeNull();
549
+
550
+ await act(async () => {
551
+ sidecar.destroyCamera(42);
552
+ });
553
+
554
+ const overlay = screen.getByRole("status", { name: /signal lost/i });
555
+ expect(overlay).toBeTruthy();
556
+ expect(overlay.textContent).toMatch(/SIGNAL LOST/i);
557
+ });
558
+
559
+ it("auto-latch releases a destroyed camera once a live camera exists (vessel switch)", async () => {
560
+ const { client, sidecar } = await buildConnectedSource([
561
+ makeCamera({ flightId: 42, cameraName: "Old Vessel Cam" }),
562
+ ]);
563
+
564
+ renderFeed(client, { flightId: null });
565
+
566
+ expect(screen.getByRole("heading", { name: "Old Vessel Cam" })).toBeTruthy();
567
+
568
+ await act(async () => {
569
+ sidecar.destroyCamera(42);
570
+ });
571
+
572
+ expect(screen.getByRole("status", { name: /signal lost/i })).toBeTruthy();
573
+
574
+ await act(async () => {
575
+ sidecar.setCameras([
576
+ toInit(makeCamera({ flightId: 42, cameraName: "Old Vessel Cam", lifecycle: "destroyed" })),
577
+ toInit(makeCamera({ flightId: 43, cameraName: "New Vessel Cam" })),
578
+ ]);
579
+ });
580
+
581
+ expect(screen.getByRole("heading", { name: "New Vessel Cam" })).toBeTruthy();
582
+ expect(screen.queryByRole("status", { name: /signal lost/i })).toBeNull();
583
+ });
584
+
585
+ it("renders SIGNAL LOST overlay from initial snapshot when camera starts destroyed", async () => {
586
+ const { client } = await buildConnectedSource([
587
+ makeCamera({
588
+ flightId: 99,
589
+ lifecycle: "destroyed",
590
+ partTitle: "Hullcam Mk2",
591
+ cameraName: "Nose Cam",
592
+ vesselName: "Debris Field",
593
+ layers: [],
594
+ operatorLayers: [],
595
+ renderWidth: 0,
596
+ renderHeight: 0,
597
+ operatorWidth: 0,
598
+ operatorHeight: 0,
599
+ fov: 0,
600
+ encoderBitrateBps: 0,
601
+ }),
602
+ ]);
603
+
604
+ renderFeed(client, { flightId: 99 });
605
+
606
+ const overlay = await screen.findByRole("status", { name: /signal lost/i });
607
+ expect(overlay).toBeTruthy();
608
+ expect(overlay.textContent).toMatch(/SIGNAL LOST/i);
609
+ });
610
+ });
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Stall presentation (showStatic prop)
614
+ // ---------------------------------------------------------------------------
615
+
616
+ /*
617
+ * The stall detector lives in the SDK's noise pipeline, which needs a canvas
618
+ * 2d context, captureStream, and requestVideoFrameCallback. The shared setup
619
+ * stubs captureStream; this adds the rest, captures the pipeline's rAF draw
620
+ * loop and rVFC frame callback, and mocks performance.now so tests can step
621
+ * a real feed into (and out of) a stall through the public client surface.
622
+ */
623
+ function installStallEnv() {
624
+ let now = 0;
625
+ const fakeCtx = {
626
+ globalAlpha: 1,
627
+ fillStyle: "",
628
+ drawImage: vi.fn(),
629
+ fillRect: vi.fn(),
630
+ createImageData: vi.fn((w: number, h: number) => ({
631
+ data: new Uint8ClampedArray(w * h * 4),
632
+ })),
633
+ putImageData: vi.fn(),
634
+ };
635
+ const origGetContext = HTMLCanvasElement.prototype.getContext;
636
+ // @ts-expect-error -- jsdom augmentation
637
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(fakeCtx);
638
+
639
+ let rafCb: FrameRequestCallback | null = null;
640
+ const rafSpy = vi
641
+ .spyOn(globalThis, "requestAnimationFrame")
642
+ .mockImplementation((cb) => {
643
+ rafCb = cb;
644
+ return 1;
645
+ });
646
+ const cancelSpy = vi
647
+ .spyOn(globalThis, "cancelAnimationFrame")
648
+ .mockImplementation(() => {});
649
+
650
+ let vfcCb: (() => void) | null = null;
651
+ const proto = HTMLVideoElement.prototype as HTMLVideoElement & {
652
+ requestVideoFrameCallback?: (cb: () => void) => number;
653
+ cancelVideoFrameCallback?: (id: number) => void;
654
+ };
655
+ proto.requestVideoFrameCallback = (cb: () => void) => {
656
+ vfcCb = cb;
657
+ return 1;
658
+ };
659
+ proto.cancelVideoFrameCallback = () => {};
660
+
661
+ const origPause = HTMLMediaElement.prototype.pause;
662
+ HTMLMediaElement.prototype.pause = () => {};
663
+
664
+ const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now);
665
+
666
+ return {
667
+ setNow(t: number) {
668
+ now = t;
669
+ },
670
+ /** Deliver one decoded frame (fires the captured rVFC callback). */
671
+ fireFrame() {
672
+ vfcCb?.();
673
+ },
674
+ /** Run one pipeline draw-loop iteration. */
675
+ drawOnce() {
676
+ rafCb?.(now);
677
+ },
678
+ restore() {
679
+ HTMLCanvasElement.prototype.getContext = origGetContext;
680
+ delete proto.requestVideoFrameCallback;
681
+ delete proto.cancelVideoFrameCallback;
682
+ HTMLMediaElement.prototype.pause = origPause;
683
+ rafSpy.mockRestore();
684
+ cancelSpy.mockRestore();
685
+ nowSpy.mockRestore();
686
+ },
687
+ };
688
+ }
689
+
690
+ describe("CameraFeed - stall presentation (showStatic)", () => {
691
+ it("forwards showStatic to the displayed camera's handle", async () => {
692
+ const { client } = await buildConnectedSource();
693
+
694
+ const { unmount } = renderFeed(client, { flightId: 42 });
695
+ // Default on (no reduced-motion in jsdom).
696
+ expect(client.camera(42).showStatic).toBe(true);
697
+ unmount();
698
+
699
+ renderFeed(client, { flightId: 42, showStatic: false });
700
+ expect(client.camera(42).showStatic).toBe(false);
701
+ });
702
+
703
+ it("showStatic=false shows the stale badge on stall and clears it when frames resume", async () => {
704
+ const env = installStallEnv();
705
+ try {
706
+ const { client, sidecar } = await buildConnectedSource();
707
+ renderFeed(client, { flightId: 42, showStatic: false });
708
+
709
+ // Slot bound by the feed's subscription; deliver the media so the
710
+ // noise pipeline (and its stall detector) attaches to a live source.
711
+ const mid = sidecar.slotMidFor(42);
712
+ expect(mid).toBeDefined();
713
+ await act(async () => {
714
+ sidecar.deliverTrack(mid as string, {} as MediaStreamTrack);
715
+ });
716
+ expect(screen.queryByRole("status", { name: /feed stale/i })).toBeNull();
717
+
718
+ // A frame lands, then nothing for >500 ms: the stall detector trips
719
+ // and the badge appears over the frozen frame.
720
+ env.setNow(1000);
721
+ await act(async () => {
722
+ env.fireFrame();
723
+ });
724
+ env.setNow(1601);
725
+ await act(async () => {
726
+ env.drawOnce();
727
+ });
728
+ const badge = screen.getByRole("status", { name: /feed stale/i });
729
+ expect(badge.textContent).toMatch(/stale/i);
730
+
731
+ // Recovery is instant: the first decoded frame clears the badge on the
732
+ // very next draw.
733
+ await act(async () => {
734
+ env.fireFrame();
735
+ env.drawOnce();
736
+ });
737
+ expect(screen.queryByRole("status", { name: /feed stale/i })).toBeNull();
738
+ } finally {
739
+ env.restore();
740
+ }
741
+ });
742
+
743
+ it("default mode shows no badge on stall (the in-stream static carries it)", async () => {
744
+ const env = installStallEnv();
745
+ try {
746
+ const { client, sidecar } = await buildConnectedSource();
747
+ renderFeed(client, { flightId: 42 });
748
+
749
+ const mid = sidecar.slotMidFor(42);
750
+ await act(async () => {
751
+ sidecar.deliverTrack(mid as string, {} as MediaStreamTrack);
752
+ });
753
+
754
+ env.setNow(1000);
755
+ await act(async () => {
756
+ env.fireFrame();
757
+ });
758
+ env.setNow(1601);
759
+ await act(async () => {
760
+ env.drawOnce();
761
+ });
762
+
763
+ // The stall IS detected (the SDK composites ramping static in-stream),
764
+ // but the chrome stays clean.
765
+ expect(client.camera(42).stalled).toBe(true);
766
+ expect(screen.queryByRole("status", { name: /feed stale/i })).toBeNull();
767
+ } finally {
768
+ env.restore();
769
+ }
770
+ });
771
+
772
+ it("SIGNAL LOST supersedes the stale badge when the camera is destroyed", async () => {
773
+ const env = installStallEnv();
774
+ try {
775
+ const { client, sidecar } = await buildConnectedSource();
776
+ renderFeed(client, { flightId: 42, showStatic: false });
777
+
778
+ const mid = sidecar.slotMidFor(42);
779
+ await act(async () => {
780
+ sidecar.deliverTrack(mid as string, {} as MediaStreamTrack);
781
+ });
782
+ env.setNow(1000);
783
+ await act(async () => {
784
+ env.fireFrame();
785
+ });
786
+ env.setNow(1601);
787
+ await act(async () => {
788
+ env.drawOnce();
789
+ });
790
+ expect(screen.getByRole("status", { name: /feed stale/i })).toBeTruthy();
791
+
792
+ await act(async () => {
793
+ sidecar.destroyCamera(42);
794
+ });
795
+ expect(screen.getByRole("status", { name: /signal lost/i })).toBeTruthy();
796
+ expect(screen.queryByRole("status", { name: /feed stale/i })).toBeNull();
797
+ } finally {
798
+ env.restore();
799
+ }
800
+ });
801
+ });
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // Zoom controls
805
+ // ---------------------------------------------------------------------------
806
+
807
+ describe("CameraFeed - quality control", () => {
808
+ it("hidden unless enableQualityControl is set", async () => {
809
+ const { client } = await buildConnectedSource();
810
+ renderFeed(client, { flightId: 42 });
811
+ expect(screen.queryByRole("button", { name: /quality/i })).toBeNull();
812
+ });
813
+
814
+ it("menu lists Auto plus the presets with target dims from the operator size", async () => {
815
+ const { client } = await buildConnectedSource([
816
+ makeCamera({
817
+ flightId: 42,
818
+ operatorWidth: 1024,
819
+ operatorHeight: 576,
820
+ renderWidth: 1024,
821
+ renderHeight: 576,
822
+ }),
823
+ ]);
824
+ renderFeed(client, { flightId: 42, enableQualityControl: true });
825
+
826
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
827
+
828
+ const items = screen
829
+ .getAllByRole("menuitemradio")
830
+ .map((el) => el.textContent);
831
+ expect(items).toEqual([
832
+ "Auto",
833
+ "Full (1024×576)",
834
+ "3/4 (768×432)",
835
+ "1/2 (512×288)",
836
+ "1/4 (256×144)",
837
+ ]);
838
+ // No request yet: Auto is the checked entry.
839
+ expect(
840
+ screen
841
+ .getByRole("menuitemradio", { name: "Auto" })
842
+ .getAttribute("aria-checked"),
843
+ ).toBe("true");
844
+ // Footer shows the effective state.
845
+ expect(screen.getByRole("status").textContent).toBe("now 1024×576");
846
+ });
847
+
848
+ it("picking a preset sends set-quality and the echoed state checks it", async () => {
849
+ const { client, sidecar } = await buildConnectedSource([
850
+ makeCamera({
851
+ flightId: 42,
852
+ operatorWidth: 1024,
853
+ operatorHeight: 576,
854
+ renderWidth: 1024,
855
+ renderHeight: 576,
856
+ }),
857
+ ]);
858
+ renderFeed(client, { flightId: 42, enableQualityControl: true });
859
+
860
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
861
+ await act(async () => {
862
+ fireEvent.click(screen.getByRole("menuitemradio", { name: /^1\/2/ }));
863
+ });
864
+
865
+ expect(sidecar.lastCommand("set-quality", 42)?.content.preset).toBe(
866
+ QualityPreset.Half,
867
+ );
868
+
869
+ // Authoritative state came back: re-opened menu reflects it.
870
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
871
+ expect(
872
+ screen
873
+ .getByRole("menuitemradio", { name: /^1\/2/ })
874
+ .getAttribute("aria-checked"),
875
+ ).toBe("true");
876
+ expect(screen.getByRole("status").textContent).toBe("now 512×288");
877
+
878
+ // Auto clears the request (preset omitted on the wire).
879
+ await act(async () => {
880
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Auto" }));
881
+ });
882
+ const last = sidecar.lastCommand("set-quality", 42);
883
+ expect(last && "preset" in last.content && last.content.preset).toBeFalsy();
884
+ });
885
+
886
+ it("marks the feed when the sidecar reports throttling below the request", async () => {
887
+ const { client, sidecar } = await buildConnectedSource([
888
+ makeCamera({
889
+ flightId: 42,
890
+ operatorWidth: 1024,
891
+ operatorHeight: 576,
892
+ renderWidth: 512,
893
+ renderHeight: 288,
894
+ viewerQuality: QualityPreset.Half,
895
+ }),
896
+ ]);
897
+ renderFeed(client, { flightId: 42, enableQualityControl: true });
898
+
899
+ // Honored request: no throttled marker.
900
+ let button = screen.getByRole("button", { name: /quality/i });
901
+ expect(button.title).toBe("Quality");
902
+
903
+ // Adaptive demote pushes below the viewer target; sidecar broadcasts it.
904
+ await act(async () => {
905
+ sidecar.updateCamera(42, {
906
+ renderWidth: 256,
907
+ renderHeight: 144,
908
+ qualityLimitedBy: "throttled",
909
+ });
910
+ });
911
+
912
+ button = screen.getByRole("button", { name: /quality/i });
913
+ expect(button.title).toMatch(/throttled/i);
914
+ fireEvent.click(button);
915
+ expect(screen.getByRole("status").textContent).toBe(
916
+ "now 256×144 · throttled",
917
+ );
918
+ // The request itself is still shown as 1/2; it gets honored again
919
+ // when the controller recovers.
920
+ expect(
921
+ screen
922
+ .getByRole("menuitemradio", { name: /^1\/2/ })
923
+ .getAttribute("aria-checked"),
924
+ ).toBe("true");
925
+ });
926
+
927
+ it("portals the open quality menu to document.body so tile clipping cannot cut it off", async () => {
928
+ const { client } = await buildConnectedSource();
929
+ const { container } = renderFeed(client, {
930
+ flightId: 42,
931
+ enableQualityControl: true,
932
+ });
933
+
934
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
935
+
936
+ const menu = screen.getByRole("menu", { name: "Quality" });
937
+ // Same portal contract as the camera menu: on document.body, outside
938
+ // the tile's DOM subtree, fixed so no ancestor overflow can clip it.
939
+ expect(menu.parentElement).toBe(document.body);
940
+ expect(container.contains(menu)).toBe(false);
941
+ expect(getComputedStyle(menu).position).toBe("fixed");
942
+ });
943
+
944
+ it("pointer-down inside the portaled quality menu keeps it open; outside dismisses it", async () => {
945
+ const { client } = await buildConnectedSource();
946
+ renderFeed(client, { flightId: 42, enableQualityControl: true });
947
+
948
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
949
+ expect(screen.getByRole("menu", { name: "Quality" })).toBeTruthy();
950
+
951
+ // A press inside the portaled menu is not "outside" even though it is
952
+ // outside the tile's subtree.
953
+ fireEvent.pointerDown(screen.getByRole("menuitemradio", { name: "Auto" }));
954
+ expect(screen.getByRole("menu", { name: "Quality" })).toBeTruthy();
955
+
956
+ // A press anywhere else dismisses.
957
+ fireEvent.pointerDown(document.body);
958
+ expect(screen.queryByRole("menu", { name: "Quality" })).toBeNull();
959
+ });
960
+
961
+ it("Escape closes the quality menu", async () => {
962
+ const { client } = await buildConnectedSource();
963
+ renderFeed(client, { flightId: 42, enableQualityControl: true });
964
+
965
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
966
+ expect(screen.getByRole("menu", { name: "Quality" })).toBeTruthy();
967
+ fireEvent.keyDown(document, { key: "Escape" });
968
+ await waitFor(() =>
969
+ expect(screen.queryByRole("menu", { name: "Quality" })).toBeNull(),
970
+ );
971
+ });
972
+ });
973
+
974
+ describe("CameraFeed - zoom controls", () => {
975
+ it("zoom controls appear when camera supportsZoom", async () => {
976
+ const { client, sidecar } = await buildConnectedSource();
977
+
978
+ renderFeed(client, { flightId: 42 });
979
+
980
+ await act(async () => {
981
+ sidecar.updateCamera(42, { supportsZoom: true });
982
+ });
983
+
984
+ expect(screen.getByRole("button", { name: /zoom in/i })).toBeTruthy();
985
+ expect(screen.getByRole("button", { name: /zoom out/i })).toBeTruthy();
986
+ });
987
+
988
+ it("zoom controls absent when camera does not support zoom", async () => {
989
+ const { client } = await buildConnectedSource();
990
+
991
+ renderFeed(client, { flightId: 42 });
992
+
993
+ expect(screen.queryByRole("button", { name: /zoom in/i })).toBeNull();
994
+ });
995
+
996
+ it("clicking the on-screen + button fires a discrete set-fov step", async () => {
997
+ const { client, sidecar } = await buildConnectedSource();
998
+
999
+ await act(async () => {
1000
+ sidecar.updateCamera(42, { supportsZoom: true, fov: 60 });
1001
+ });
1002
+
1003
+ renderFeed(client, { flightId: 42 });
1004
+
1005
+ await act(async () => {
1006
+ fireEvent.click(screen.getByRole("button", { name: /zoom in/i }));
1007
+ });
1008
+ expect(sidecar.lastCommand("set-fov")?.content).toMatchObject({
1009
+ flightId: 42,
1010
+ fov: 55, // 60 - 5 (zoom in, FoV decreases)
1011
+ });
1012
+ expect(sidecar.lastCommand("set-zoom-rate")).toBeUndefined();
1013
+
1014
+ await act(async () => {
1015
+ fireEvent.click(screen.getByRole("button", { name: /zoom out/i }));
1016
+ });
1017
+ // Accumulates against the optimistic FoV (55 -> 60).
1018
+ expect(sidecar.lastCommand("set-fov")?.content.fov).toBe(60);
1019
+ });
1020
+
1021
+ it("keyboard-activating a zoom button (no pointer events) fires a discrete nudge", async () => {
1022
+ const { client, sidecar } = await buildConnectedSource();
1023
+
1024
+ await act(async () => {
1025
+ sidecar.updateCamera(42, { supportsZoom: true, fov: 60 });
1026
+ });
1027
+
1028
+ renderFeed(client, { flightId: 42 });
1029
+
1030
+ await act(async () => {
1031
+ fireEvent.click(screen.getByRole("button", { name: /zoom in/i }), { detail: 0 });
1032
+ });
1033
+ expect(sidecar.lastCommand("set-fov")?.content).toMatchObject({
1034
+ flightId: 42,
1035
+ fov: 55,
1036
+ });
1037
+ expect(sidecar.lastCommand("set-zoom-rate")).toBeUndefined();
1038
+ });
1039
+
1040
+ it("handle.setZoomRate(1) holds a zoom-in rate; setZoomRate(0) stops it", async () => {
1041
+ const { client, sidecar } = await buildConnectedSource();
1042
+
1043
+ await act(async () => {
1044
+ sidecar.updateCamera(42, { supportsZoom: true, fov: 60 });
1045
+ });
1046
+
1047
+ const handleRef = createRef<CameraFeedHandle>();
1048
+ renderFeed(client, { flightId: 42, ref: handleRef });
1049
+
1050
+ await act(async () => {
1051
+ handleRef.current?.setZoomRate(1);
1052
+ });
1053
+ expect(sidecar.lastCommand("set-zoom-rate")?.content).toMatchObject({
1054
+ flightId: 42,
1055
+ rate: 1,
1056
+ });
1057
+
1058
+ await act(async () => {
1059
+ handleRef.current?.setZoomRate(0);
1060
+ });
1061
+ expect(sidecar.lastCommand("set-zoom-rate")?.content.rate).toBe(0);
1062
+ });
1063
+
1064
+ it("handle.setZoomRate is a no-op when camera does not support zoom", async () => {
1065
+ const { client, sidecar } = await buildConnectedSource();
1066
+
1067
+ const handleRef = createRef<CameraFeedHandle>();
1068
+ renderFeed(client, { flightId: 42, ref: handleRef });
1069
+
1070
+ await act(async () => {
1071
+ handleRef.current?.setZoomRate(1);
1072
+ });
1073
+
1074
+ expect(sidecar.lastCommand("set-zoom-rate")).toBeUndefined();
1075
+ });
1076
+
1077
+ it("a release with no prior press emits no command (rate already 0)", async () => {
1078
+ const { client, sidecar } = await buildConnectedSource();
1079
+
1080
+ await act(async () => {
1081
+ sidecar.updateCamera(42, { supportsZoom: true, fov: 60 });
1082
+ });
1083
+
1084
+ const handleRef = createRef<CameraFeedHandle>();
1085
+ renderFeed(client, { flightId: 42, ref: handleRef });
1086
+
1087
+ await act(async () => {
1088
+ handleRef.current?.setZoomRate(0);
1089
+ });
1090
+
1091
+ // sendZoomRate dedupes against the last-sent rate (0).
1092
+ expect(sidecar.lastCommand("set-zoom-rate")).toBeUndefined();
1093
+ });
1094
+ });
1095
+
1096
+ // ---------------------------------------------------------------------------
1097
+ // Vertical FoV (zoom) slider
1098
+ // ---------------------------------------------------------------------------
1099
+
1100
+ describe("CameraFeed - vertical FoV (zoom) slider", () => {
1101
+ const PAN_ZOOM_CAMERA = {
1102
+ flightId: 42,
1103
+ cameraName: "Gimbal Cam",
1104
+ supportsPan: true,
1105
+ panYaw: 0,
1106
+ panPitch: 0,
1107
+ panYawMin: -45,
1108
+ panYawMax: 45,
1109
+ panPitchMin: -30,
1110
+ panPitchMax: 30,
1111
+ supportsZoom: true,
1112
+ fov: 60,
1113
+ fovMin: 10,
1114
+ fovMax: 90,
1115
+ };
1116
+
1117
+ it("shows a single zoom slider (no yaw/pitch sliders) when zoom is supported", async () => {
1118
+ const { client, sidecar } = await buildConnectedSource();
1119
+
1120
+ await act(async () => {
1121
+ sidecar.updateCamera(42, PAN_ZOOM_CAMERA);
1122
+ });
1123
+
1124
+ renderFeed(client, { flightId: 42 });
1125
+
1126
+ expect(screen.getByRole("slider", { name: /zoom/i })).toBeTruthy();
1127
+ expect(screen.queryByRole("slider", { name: /pan/i })).toBeNull();
1128
+ });
1129
+
1130
+ it("does not show the zoom slider when the camera has no zoom", async () => {
1131
+ const { client } = await buildConnectedSource();
1132
+
1133
+ renderFeed(client, { flightId: 42 });
1134
+
1135
+ expect(screen.queryByRole("slider", { name: /zoom/i })).toBeNull();
1136
+ });
1137
+
1138
+ it("zoom slider min/max reflect the camera FoV range and initial value", async () => {
1139
+ const { client, sidecar } = await buildConnectedSource();
1140
+
1141
+ await act(async () => {
1142
+ sidecar.updateCamera(42, PAN_ZOOM_CAMERA);
1143
+ });
1144
+
1145
+ renderFeed(client, { flightId: 42 });
1146
+
1147
+ const fovSlider = screen.getByRole<HTMLInputElement>("slider", { name: /zoom/i });
1148
+ expect(Number(fovSlider.min)).toBe(10);
1149
+ expect(Number(fovSlider.max)).toBe(90);
1150
+ expect(Number(fovSlider.value)).toBe(60);
1151
+ });
1152
+
1153
+ it("zoom slider commits the settled absolute set-fov on release (debounced)", async () => {
1154
+ const { client, sidecar } = await buildConnectedSource();
1155
+
1156
+ await act(async () => {
1157
+ sidecar.updateCamera(42, PAN_ZOOM_CAMERA);
1158
+ });
1159
+
1160
+ renderFeed(client, { flightId: 42 });
1161
+
1162
+ const fovSlider = screen.getByRole("slider", { name: /zoom/i });
1163
+
1164
+ await act(async () => {
1165
+ fireEvent.pointerDown(fovSlider);
1166
+ fireEvent.change(fovSlider, { target: { value: "50" } });
1167
+ fireEvent.change(fovSlider, { target: { value: "30" } });
1168
+ });
1169
+ expect(sidecar.lastCommand("set-fov")).toBeUndefined();
1170
+
1171
+ await act(async () => {
1172
+ fireEvent.pointerUp(fovSlider);
1173
+ });
1174
+ expect(sidecar.lastCommand("set-fov")?.content).toMatchObject({
1175
+ flightId: 42,
1176
+ fov: 30,
1177
+ });
1178
+ });
1179
+
1180
+ it("zoom slider commits the settled value after a pause (no pointer release)", async () => {
1181
+ vi.useFakeTimers();
1182
+ const { client, sidecar } = await buildConnectedSource();
1183
+
1184
+ await act(async () => {
1185
+ sidecar.updateCamera(42, PAN_ZOOM_CAMERA);
1186
+ });
1187
+
1188
+ renderFeed(client, { flightId: 42 });
1189
+
1190
+ const fovSlider = screen.getByRole("slider", { name: /zoom/i });
1191
+
1192
+ await act(async () => {
1193
+ fireEvent.change(fovSlider, { target: { value: "42" } });
1194
+ });
1195
+ expect(sidecar.lastCommand("set-fov")).toBeUndefined();
1196
+
1197
+ await act(async () => {
1198
+ vi.advanceTimersByTime(150); // past FOV_SLIDER_DEBOUNCE_MS (120)
1199
+ });
1200
+ expect(sidecar.lastCommand("set-fov")?.content.fov).toBe(42);
1201
+
1202
+ vi.useRealTimers();
1203
+ });
1204
+
1205
+ it("holding a zoom button sends a constant rate; releasing stops it", async () => {
1206
+ const { client, sidecar } = await buildConnectedSource();
1207
+
1208
+ await act(async () => {
1209
+ sidecar.updateCamera(42, { supportsZoom: true, fov: 60 });
1210
+ });
1211
+
1212
+ renderFeed(client, { flightId: 42 });
1213
+
1214
+ const zoomInBtn = screen.getByRole("button", { name: /zoom in/i });
1215
+
1216
+ await act(async () => {
1217
+ fireEvent.pointerDown(zoomInBtn);
1218
+ });
1219
+ expect(sidecar.lastCommand("set-zoom-rate")?.content).toMatchObject({
1220
+ flightId: 42,
1221
+ rate: 1,
1222
+ });
1223
+
1224
+ await act(async () => {
1225
+ fireEvent.pointerUp(zoomInBtn);
1226
+ });
1227
+ expect(sidecar.lastCommand("set-zoom-rate")?.content.rate).toBe(0);
1228
+ });
1229
+
1230
+ it("zoom slider thumb tracks the camera echo when not dragging", async () => {
1231
+ const { client, sidecar } = await buildConnectedSource();
1232
+
1233
+ await act(async () => {
1234
+ sidecar.updateCamera(42, PAN_ZOOM_CAMERA);
1235
+ });
1236
+
1237
+ renderFeed(client, { flightId: 42 });
1238
+
1239
+ await act(async () => {
1240
+ sidecar.updateCamera(42, { fov: 45 });
1241
+ });
1242
+
1243
+ const fovSlider = screen.getByRole<HTMLInputElement>("slider", { name: /zoom/i });
1244
+ expect(Number(fovSlider.value)).toBe(45);
1245
+ });
1246
+ });
1247
+
1248
+ // ---------------------------------------------------------------------------
1249
+ // ResizeObserver render-size feedback
1250
+ // ---------------------------------------------------------------------------
1251
+
1252
+ describe("CameraFeed - ResizeObserver render-size feedback", () => {
1253
+ let resizeCallback:
1254
+ | ((entries: ResizeObserverEntry[], observer: ResizeObserver) => void)
1255
+ | undefined;
1256
+ const originalResizeObserver = globalThis.ResizeObserver;
1257
+
1258
+ beforeEach(() => {
1259
+ vi.useFakeTimers();
1260
+ globalThis.ResizeObserver = class {
1261
+ constructor(
1262
+ cb: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void,
1263
+ ) {
1264
+ resizeCallback = cb;
1265
+ }
1266
+ observe() {}
1267
+ unobserve() {}
1268
+ disconnect() {}
1269
+ } as unknown as typeof ResizeObserver;
1270
+ });
1271
+
1272
+ afterEach(() => {
1273
+ vi.useRealTimers();
1274
+ globalThis.ResizeObserver = originalResizeObserver;
1275
+ resizeCallback = undefined;
1276
+ });
1277
+
1278
+ it("render-size observer fires set-render-size command", async () => {
1279
+ const { client, sidecar } = await buildConnectedSource();
1280
+
1281
+ renderFeed(client, { flightId: 42 });
1282
+
1283
+ await act(async () => {
1284
+ resizeCallback?.(
1285
+ [{ contentRect: { width: 400, height: 400 } }] as ResizeObserverEntry[],
1286
+ {} as ResizeObserver,
1287
+ );
1288
+ });
1289
+
1290
+ await act(async () => {
1291
+ vi.advanceTimersByTime(501);
1292
+ });
1293
+
1294
+ const renderSizeMsg = sidecar.lastCommand("set-render-size");
1295
+ expect(renderSizeMsg).toBeTruthy();
1296
+ // Width as-is (already even); height derived 16:9 then rounded to even px:
1297
+ // 400 * 9/16 = 225 -> 226.
1298
+ expect(renderSizeMsg?.content.width).toBe(400);
1299
+ expect(renderSizeMsg?.content.height).toBe(226);
1300
+ });
1301
+ });
1302
+
1303
+ // ---------------------------------------------------------------------------
1304
+ // Pan reticle
1305
+ // ---------------------------------------------------------------------------
1306
+
1307
+ describe("CameraFeed - pan reticle", () => {
1308
+ const PAN_FLIP = {
1309
+ supportsPan: true,
1310
+ panYawMin: -45,
1311
+ panYawMax: 45,
1312
+ panPitchMin: -30,
1313
+ panPitchMax: 30,
1314
+ };
1315
+
1316
+ it("pan controls appear when camera supportsPan", async () => {
1317
+ const { client, sidecar } = await buildConnectedSource();
1318
+
1319
+ renderFeed(client, { flightId: 42 });
1320
+
1321
+ await act(async () => {
1322
+ sidecar.updateCamera(42, PAN_FLIP);
1323
+ });
1324
+
1325
+ expect(screen.getByRole("button", { name: /pan left/i })).toBeTruthy();
1326
+ expect(
1327
+ (screen.getByRole("button", { name: /pan up/i }) as HTMLButtonElement).disabled,
1328
+ ).toBe(false);
1329
+ });
1330
+
1331
+ it("pan controls absent when camera does not support pan", async () => {
1332
+ const { client } = await buildConnectedSource();
1333
+
1334
+ renderFeed(client, { flightId: 42 });
1335
+
1336
+ expect(screen.queryByRole("button", { name: /pan left/i })).toBeNull();
1337
+ });
1338
+
1339
+ const panRateMsgs = (sidecar: MockSidecar) =>
1340
+ sidecar.commands.filter(
1341
+ (c): c is Extract<ClientMessage, { type: "set-pan-rate" }> =>
1342
+ c.type === "set-pan-rate",
1343
+ );
1344
+
1345
+ it("clicking a pan arrow moves one discrete step (absolute set-pan, no rate)", async () => {
1346
+ const { client, sidecar } = await buildConnectedSource();
1347
+
1348
+ await act(async () => {
1349
+ sidecar.updateCamera(42, PAN_FLIP);
1350
+ });
1351
+
1352
+ renderFeed(client, { flightId: 42 });
1353
+
1354
+ const leftArrow = screen.getByRole("button", { name: /pan left/i });
1355
+
1356
+ await act(async () => {
1357
+ fireEvent.click(leftArrow);
1358
+ });
1359
+ expect(panRateMsgs(sidecar)).toHaveLength(0);
1360
+ expect(sidecar.lastCommand("set-pan")?.content).toMatchObject({
1361
+ flightId: 42,
1362
+ yaw: -5, // left = negative yaw
1363
+ pitch: 0,
1364
+ });
1365
+
1366
+ await act(async () => {
1367
+ fireEvent.click(leftArrow);
1368
+ });
1369
+ expect(sidecar.lastCommand("set-pan")?.content.yaw).toBe(-10);
1370
+ });
1371
+
1372
+ it("dragging the pan ball sends a proportional set-pan-rate; release sends 0", async () => {
1373
+ const origCapture = HTMLElement.prototype.setPointerCapture;
1374
+ HTMLElement.prototype.setPointerCapture = vi.fn();
1375
+
1376
+ const { client, sidecar } = await buildConnectedSource();
1377
+
1378
+ await act(async () => {
1379
+ sidecar.updateCamera(42, PAN_FLIP);
1380
+ });
1381
+
1382
+ renderFeed(client, { flightId: 42 });
1383
+
1384
+ const ball = screen.getByTitle("Drag to pan");
1385
+
1386
+ await act(async () => {
1387
+ fireEvent.pointerDown(ball, { clientX: 100, clientY: 100 });
1388
+ });
1389
+ expect(panRateMsgs(sidecar)).toHaveLength(0);
1390
+
1391
+ // Deflect fully right (40px >= PAN_BALL_RADIUS => rate clamps to +1).
1392
+ await act(async () => {
1393
+ fireEvent.pointerMove(ball, { clientX: 140, clientY: 100 });
1394
+ });
1395
+ const moved = panRateMsgs(sidecar);
1396
+ expect(moved.length).toBeGreaterThan(0);
1397
+ expect(moved.at(-1)?.content).toMatchObject({ flightId: 42, yawRate: 1 });
1398
+ expect(moved.at(-1)?.content.pitchRate).toBe(0);
1399
+
1400
+ await act(async () => {
1401
+ fireEvent.pointerUp(ball);
1402
+ });
1403
+ expect(panRateMsgs(sidecar).at(-1)?.content).toMatchObject({
1404
+ yawRate: 0,
1405
+ pitchRate: 0,
1406
+ });
1407
+
1408
+ HTMLElement.prototype.setPointerCapture = origCapture;
1409
+ });
1410
+
1411
+ it("handle.setPanAxis('yaw', 0.5) sends set-pan-rate on the yaw axis", async () => {
1412
+ const { client, sidecar } = await buildConnectedSource();
1413
+
1414
+ await act(async () => {
1415
+ sidecar.updateCamera(42, PAN_FLIP);
1416
+ });
1417
+
1418
+ const handleRef = createRef<CameraFeedHandle>();
1419
+ renderFeed(client, { flightId: 42, ref: handleRef });
1420
+
1421
+ await act(async () => {
1422
+ handleRef.current?.setPanAxis("yaw", 0.5);
1423
+ });
1424
+
1425
+ expect(sidecar.lastCommand("set-pan-rate")?.content).toMatchObject({
1426
+ flightId: 42,
1427
+ yawRate: 0.5,
1428
+ pitchRate: 0,
1429
+ });
1430
+ });
1431
+
1432
+ it("handle.setPanAxis('pitch', 0.5) sends set-pan-rate on the pitch axis", async () => {
1433
+ const { client, sidecar } = await buildConnectedSource();
1434
+
1435
+ await act(async () => {
1436
+ sidecar.updateCamera(42, PAN_FLIP);
1437
+ });
1438
+
1439
+ const handleRef = createRef<CameraFeedHandle>();
1440
+ renderFeed(client, { flightId: 42, ref: handleRef });
1441
+
1442
+ await act(async () => {
1443
+ handleRef.current?.setPanAxis("pitch", 0.5);
1444
+ });
1445
+
1446
+ expect(sidecar.lastCommand("set-pan-rate")?.content).toMatchObject({
1447
+ flightId: 42,
1448
+ yawRate: 0,
1449
+ pitchRate: 0.5,
1450
+ });
1451
+ });
1452
+
1453
+ it("yaw and pitch axes compose -- setting one preserves the other", async () => {
1454
+ const { client, sidecar } = await buildConnectedSource();
1455
+
1456
+ await act(async () => {
1457
+ sidecar.updateCamera(42, PAN_FLIP);
1458
+ });
1459
+
1460
+ const handleRef = createRef<CameraFeedHandle>();
1461
+ renderFeed(client, { flightId: 42, ref: handleRef });
1462
+
1463
+ await act(async () => {
1464
+ handleRef.current?.setPanAxis("yaw", 1);
1465
+ });
1466
+ await act(async () => {
1467
+ handleRef.current?.setPanAxis("pitch", 0.5);
1468
+ });
1469
+
1470
+ expect(sidecar.lastCommand("set-pan-rate")?.content).toMatchObject({
1471
+ yawRate: 1,
1472
+ pitchRate: 0.5,
1473
+ });
1474
+ });
1475
+
1476
+ it("a tiny analog deflection inside the deadzone sends no command", async () => {
1477
+ const { client, sidecar } = await buildConnectedSource();
1478
+
1479
+ await act(async () => {
1480
+ sidecar.updateCamera(42, PAN_FLIP);
1481
+ });
1482
+
1483
+ const handleRef = createRef<CameraFeedHandle>();
1484
+ renderFeed(client, { flightId: 42, ref: handleRef });
1485
+
1486
+ // 0.02 < ANALOG_DEADZONE (0.05) -> snapped to 0 -> deduped against 0 -> no traffic.
1487
+ await act(async () => {
1488
+ handleRef.current?.setPanAxis("yaw", 0.02);
1489
+ });
1490
+
1491
+ expect(panRateMsgs(sidecar)).toHaveLength(0);
1492
+ });
1493
+
1494
+ it("handle.setPanAxis is a no-op when camera does not support pan", async () => {
1495
+ // Default camera has supportsPan: false.
1496
+ const { client, sidecar } = await buildConnectedSource();
1497
+
1498
+ const handleRef = createRef<CameraFeedHandle>();
1499
+ renderFeed(client, { flightId: 42, ref: handleRef });
1500
+
1501
+ await act(async () => {
1502
+ handleRef.current?.setPanAxis("yaw", 1);
1503
+ });
1504
+
1505
+ expect(panRateMsgs(sidecar)).toHaveLength(0);
1506
+ });
1507
+
1508
+ it("unmounting mid-pan stops the rate so the plugin doesn't run away", async () => {
1509
+ const { client, sidecar } = await buildConnectedSource();
1510
+
1511
+ await act(async () => {
1512
+ sidecar.updateCamera(42, PAN_FLIP);
1513
+ });
1514
+
1515
+ const handleRef = createRef<CameraFeedHandle>();
1516
+ const { unmount } = renderFeed(client, { flightId: 42, ref: handleRef });
1517
+
1518
+ await act(async () => {
1519
+ handleRef.current?.setPanAxis("yaw", 1);
1520
+ });
1521
+ expect(panRateMsgs(sidecar).at(-1)?.content.yawRate).toBe(1);
1522
+
1523
+ await act(async () => {
1524
+ unmount();
1525
+ });
1526
+ expect(panRateMsgs(sidecar).at(-1)?.content).toMatchObject({
1527
+ flightId: 42,
1528
+ yawRate: 0,
1529
+ pitchRate: 0,
1530
+ });
1531
+ });
1532
+ });
1533
+
1534
+ // ---------------------------------------------------------------------------
1535
+ // client prop override
1536
+ // ---------------------------------------------------------------------------
1537
+
1538
+ // ---------------------------------------------------------------------------
1539
+ // useStream injection seam
1540
+ // ---------------------------------------------------------------------------
1541
+
1542
+ describe("CameraFeed - useStream injection seam", () => {
1543
+ const THREE_CAMERAS = [
1544
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam", vesselName: "Kerbal X" }),
1545
+ makeCamera({ flightId: 43, cameraName: "Nose Cam", vesselName: "Kerbal X" }),
1546
+ makeCamera({ flightId: 44, cameraName: "Tail Cam", vesselName: "Kerbal X" }),
1547
+ ];
1548
+
1549
+ it("without useStream, the built-in stream binds to the video (regression)", async () => {
1550
+ const { client, sidecar } = await buildConnectedSource();
1551
+
1552
+ renderFeed(client, { flightId: 42 });
1553
+
1554
+ // Deliver a track so the built-in useKerbcastStream yields a MediaStream.
1555
+ const mid = sidecar.slotMidFor(42);
1556
+ expect(mid).toBeDefined();
1557
+ await act(async () => {
1558
+ sidecar.deliverTrack(mid as string, {} as MediaStreamTrack);
1559
+ });
1560
+
1561
+ const video = document.querySelector("video");
1562
+ expect(video?.srcObject).toBe(client.camera(42).mediaStream);
1563
+ });
1564
+
1565
+ it("routes the video through a custom useStream, called with the RESOLVED flightId", async () => {
1566
+ const { client } = await buildConnectedSource(THREE_CAMERAS);
1567
+
1568
+ const sentinel = {} as MediaStream;
1569
+ const seen: (number | null)[] = [];
1570
+ // Stable identity (declared once per test render tree), so React treats it
1571
+ // as the same hook every render — the rules-of-hooks contract the prop docs.
1572
+ const useCustom: CameraStreamHook = (flightId) => {
1573
+ seen.push(flightId);
1574
+ return flightId === 42 ? sentinel : null;
1575
+ };
1576
+
1577
+ render(
1578
+ <KerbcastProvider client={client}>
1579
+ {/* flightId=null: auto-latch must resolve to the first live camera (42)
1580
+ before the hook is called, proving it sees the resolved id. */}
1581
+ <CameraFeed flightId={null} useStream={useCustom} />
1582
+ </KerbcastProvider>,
1583
+ );
1584
+
1585
+ expect(screen.getByRole("heading", { name: "Starboard Cam" })).toBeTruthy();
1586
+ // Called with the resolved 42, never the requested null.
1587
+ expect(seen).toContain(42);
1588
+ expect(seen).not.toContain(null);
1589
+ // The stream the custom hook returned is what got bound.
1590
+ const video = document.querySelector("video");
1591
+ expect(video?.srcObject).toBe(sentinel);
1592
+ });
1593
+ });
1594
+
1595
+ describe("CameraFeed - client prop override", () => {
1596
+ it("accepts a client prop instead of a provider", async () => {
1597
+ const { client } = await buildConnectedSource([
1598
+ makeCamera({ flightId: 42, cameraName: "Override Cam", vesselName: "Test" }),
1599
+ ]);
1600
+
1601
+ // Render without a KerbcastProvider -- client prop provides it implicitly.
1602
+ render(<CameraFeed client={client} flightId={42} />);
1603
+
1604
+ await waitFor(() => {
1605
+ expect(screen.getByRole("heading", { name: "Override Cam" })).toBeTruthy();
1606
+ });
1607
+ });
1608
+ });