@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
@@ -22,7 +22,7 @@
22
22
  * - Station (brokered) mode -- gonogo-only KerbcastDataSource.attachBroker.
23
23
  */
24
24
 
25
- import { KerbcastClient, QualityPreset } from "@ksp-gonogo/kerbcast";
25
+ import { KerbcastClient, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
26
26
  import type { CameraLifecycle, ClientMessage } from "@ksp-gonogo/kerbcast";
27
27
  import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
28
28
  import {
@@ -40,9 +40,125 @@ import {
40
40
  CameraFeed,
41
41
  type CameraFeedHandle,
42
42
  type CameraStreamHook,
43
+ type FeedAction,
43
44
  } from "./CameraFeed";
44
45
  import { KerbcastProvider } from "./context";
45
46
 
47
+ /*
48
+ * The REC control reads/drives `useRecordings()` (see CameraFeed - REC below).
49
+ * The real hook sits on top of a real `RecordingController`, which under
50
+ * jsdom needs a track-bearing MediaStream the noise-pipeline stub doesn't
51
+ * produce (`canvas.captureStream()` is stubbed empty). Stand in a fake store
52
+ * that mirrors the real hook's shape (its own per-component useState, so
53
+ * start/stop naturally re-render the owning CameraFeed exactly like the real
54
+ * hook does) so the REC tests exercise CameraFeed's wiring to the store's
55
+ * contract without depending on a real MediaRecorder track.
56
+ */
57
+ const recordingsStoreSpies = vi.hoisted(() => ({
58
+ start: vi.fn(),
59
+ stop: vi.fn(),
60
+ /** When true, the fake store's stop() rejects instead of resolving -- lets
61
+ * a test drive CameraFeed's stop-failure path (see "CameraFeed - REC"). */
62
+ stopRejects: false,
63
+ /** Flips a still-arming fake recording out of arming, simulating the real
64
+ * controller's arm-and-wait resolving once the feed reaches full
65
+ * resolution. Given its real implementation by the mock factory below
66
+ * (it needs the factory's own closured resolver map, unavailable at
67
+ * hoist time). */
68
+ resolveArming: (_recordingId: string) => {},
69
+ }));
70
+
71
+ vi.mock("./hooks/useRecordings", async () => {
72
+ const { useCallback, useState: useReactState } = await import("react");
73
+
74
+ interface FakeActive {
75
+ recordingId: string;
76
+ flightId: number;
77
+ startedAt: number;
78
+ arming: boolean;
79
+ }
80
+
81
+ /* recordingId -> the setState callback that flips that entry's arming off,
82
+ invoked by test code via recordingsStoreSpies.resolveArming. */
83
+ const armingResolvers = new Map<string, () => void>();
84
+ recordingsStoreSpies.resolveArming = (recordingId: string) => {
85
+ armingResolvers.get(recordingId)?.();
86
+ armingResolvers.delete(recordingId);
87
+ };
88
+
89
+ function useRecordings() {
90
+ const [state, setState] = useReactState<{
91
+ active: FakeActive[];
92
+ recordings: unknown[];
93
+ }>({ active: [], recordings: [] });
94
+
95
+ const start = useCallback(
96
+ (flightId: number, opts?: { forceFullResolution?: boolean }): string => {
97
+ recordingsStoreSpies.start(flightId, opts);
98
+ const recordingId = `rec-${flightId}`;
99
+ const forced = opts?.forceFullResolution === true;
100
+ setState((s) => ({
101
+ ...s,
102
+ active: [
103
+ ...s.active,
104
+ { recordingId, flightId, startedAt: performance.now(), arming: forced },
105
+ ],
106
+ }));
107
+ if (forced) {
108
+ armingResolvers.set(recordingId, () => {
109
+ setState((s) => ({
110
+ ...s,
111
+ active: s.active.map((a) =>
112
+ a.recordingId === recordingId
113
+ ? { ...a, arming: false, startedAt: performance.now() }
114
+ : a,
115
+ ),
116
+ }));
117
+ });
118
+ }
119
+ return recordingId;
120
+ },
121
+ [],
122
+ );
123
+
124
+ const stop = useCallback(async (recordingId: string) => {
125
+ recordingsStoreSpies.stop(recordingId);
126
+ armingResolvers.delete(recordingId);
127
+ if (recordingsStoreSpies.stopRejects) {
128
+ throw new Error("stop failed");
129
+ }
130
+ const handle = {
131
+ recordingId,
132
+ blob: new Blob(),
133
+ mimeType: "video/webm",
134
+ utSamples: [],
135
+ byteSize: 0,
136
+ durationMs: 0,
137
+ };
138
+ setState((s) => ({
139
+ active: s.active.filter((a) => a.recordingId !== recordingId),
140
+ recordings: [...s.recordings, handle],
141
+ }));
142
+ return handle;
143
+ }, []);
144
+
145
+ return {
146
+ recordings: state.recordings,
147
+ groups: [],
148
+ active: state.active,
149
+ isRecording: (flightId: number) => state.active.some((a) => a.flightId === flightId),
150
+ start,
151
+ stop,
152
+ startGroup: vi.fn(),
153
+ stopGroup: vi.fn(),
154
+ discard: vi.fn(),
155
+ discardGroup: vi.fn(),
156
+ };
157
+ }
158
+
159
+ return { useRecordings };
160
+ });
161
+
46
162
  // ---------------------------------------------------------------------------
47
163
  // Camera-state fixture factory
48
164
  // ---------------------------------------------------------------------------
@@ -108,6 +224,7 @@ function toInit(c: CameraStateLike): MockCameraInit {
108
224
  degradeLevel: c.degradeLevel as number | undefined,
109
225
  viewerQuality: c.viewerQuality as QualityPreset | undefined,
110
226
  qualityLimitedBy: c.qualityLimitedBy as string | undefined,
227
+ trackMode: c.trackMode as TrackMode | undefined,
111
228
  };
112
229
  }
113
230
 
@@ -154,8 +271,16 @@ function renderFeed(
154
271
  showDebugInfo?: boolean;
155
272
  renderSize?: "auto" | "none";
156
273
  enableQualityControl?: boolean;
274
+ enableTracking?: boolean;
275
+ enableRecording?: boolean;
276
+ recordFullResolution?: boolean;
277
+ enableFullscreen?: boolean;
278
+ enablePictureInPicture?: boolean;
279
+ actions?: FeedAction[];
280
+ trailingActions?: FeedAction[];
157
281
  showStatic?: boolean;
158
282
  showStandbyIcon?: boolean;
283
+ showActions?: boolean;
159
284
  ref?: React.Ref<CameraFeedHandle>;
160
285
  },
161
286
  ) {
@@ -213,6 +338,9 @@ afterEach(() => {
213
338
  }
214
339
  createdClients.length = 0;
215
340
  vi.restoreAllMocks();
341
+ recordingsStoreSpies.start.mockClear();
342
+ recordingsStoreSpies.stop.mockClear();
343
+ recordingsStoreSpies.stopRejects = false;
216
344
  });
217
345
 
218
346
  // ---------------------------------------------------------------------------
@@ -1351,28 +1479,105 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
1351
1479
  resizeCallback = undefined;
1352
1480
  });
1353
1481
 
1354
- it("render-size observer fires set-render-size command", async () => {
1482
+ it("reports the measured display size (report-display-size, real w x h)", async () => {
1355
1483
  const { client, sidecar } = await buildConnectedSource();
1356
1484
 
1357
1485
  renderFeed(client, { flightId: 42 });
1358
1486
 
1487
+ // First measurement reports promptly (no debounce wait).
1359
1488
  await act(async () => {
1360
1489
  resizeCallback?.(
1361
- [{ contentRect: { width: 400, height: 400 } }] as ResizeObserverEntry[],
1490
+ [{ contentRect: { width: 400, height: 224 } }] as ResizeObserverEntry[],
1362
1491
  {} as ResizeObserver,
1363
1492
  );
1364
1493
  });
1365
1494
 
1495
+ const msg = sidecar.lastCommand("report-display-size");
1496
+ expect(msg).toBeTruthy();
1497
+ // Measured w x h (no synthetic 16:9 crop), bucketed to a 16-multiple:
1498
+ // 400 -> 400, 224 -> 224.
1499
+ expect(msg?.content.width).toBe(400);
1500
+ expect(msg?.content.height).toBe(224);
1501
+ // Measurement no longer drives the operator set-render-size (that path is
1502
+ // the manual quality cap only).
1503
+ expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
1504
+ });
1505
+
1506
+ it("does not report when renderSize is none", async () => {
1507
+ const { client, sidecar } = await buildConnectedSource();
1508
+
1509
+ renderFeed(client, { flightId: 42, renderSize: "none" });
1510
+
1511
+ // The observer is never armed when reporting is disabled.
1512
+ expect(resizeCallback).toBeUndefined();
1513
+ expect(sidecar.commands.some((c) => c.type === "report-display-size")).toBe(false);
1514
+ });
1515
+ });
1516
+
1517
+ // ---------------------------------------------------------------------------
1518
+ // showActions
1519
+ // ---------------------------------------------------------------------------
1520
+
1521
+ describe("CameraFeed - showActions", () => {
1522
+ it("renders the action bar by default", async () => {
1523
+ const { client } = await buildConnectedSource();
1524
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
1525
+ // The camera step buttons live in the action bar.
1526
+ expect(queryByLabelText("Next camera")).toBeTruthy();
1527
+ });
1528
+
1529
+ it("hides the action bar when showActions is false", async () => {
1530
+ const { client } = await buildConnectedSource();
1531
+ const { queryByLabelText } = renderFeed(client, {
1532
+ flightId: 42,
1533
+ showActions: false,
1534
+ });
1535
+ expect(queryByLabelText("Next camera")).toBeNull();
1536
+ expect(queryByLabelText("Previous camera")).toBeNull();
1537
+ });
1538
+ });
1539
+
1540
+ // ---------------------------------------------------------------------------
1541
+ // Action-bar overflow threshold crossing -- never-remount regression
1542
+ // ---------------------------------------------------------------------------
1543
+
1544
+ describe("CameraFeed - action bar overflow threshold crossing", () => {
1545
+ it("never remounts the <video> element when the actions count crosses the overflow threshold", async () => {
1546
+ const { client } = await buildConnectedSource();
1547
+
1548
+ function Harness() {
1549
+ const [count, setCount] = useState(3);
1550
+ const actions = Array.from({ length: count }, (_, i) => ({
1551
+ id: `extra-${i}`,
1552
+ label: `Extra ${i}`,
1553
+ icon: <span>{i}</span>,
1554
+ onClick: () => {},
1555
+ }));
1556
+ return (
1557
+ <KerbcastProvider client={client}>
1558
+ <CameraFeed flightId={42} actions={actions} />
1559
+ <button type="button" onClick={() => setCount(4)}>
1560
+ grow
1561
+ </button>
1562
+ </KerbcastProvider>
1563
+ );
1564
+ }
1565
+
1566
+ const { container } = render(<Harness />);
1567
+ const before = container.querySelector("video");
1568
+ expect(before).toBeTruthy();
1569
+ // Below the threshold (3 total): every action is inline, no ⋮ trigger.
1570
+ expect(screen.queryByRole("button", { name: /more actions/i })).toBeNull();
1571
+
1366
1572
  await act(async () => {
1367
- vi.advanceTimersByTime(501);
1573
+ fireEvent.click(screen.getByRole("button", { name: /^grow$/i }));
1368
1574
  });
1369
1575
 
1370
- const renderSizeMsg = sidecar.lastCommand("set-render-size");
1371
- expect(renderSizeMsg).toBeTruthy();
1372
- // Width as-is (already even); height derived 16:9 then rounded to even px:
1373
- // 400 * 9/16 = 225 -> 226.
1374
- expect(renderSizeMsg?.content.width).toBe(400);
1375
- expect(renderSizeMsg?.content.height).toBe(226);
1576
+ // Crossed the threshold (4 total, all non-stateful >= 2): the ⋮ trigger
1577
+ // now appears -- but the SAME <video> node is still there, never remounted.
1578
+ expect(screen.getByRole("button", { name: /more actions/i })).toBeTruthy();
1579
+ const after = container.querySelector("video");
1580
+ expect(after).toBe(before);
1376
1581
  });
1377
1582
  });
1378
1583
 
@@ -1682,3 +1887,507 @@ describe("CameraFeed - client prop override", () => {
1682
1887
  });
1683
1888
  });
1684
1889
  });
1890
+
1891
+ // ---------------------------------------------------------------------------
1892
+ // Tracking (crosshair) — auto-track a moving vessel, server-authoritative
1893
+ // ---------------------------------------------------------------------------
1894
+
1895
+ describe("CameraFeed - tracking (crosshair)", () => {
1896
+ function panZoomCam(overrides: Record<string, unknown> = {}) {
1897
+ return makeCamera({
1898
+ flightId: 42,
1899
+ cameraName: "Launchpad Cam",
1900
+ supportsPan: true,
1901
+ supportsZoom: true,
1902
+ panPitchMin: -90,
1903
+ panPitchMax: 90,
1904
+ ...overrides,
1905
+ });
1906
+ }
1907
+
1908
+ it("shows the crosshair on a pan+zoom camera when enableTracking", async () => {
1909
+ const { client } = await buildConnectedSource([panZoomCam()]);
1910
+ const { queryByLabelText } = renderFeed(client, {
1911
+ flightId: 42,
1912
+ enableTracking: true,
1913
+ });
1914
+ expect(queryByLabelText("Auto-track")).toBeTruthy();
1915
+ });
1916
+
1917
+ it("hides the crosshair without enableTracking", async () => {
1918
+ const { client } = await buildConnectedSource([panZoomCam()]);
1919
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
1920
+ expect(queryByLabelText("Auto-track")).toBeNull();
1921
+ });
1922
+
1923
+ it("hides the crosshair when the camera lacks pan or zoom", async () => {
1924
+ const { client } = await buildConnectedSource([
1925
+ makeCamera({ flightId: 42, supportsPan: true, supportsZoom: false }),
1926
+ ]);
1927
+ const { queryByLabelText } = renderFeed(client, {
1928
+ flightId: 42,
1929
+ enableTracking: true,
1930
+ });
1931
+ expect(queryByLabelText("Auto-track")).toBeNull();
1932
+ });
1933
+
1934
+ it("clicking a mode sends set-track-target with that mode", async () => {
1935
+ const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
1936
+ const { getByLabelText } = renderFeed(client, {
1937
+ flightId: 42,
1938
+ enableTracking: true,
1939
+ });
1940
+ fireEvent.click(getByLabelText("Auto-track"));
1941
+ fireEvent.click(
1942
+ await screen.findByRole("menuitemradio", { name: "Track active vessel" }),
1943
+ );
1944
+ expect(sidecar.lastCommand("set-track-target", 42)?.content.mode).toBe(
1945
+ TrackMode.ActiveVessel,
1946
+ );
1947
+ });
1948
+
1949
+ it("highlight reflects the SERVER trackMode, never the click (not optimistic)", async () => {
1950
+ const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
1951
+ // Suppress the echo so a click alone cannot flip the highlight; only a
1952
+ // real server state change may.
1953
+ const spy = vi.spyOn(client, "setTrackTarget").mockResolvedValue();
1954
+ const { getByLabelText } = renderFeed(client, {
1955
+ flightId: 42,
1956
+ enableTracking: true,
1957
+ });
1958
+ expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("false");
1959
+
1960
+ fireEvent.click(getByLabelText("Auto-track"));
1961
+ fireEvent.click(
1962
+ await screen.findByRole("menuitemradio", { name: "Track target" }),
1963
+ );
1964
+ expect(spy).toHaveBeenCalledWith(42, TrackMode.Target);
1965
+ // No echo yet -> highlight must stay off.
1966
+ expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("false");
1967
+
1968
+ // Server confirms -> highlight turns on.
1969
+ await act(async () => {
1970
+ sidecar.updateCamera(42, { trackMode: TrackMode.Target });
1971
+ });
1972
+ expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("true");
1973
+ });
1974
+
1975
+ it("clicking the active mode deselects to none", async () => {
1976
+ const { client, sidecar } = await buildConnectedSource([
1977
+ panZoomCam({ trackMode: TrackMode.ActiveVessel }),
1978
+ ]);
1979
+ const { getByLabelText } = renderFeed(client, {
1980
+ flightId: 42,
1981
+ enableTracking: true,
1982
+ });
1983
+ fireEvent.click(getByLabelText("Auto-track"));
1984
+ fireEvent.click(
1985
+ await screen.findByRole("menuitemradio", { name: "Track active vessel" }),
1986
+ );
1987
+ expect(sidecar.lastCommand("set-track-target", 42)?.content.mode).toBe(
1988
+ TrackMode.None,
1989
+ );
1990
+ });
1991
+
1992
+ it("disables the manual pan + zoom controls while tracking", async () => {
1993
+ const { client } = await buildConnectedSource([
1994
+ panZoomCam({ trackMode: TrackMode.Target }),
1995
+ ]);
1996
+ const { getByLabelText } = renderFeed(client, {
1997
+ flightId: 42,
1998
+ enableTracking: true,
1999
+ });
2000
+ expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(true);
2001
+ expect((getByLabelText("Zoom out") as HTMLButtonElement).disabled).toBe(true);
2002
+ expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(true);
2003
+ expect((getByLabelText("Pan right") as HTMLButtonElement).disabled).toBe(true);
2004
+ });
2005
+
2006
+ it("keeps manual pan + zoom enabled when not tracking", async () => {
2007
+ const { client } = await buildConnectedSource([
2008
+ panZoomCam({ trackMode: TrackMode.None }),
2009
+ ]);
2010
+ const { getByLabelText } = renderFeed(client, {
2011
+ flightId: 42,
2012
+ enableTracking: true,
2013
+ });
2014
+ expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(false);
2015
+ expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(false);
2016
+ });
2017
+ });
2018
+
2019
+ // ---------------------------------------------------------------------------
2020
+ // REC (per-feed recording)
2021
+ // ---------------------------------------------------------------------------
2022
+
2023
+ describe("CameraFeed - REC", () => {
2024
+ it("does not render REC without enableRecording", async () => {
2025
+ const { client } = await buildConnectedSource();
2026
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
2027
+ expect(queryByLabelText("Start recording")).toBeNull();
2028
+ });
2029
+
2030
+ it("renders an idle REC toggle when enableRecording", async () => {
2031
+ const { client } = await buildConnectedSource();
2032
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2033
+ flightId: 42,
2034
+ enableRecording: true,
2035
+ });
2036
+ const rec = getByLabelText("Start recording");
2037
+ expect(rec.getAttribute("aria-pressed")).toBe("false");
2038
+ // No red-dot/timer badge while idle.
2039
+ expect(queryByLabelText("Recording")).toBeNull();
2040
+ });
2041
+
2042
+ it("clicking idle REC starts a recording via the store", async () => {
2043
+ const { client } = await buildConnectedSource();
2044
+ const { getByLabelText } = renderFeed(client, {
2045
+ flightId: 42,
2046
+ enableRecording: true,
2047
+ });
2048
+
2049
+ fireEvent.click(getByLabelText("Start recording"));
2050
+
2051
+ expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, { forceFullResolution: false });
2052
+ const rec = getByLabelText("Stop recording");
2053
+ expect(rec.getAttribute("aria-pressed")).toBe("true");
2054
+ expect(getByLabelText("Recording")).toBeTruthy();
2055
+ });
2056
+
2057
+ it("clicking active REC stops the recording via the store", async () => {
2058
+ const { client } = await buildConnectedSource();
2059
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2060
+ flightId: 42,
2061
+ enableRecording: true,
2062
+ });
2063
+
2064
+ fireEvent.click(getByLabelText("Start recording"));
2065
+ await act(async () => {
2066
+ fireEvent.click(getByLabelText("Stop recording"));
2067
+ });
2068
+
2069
+ expect(recordingsStoreSpies.stop).toHaveBeenCalledWith("rec-42");
2070
+ expect(getByLabelText("Start recording").getAttribute("aria-pressed")).toBe("false");
2071
+ expect(queryByLabelText("Recording")).toBeNull();
2072
+ });
2073
+
2074
+ it("does not leave an unhandled rejection when the store's stop() fails", async () => {
2075
+ recordingsStoreSpies.stopRejects = true;
2076
+ const { client } = await buildConnectedSource();
2077
+ const { getByLabelText } = renderFeed(client, {
2078
+ flightId: 42,
2079
+ enableRecording: true,
2080
+ });
2081
+
2082
+ fireEvent.click(getByLabelText("Start recording"));
2083
+ await act(async () => {
2084
+ fireEvent.click(getByLabelText("Stop recording"));
2085
+ await Promise.resolve();
2086
+ await Promise.resolve();
2087
+ });
2088
+
2089
+ /* Reaching here without vitest reporting an unhandled rejection is the
2090
+ assertion: toggleRecording's void recordings.stop(...) must catch. */
2091
+ expect(recordingsStoreSpies.stop).toHaveBeenCalledWith("rec-42");
2092
+ });
2093
+
2094
+ it("reads recording state from the store, not local state", async () => {
2095
+ // Two feeds on independent flightIds: starting one's recording must not
2096
+ // flip the other's REC control -- isRecording(flightId) is per-feed.
2097
+ const { client } = await buildConnectedSource([
2098
+ makeCamera({ flightId: 42, cameraName: "Cam A" }),
2099
+ makeCamera({ flightId: 43, cameraName: "Cam B" }),
2100
+ ]);
2101
+ renderFeed(client, { flightId: 42, enableRecording: true });
2102
+ renderFeed(client, { flightId: 43, enableRecording: true });
2103
+
2104
+ fireEvent.click(screen.getAllByLabelText("Start recording")[0]);
2105
+
2106
+ const stopButtons = screen.queryAllByLabelText("Stop recording");
2107
+ const idleButtons = screen.queryAllByLabelText("Start recording");
2108
+ expect(stopButtons).toHaveLength(1);
2109
+ expect(idleButtons).toHaveLength(1);
2110
+ });
2111
+
2112
+ it("shows a live elapsed timer while recording", async () => {
2113
+ vi.useFakeTimers({
2114
+ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"],
2115
+ });
2116
+ try {
2117
+ const { client } = await buildConnectedSource();
2118
+ const { getByLabelText } = renderFeed(client, {
2119
+ flightId: 42,
2120
+ enableRecording: true,
2121
+ });
2122
+
2123
+ act(() => {
2124
+ fireEvent.click(getByLabelText("Start recording"));
2125
+ });
2126
+ expect(getByLabelText("Recording").textContent).toBe("REC 0:00");
2127
+
2128
+ await act(async () => {
2129
+ await vi.advanceTimersByTimeAsync(3000);
2130
+ });
2131
+ expect(getByLabelText("Recording").textContent).toBe("REC 0:03");
2132
+ } finally {
2133
+ vi.useRealTimers();
2134
+ }
2135
+ });
2136
+
2137
+ it("REC is a stateful primary that never enters the ⋮ overflow", async () => {
2138
+ const { client } = await buildConnectedSource();
2139
+ const extraActions: FeedAction[] = Array.from({ length: 3 }, (_, i) => ({
2140
+ id: `extra-${i}`,
2141
+ label: `Extra ${i}`,
2142
+ icon: <span>{i}</span>,
2143
+ onClick: () => {},
2144
+ }));
2145
+ const { getByLabelText, getByRole } = renderFeed(client, {
2146
+ flightId: 42,
2147
+ enableRecording: true,
2148
+ actions: extraActions,
2149
+ });
2150
+
2151
+ // Crowded (>= 4 total, >= 2 non-stateful): the overflow trigger exists...
2152
+ expect(getByRole("button", { name: /more actions/i })).toBeTruthy();
2153
+ // ...but REC is directly queryable without opening it -- it never
2154
+ // collapsed into the menu.
2155
+ expect(getByLabelText("Start recording")).toBeTruthy();
2156
+ });
2157
+
2158
+ it("never remounts the <video> element across a start/stop cycle", async () => {
2159
+ const { client } = await buildConnectedSource();
2160
+ const { container, getByLabelText } = renderFeed(client, {
2161
+ flightId: 42,
2162
+ enableRecording: true,
2163
+ });
2164
+ const before = container.querySelector("video");
2165
+ expect(before).toBeTruthy();
2166
+
2167
+ fireEvent.click(getByLabelText("Start recording"));
2168
+ await act(async () => {
2169
+ fireEvent.click(getByLabelText("Stop recording"));
2170
+ });
2171
+
2172
+ const after = container.querySelector("video");
2173
+ expect(after).toBe(before);
2174
+ });
2175
+ });
2176
+
2177
+ describe("CameraFeed - recordFullResolution", () => {
2178
+ it("threads recordFullResolution: true into the store's start call", async () => {
2179
+ const { client } = await buildConnectedSource();
2180
+ const { getByLabelText } = renderFeed(client, {
2181
+ flightId: 42,
2182
+ enableRecording: true,
2183
+ recordFullResolution: true,
2184
+ });
2185
+
2186
+ fireEvent.click(getByLabelText("Start recording"));
2187
+
2188
+ expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, {
2189
+ forceFullResolution: true,
2190
+ });
2191
+ });
2192
+
2193
+ it("sends forceFullResolution: false when the prop is absent (default)", async () => {
2194
+ const { client } = await buildConnectedSource();
2195
+ const { getByLabelText } = renderFeed(client, {
2196
+ flightId: 42,
2197
+ enableRecording: true,
2198
+ });
2199
+
2200
+ fireEvent.click(getByLabelText("Start recording"));
2201
+
2202
+ expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, {
2203
+ forceFullResolution: false,
2204
+ });
2205
+ });
2206
+
2207
+ it("shows the ARMING pill instead of the REC badge while arming, then swaps to the REC badge", async () => {
2208
+ const { client } = await buildConnectedSource();
2209
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2210
+ flightId: 42,
2211
+ enableRecording: true,
2212
+ recordFullResolution: true,
2213
+ });
2214
+
2215
+ fireEvent.click(getByLabelText("Start recording"));
2216
+
2217
+ expect(getByLabelText("Arming").textContent).toBe("ARMING");
2218
+ expect(queryByLabelText("Recording")).toBeNull();
2219
+
2220
+ await act(async () => {
2221
+ recordingsStoreSpies.resolveArming("rec-42");
2222
+ });
2223
+
2224
+ expect(queryByLabelText("Arming")).toBeNull();
2225
+ expect(getByLabelText("Recording").textContent).toMatch(/^REC /);
2226
+ });
2227
+
2228
+ it("never remounts the <video> element across the arming -> recording transition", async () => {
2229
+ const { client } = await buildConnectedSource();
2230
+ const { container, getByLabelText } = renderFeed(client, {
2231
+ flightId: 42,
2232
+ enableRecording: true,
2233
+ recordFullResolution: true,
2234
+ });
2235
+ const before = container.querySelector("video");
2236
+ expect(before).toBeTruthy();
2237
+
2238
+ fireEvent.click(getByLabelText("Start recording"));
2239
+ expect(getByLabelText("Arming")).toBeTruthy();
2240
+
2241
+ await act(async () => {
2242
+ recordingsStoreSpies.resolveArming("rec-42");
2243
+ });
2244
+ expect(getByLabelText("Recording")).toBeTruthy();
2245
+
2246
+ const after = container.querySelector("video");
2247
+ expect(after).toBe(before);
2248
+ });
2249
+
2250
+ it("does not show the ARMING pill when recordFullResolution is false (default)", async () => {
2251
+ const { client } = await buildConnectedSource();
2252
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2253
+ flightId: 42,
2254
+ enableRecording: true,
2255
+ });
2256
+
2257
+ fireEvent.click(getByLabelText("Start recording"));
2258
+
2259
+ expect(queryByLabelText("Arming")).toBeNull();
2260
+ expect(getByLabelText("Recording")).toBeTruthy();
2261
+ });
2262
+ });
2263
+
2264
+ describe("CameraFeed - recording locks source and close", () => {
2265
+ it("disables the camera source dropdown while recording, with a hint, and re-enables on stop", async () => {
2266
+ const { client } = await buildConnectedSource();
2267
+ const { getByLabelText, getByRole } = renderFeed(client, {
2268
+ flightId: 42,
2269
+ enableRecording: true,
2270
+ });
2271
+
2272
+ const source = getByRole<HTMLButtonElement>("button", { name: /starboard cam/i });
2273
+ expect(source.disabled).toBe(false);
2274
+
2275
+ fireEvent.click(getByLabelText("Start recording"));
2276
+
2277
+ expect(source.disabled).toBe(true);
2278
+ expect(source.title).toBe("Stop recording to change camera");
2279
+
2280
+ await act(async () => {
2281
+ fireEvent.click(getByLabelText("Stop recording"));
2282
+ });
2283
+
2284
+ expect(source.disabled).toBe(false);
2285
+ });
2286
+
2287
+ it("does not open the camera menu when the source dropdown is clicked while recording", async () => {
2288
+ const { client } = await buildConnectedSource([
2289
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
2290
+ makeCamera({ flightId: 43, cameraName: "Nose Cam" }),
2291
+ ]);
2292
+ const { getByLabelText, getByRole, queryAllByRole } = renderFeed(client, {
2293
+ flightId: 42,
2294
+ enableRecording: true,
2295
+ });
2296
+
2297
+ fireEvent.click(getByLabelText("Start recording"));
2298
+ fireEvent.click(getByRole("button", { name: /starboard cam/i }));
2299
+
2300
+ expect(queryAllByRole("menuitemradio")).toHaveLength(0);
2301
+ });
2302
+
2303
+ it("disables the pinned trailing close control while recording and re-enables on stop", async () => {
2304
+ const onClose = vi.fn();
2305
+ const { client } = await buildConnectedSource();
2306
+ const { getByLabelText } = renderFeed(client, {
2307
+ flightId: 42,
2308
+ enableRecording: true,
2309
+ trailingActions: [
2310
+ { id: "close", label: "Close", icon: <span>x</span>, onClick: onClose },
2311
+ ],
2312
+ });
2313
+
2314
+ const close = getByLabelText("Close") as HTMLButtonElement;
2315
+ expect(close.disabled).toBe(false);
2316
+
2317
+ fireEvent.click(getByLabelText("Start recording"));
2318
+
2319
+ expect(close.disabled).toBe(true);
2320
+ expect(close.title).toBe("Stop recording to close this feed");
2321
+ fireEvent.click(close);
2322
+ expect(onClose).not.toHaveBeenCalled();
2323
+
2324
+ await act(async () => {
2325
+ fireEvent.click(getByLabelText("Stop recording"));
2326
+ });
2327
+
2328
+ expect(close.disabled).toBe(false);
2329
+ fireEvent.click(close);
2330
+ expect(onClose).toHaveBeenCalledTimes(1);
2331
+ });
2332
+
2333
+ it("keeps tracking, PiP, fullscreen, and the REC(stop) button enabled while recording", async () => {
2334
+ const fsDoc = document as Document & { fullscreenEnabled?: boolean };
2335
+ const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
2336
+ Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
2337
+ Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
2338
+
2339
+ try {
2340
+ const { client } = await buildConnectedSource([
2341
+ makeCamera({
2342
+ flightId: 42,
2343
+ cameraName: "Launchpad Cam",
2344
+ supportsPan: true,
2345
+ supportsZoom: true,
2346
+ panPitchMin: -90,
2347
+ panPitchMax: 90,
2348
+ }),
2349
+ ]);
2350
+ const { getByLabelText, queryByLabelText } = renderFeed(client, {
2351
+ flightId: 42,
2352
+ enableRecording: true,
2353
+ enableTracking: true,
2354
+ enableFullscreen: true,
2355
+ enablePictureInPicture: true,
2356
+ });
2357
+
2358
+ fireEvent.click(getByLabelText("Start recording"));
2359
+
2360
+ // Tracking + REC are stateful primaries, always inline; PiP + fullscreen
2361
+ // (non-stateful) collapse into the ⋮ overflow once the row is crowded
2362
+ // (REC + tracking + PiP + fullscreen == 4), so open it to reach them.
2363
+ const overflowTrigger = queryByLabelText("More actions");
2364
+ if (overflowTrigger) fireEvent.click(overflowTrigger);
2365
+
2366
+ expect((getByLabelText("Auto-track") as HTMLButtonElement).disabled).toBe(false);
2367
+ expect((getByLabelText("Picture in picture") as HTMLButtonElement).disabled).toBe(false);
2368
+ expect((getByLabelText("Enter fullscreen") as HTMLButtonElement).disabled).toBe(false);
2369
+ expect((getByLabelText("Stop recording") as HTMLButtonElement).disabled).toBe(false);
2370
+ } finally {
2371
+ delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
2372
+ delete (pipDoc as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
2373
+ }
2374
+ });
2375
+
2376
+ it("leaves the source dropdown and close control enabled when not recording", async () => {
2377
+ const onClose = vi.fn();
2378
+ const { client } = await buildConnectedSource();
2379
+ const { getByLabelText, getByRole } = renderFeed(client, {
2380
+ flightId: 42,
2381
+ enableRecording: true,
2382
+ trailingActions: [
2383
+ { id: "close", label: "Close", icon: <span>x</span>, onClick: onClose },
2384
+ ],
2385
+ });
2386
+
2387
+ expect(getByRole<HTMLButtonElement>("button", { name: /starboard cam/i }).disabled).toBe(
2388
+ false,
2389
+ );
2390
+ fireEvent.click(getByLabelText("Close"));
2391
+ expect(onClose).toHaveBeenCalledTimes(1);
2392
+ });
2393
+ });