@ksp-gonogo/kerbcast-react 1.6.3 → 1.7.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.
package/dist/context.d.ts CHANGED
@@ -11,17 +11,38 @@ export interface KerbcastSubscriptions {
11
11
  }
12
12
  /** Build the default refcounted subscriptions implementation for a client. */
13
13
  export declare function createClientSubscriptions(client: KerbcastClient): KerbcastSubscriptions;
14
+ /**
15
+ * Collects each mounted feed's self-measured display size and drives the
16
+ * client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
17
+ * camera at once (e.g. a tiny avatar and a spotlight of the same kerbal); the
18
+ * sidecar keys the auto-resolution max per CONSUMER, so those would clobber
19
+ * each other last-writer-wins. This collapses them at the client edge: it
20
+ * keeps the MAX display size across every mounted reporter for a flightId and
21
+ * sends one report per max-change. Co-located with the subscription refcount
22
+ * (same per-flightId lifetime) so one object owns everything this client is
23
+ * doing with camera N.
24
+ */
25
+ export interface KerbcastDisplaySizes {
26
+ /** Register/update a reporter's measured size for a camera. */
27
+ report(flightId: number, instanceId: string, width: number, height: number): void;
28
+ /** Deregister a reporter (on unmount / flightId change). */
29
+ clear(flightId: number, instanceId: string): void;
30
+ }
31
+ /** Build the default per-flightId MAX display-size registry for a client. */
32
+ export declare function createClientDisplaySizes(client: KerbcastClient): KerbcastDisplaySizes;
14
33
  export interface KerbcastProviderProps {
15
34
  client: KerbcastClient;
16
35
  /** Override the subscription manager. Defaults to a refcounted per-client impl. */
17
36
  subscriptions?: KerbcastSubscriptions;
37
+ /** Override the display-size registry. Defaults to a per-flightId MAX impl. */
38
+ displaySizes?: KerbcastDisplaySizes;
18
39
  children: ReactNode;
19
40
  }
20
41
  /**
21
42
  * Provide a `KerbcastClient` (and optional subscriptions override) to the
22
43
  * component subtree. Hooks and `CameraFeed` read both from this context.
23
44
  */
24
- export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }: KerbcastProviderProps): React.JSX.Element;
45
+ export declare function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }: KerbcastProviderProps): React.JSX.Element;
25
46
  /**
26
47
  * Return the `KerbcastClient` from the nearest `KerbcastProvider`.
27
48
  * Throws if called outside a provider.
@@ -32,3 +53,8 @@ export declare function useKerbcastClient(): KerbcastClient;
32
53
  * Throws if called outside a provider.
33
54
  */
34
55
  export declare function useKerbcastSubscriptions(): KerbcastSubscriptions;
56
+ /**
57
+ * Return the `KerbcastDisplaySizes` registry from the nearest
58
+ * `KerbcastProvider`. Throws if called outside a provider.
59
+ */
60
+ export declare function useKerbcastDisplaySizes(): KerbcastDisplaySizes;
package/dist/context.js CHANGED
@@ -23,24 +23,79 @@ export function createClientSubscriptions(client) {
23
23
  },
24
24
  };
25
25
  }
26
+ /** Build the default per-flightId MAX display-size registry for a client. */
27
+ export function createClientDisplaySizes(client) {
28
+ // flightId -> (instanceId -> measured size)
29
+ const perCamera = new Map();
30
+ // Last size reported to the sidecar per flightId, so we send only on change.
31
+ const lastSent = new Map();
32
+ function recompute(flightId) {
33
+ const instances = perCamera.get(flightId);
34
+ if (!instances || instances.size === 0) {
35
+ // No reporters left. Drop bookkeeping and send nothing: the subscription
36
+ // release (unsubscribe -> sidecar remove_track -> forget_display_size)
37
+ // clears the sidecar side. Sending a spurious size here would fight it.
38
+ perCamera.delete(flightId);
39
+ lastSent.delete(flightId);
40
+ return;
41
+ }
42
+ let maxW = 0;
43
+ let maxH = 0;
44
+ for (const { w, h } of instances.values()) {
45
+ if (w > maxW)
46
+ maxW = w;
47
+ if (h > maxH)
48
+ maxH = h;
49
+ }
50
+ const prev = lastSent.get(flightId);
51
+ if (prev && prev.w === maxW && prev.h === maxH)
52
+ return;
53
+ lastSent.set(flightId, { w: maxW, h: maxH });
54
+ client.reportDisplaySize(flightId, maxW, maxH).catch(() => { });
55
+ }
56
+ return {
57
+ report(flightId, instanceId, width, height) {
58
+ let instances = perCamera.get(flightId);
59
+ if (!instances) {
60
+ instances = new Map();
61
+ perCamera.set(flightId, instances);
62
+ }
63
+ instances.set(instanceId, { w: width, h: height });
64
+ recompute(flightId);
65
+ },
66
+ clear(flightId, instanceId) {
67
+ const instances = perCamera.get(flightId);
68
+ if (!instances)
69
+ return;
70
+ instances.delete(instanceId);
71
+ recompute(flightId);
72
+ },
73
+ };
74
+ }
26
75
  const KerbcastContext = createContext(null);
27
76
  /**
28
77
  * Provide a `KerbcastClient` (and optional subscriptions override) to the
29
78
  * component subtree. Hooks and `CameraFeed` read both from this context.
30
79
  */
31
- export function KerbcastProvider({ client, subscriptions: subscriptionsProp, children, }) {
80
+ export function KerbcastProvider({ client, subscriptions: subscriptionsProp, displaySizes: displaySizesProp, children, }) {
32
81
  // Create default subscriptions once per client. A ref so the object is
33
82
  // stable across renders even if the parent re-renders.
34
83
  const defaultSubsRef = useRef(null);
35
84
  if (defaultSubsRef.current === null) {
36
85
  defaultSubsRef.current = createClientSubscriptions(client);
37
86
  }
87
+ // Same for the display-size registry: one per client, stable across renders.
88
+ const defaultSizesRef = useRef(null);
89
+ if (defaultSizesRef.current === null) {
90
+ defaultSizesRef.current = createClientDisplaySizes(client);
91
+ }
38
92
  const value = useMemo(() => ({
39
93
  client,
40
94
  subscriptions: subscriptionsProp ?? defaultSubsRef.current,
95
+ displaySizes: displaySizesProp ?? defaultSizesRef.current,
41
96
  }),
42
97
  // eslint-disable-next-line react-hooks/exhaustive-deps
43
- [client, subscriptionsProp]);
98
+ [client, subscriptionsProp, displaySizesProp]);
44
99
  return (_jsx(KerbcastContext.Provider, { value: value, children: children }));
45
100
  }
46
101
  // ---------------------------------------------------------------------------
@@ -68,3 +123,14 @@ export function useKerbcastSubscriptions() {
68
123
  }
69
124
  return ctx.subscriptions;
70
125
  }
126
+ /**
127
+ * Return the `KerbcastDisplaySizes` registry from the nearest
128
+ * `KerbcastProvider`. Throws if called outside a provider.
129
+ */
130
+ export function useKerbcastDisplaySizes() {
131
+ const ctx = useContext(KerbcastContext);
132
+ if (!ctx) {
133
+ throw new Error("useKerbcastDisplaySizes must be used inside a KerbcastProvider");
134
+ }
135
+ return ctx.displaySizes;
136
+ }
@@ -0,0 +1,23 @@
1
+ export interface ReportDisplaySizeOptions {
2
+ /**
3
+ * Report while mounted. Default true. Set false for a fixed-resolution feed
4
+ * that should not drive auto-resolution. This is a RESOLUTION concern only,
5
+ * independent of whether the feed shows its action UI.
6
+ */
7
+ enabled?: boolean;
8
+ /**
9
+ * Report a square (side x side, the min edge) rather than the raw w x h.
10
+ * Face cams are square; part cams are not.
11
+ */
12
+ square?: boolean;
13
+ }
14
+ /**
15
+ * Self-measure a feed element and drive the client's per-consumer
16
+ * `reportDisplaySize` (via the context display-size registry, which collapses
17
+ * multiple feeds of the same camera to one MAX report). Attach `ref` to the
18
+ * element whose rendered pixel box is the display size. Reporting follows the
19
+ * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
20
+ * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
21
+ * server-side clear.
22
+ */
23
+ export declare function useReportDisplaySize(flightId: number | null, ref: React.RefObject<Element | null>, { enabled, square }?: ReportDisplaySizeOptions): void;
@@ -0,0 +1,71 @@
1
+ import { useEffect, useId } from "react";
2
+ import { useKerbcastDisplaySizes } from "../context";
3
+ /*
4
+ * Debounce + bucket for self-measured display-size reporting. The first
5
+ * measurement after mount reports promptly (so a feed never sits at a stale
6
+ * resolution until it is resized); later churn is debounced. Sizes are ceiled
7
+ * to a BUCKET_PX multiple: that meets-the-minimum-need (never under-serve),
8
+ * yields an even value (H.264-friendly), and gives a material-change threshold
9
+ * so a feed re-reports only when a resize crosses a bucket boundary.
10
+ */
11
+ const DEBOUNCE_MS = 200;
12
+ const BUCKET_PX = 16;
13
+ function bucket(px) {
14
+ const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
15
+ return b < BUCKET_PX ? BUCKET_PX : b;
16
+ }
17
+ /**
18
+ * Self-measure a feed element and drive the client's per-consumer
19
+ * `reportDisplaySize` (via the context display-size registry, which collapses
20
+ * multiple feeds of the same camera to one MAX report). Attach `ref` to the
21
+ * element whose rendered pixel box is the display size. Reporting follows the
22
+ * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
23
+ * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
24
+ * server-side clear.
25
+ */
26
+ export function useReportDisplaySize(flightId, ref, { enabled = true, square = false } = {}) {
27
+ const displaySizes = useKerbcastDisplaySizes();
28
+ const instanceId = useId();
29
+ useEffect(() => {
30
+ if (flightId === null || !enabled)
31
+ return;
32
+ const el = ref.current;
33
+ if (!el)
34
+ return;
35
+ let timer = null;
36
+ let lastW = -1;
37
+ let lastH = -1;
38
+ let firstReported = false;
39
+ const report = (width, height) => {
40
+ const side = square ? Math.min(width, height) : 0;
41
+ const bw = bucket(square ? side : width);
42
+ const bh = bucket(square ? side : height);
43
+ if (bw === lastW && bh === lastH)
44
+ return;
45
+ lastW = bw;
46
+ lastH = bh;
47
+ displaySizes.report(flightId, instanceId, bw, bh);
48
+ };
49
+ const observer = new ResizeObserver((entries) => {
50
+ const entry = entries[0];
51
+ if (!entry)
52
+ return;
53
+ const { width, height } = entry.contentRect;
54
+ if (!firstReported) {
55
+ firstReported = true;
56
+ report(width, height);
57
+ return;
58
+ }
59
+ if (timer !== null)
60
+ clearTimeout(timer);
61
+ timer = setTimeout(() => report(width, height), DEBOUNCE_MS);
62
+ });
63
+ observer.observe(el);
64
+ return () => {
65
+ observer.disconnect();
66
+ if (timer !== null)
67
+ clearTimeout(timer);
68
+ displaySizes.clear(flightId, instanceId);
69
+ };
70
+ }, [displaySizes, flightId, ref, enabled, square, instanceId]);
71
+ }
package/dist/index.d.ts CHANGED
@@ -5,12 +5,13 @@
5
5
  * is a peer to @ksp-gonogo/kerbcast (the core SDK) and requires React 18+
6
6
  * and styled-components 6+ as peer dependencies.
7
7
  */
8
- export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
9
- export type { KerbcastProviderProps, KerbcastSubscriptions } from "./context";
8
+ export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
9
+ export type { KerbcastProviderProps, KerbcastSubscriptions, KerbcastDisplaySizes, } from "./context";
10
10
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
11
11
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
12
12
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
13
13
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
14
+ export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
14
15
  export { StandbyIcon } from "./StandbyIcon";
15
16
  export { buildCameraLabeler } from "./cameraLabels";
16
17
  export type { LabelableCamera } from "./cameraLabels";
@@ -18,3 +19,5 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
18
19
  export type { CameraLifecycle } from "./lifecycle";
19
20
  export { CameraFeed } from "./CameraFeed";
20
21
  export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
22
+ export { KerbalFaceFeed } from "./KerbalFaceFeed";
23
+ export type { KerbalFaceFeedProps, KerbalFeedState } from "./KerbalFaceFeed";
package/dist/index.js CHANGED
@@ -6,12 +6,13 @@
6
6
  * and styled-components 6+ as peer dependencies.
7
7
  */
8
8
  // Context and provider
9
- export { KerbcastProvider, createClientSubscriptions, useKerbcastClient, useKerbcastSubscriptions, } from "./context";
9
+ export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes, useKerbcastClient, useKerbcastSubscriptions, useKerbcastDisplaySizes, } from "./context";
10
10
  // Hooks
11
11
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
12
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
13
13
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
14
14
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
15
+ export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
15
16
  // Shared standby glyph (used by CameraFeed + downstream dashboards)
16
17
  export { StandbyIcon } from "./StandbyIcon";
17
18
  // Camera label utilities
@@ -20,3 +21,5 @@ export { buildCameraLabeler } from "./cameraLabels";
20
21
  export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
21
22
  // CameraFeed component
22
23
  export { CameraFeed } from "./CameraFeed";
24
+ // KerbalFaceFeed — shared single-kerbal-face primitive
25
+ export { KerbalFaceFeed } from "./KerbalFaceFeed";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "description": "React components and hooks for kerbcast camera feeds.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@ksp-gonogo/kerbcast": "^1.6.3"
48
+ "@ksp-gonogo/kerbcast": "^1.7.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -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 {
@@ -108,6 +108,7 @@ function toInit(c: CameraStateLike): MockCameraInit {
108
108
  degradeLevel: c.degradeLevel as number | undefined,
109
109
  viewerQuality: c.viewerQuality as QualityPreset | undefined,
110
110
  qualityLimitedBy: c.qualityLimitedBy as string | undefined,
111
+ trackMode: c.trackMode as TrackMode | undefined,
111
112
  };
112
113
  }
113
114
 
@@ -154,8 +155,10 @@ function renderFeed(
154
155
  showDebugInfo?: boolean;
155
156
  renderSize?: "auto" | "none";
156
157
  enableQualityControl?: boolean;
158
+ enableTracking?: boolean;
157
159
  showStatic?: boolean;
158
160
  showStandbyIcon?: boolean;
161
+ showActions?: boolean;
159
162
  ref?: React.Ref<CameraFeedHandle>;
160
163
  },
161
164
  ) {
@@ -1351,28 +1354,61 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
1351
1354
  resizeCallback = undefined;
1352
1355
  });
1353
1356
 
1354
- it("render-size observer fires set-render-size command", async () => {
1357
+ it("reports the measured display size (report-display-size, real w x h)", async () => {
1355
1358
  const { client, sidecar } = await buildConnectedSource();
1356
1359
 
1357
1360
  renderFeed(client, { flightId: 42 });
1358
1361
 
1362
+ // First measurement reports promptly (no debounce wait).
1359
1363
  await act(async () => {
1360
1364
  resizeCallback?.(
1361
- [{ contentRect: { width: 400, height: 400 } }] as ResizeObserverEntry[],
1365
+ [{ contentRect: { width: 400, height: 224 } }] as ResizeObserverEntry[],
1362
1366
  {} as ResizeObserver,
1363
1367
  );
1364
1368
  });
1365
1369
 
1366
- await act(async () => {
1367
- vi.advanceTimersByTime(501);
1368
- });
1370
+ const msg = sidecar.lastCommand("report-display-size");
1371
+ expect(msg).toBeTruthy();
1372
+ // Measured w x h (no synthetic 16:9 crop), bucketed to a 16-multiple:
1373
+ // 400 -> 400, 224 -> 224.
1374
+ expect(msg?.content.width).toBe(400);
1375
+ expect(msg?.content.height).toBe(224);
1376
+ // Measurement no longer drives the operator set-render-size (that path is
1377
+ // the manual quality cap only).
1378
+ expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
1379
+ });
1380
+
1381
+ it("does not report when renderSize is none", async () => {
1382
+ const { client, sidecar } = await buildConnectedSource();
1369
1383
 
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);
1384
+ renderFeed(client, { flightId: 42, renderSize: "none" });
1385
+
1386
+ // The observer is never armed when reporting is disabled.
1387
+ expect(resizeCallback).toBeUndefined();
1388
+ expect(sidecar.commands.some((c) => c.type === "report-display-size")).toBe(false);
1389
+ });
1390
+ });
1391
+
1392
+ // ---------------------------------------------------------------------------
1393
+ // showActions
1394
+ // ---------------------------------------------------------------------------
1395
+
1396
+ describe("CameraFeed - showActions", () => {
1397
+ it("renders the action bar by default", async () => {
1398
+ const { client } = await buildConnectedSource();
1399
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
1400
+ // The camera step buttons live in the action bar.
1401
+ expect(queryByLabelText("Next camera")).toBeTruthy();
1402
+ });
1403
+
1404
+ it("hides the action bar when showActions is false", async () => {
1405
+ const { client } = await buildConnectedSource();
1406
+ const { queryByLabelText } = renderFeed(client, {
1407
+ flightId: 42,
1408
+ showActions: false,
1409
+ });
1410
+ expect(queryByLabelText("Next camera")).toBeNull();
1411
+ expect(queryByLabelText("Previous camera")).toBeNull();
1376
1412
  });
1377
1413
  });
1378
1414
 
@@ -1682,3 +1718,131 @@ describe("CameraFeed - client prop override", () => {
1682
1718
  });
1683
1719
  });
1684
1720
  });
1721
+
1722
+ // ---------------------------------------------------------------------------
1723
+ // Tracking (crosshair) — auto-track a moving vessel, server-authoritative
1724
+ // ---------------------------------------------------------------------------
1725
+
1726
+ describe("CameraFeed - tracking (crosshair)", () => {
1727
+ function panZoomCam(overrides: Record<string, unknown> = {}) {
1728
+ return makeCamera({
1729
+ flightId: 42,
1730
+ cameraName: "Launchpad Cam",
1731
+ supportsPan: true,
1732
+ supportsZoom: true,
1733
+ panPitchMin: -90,
1734
+ panPitchMax: 90,
1735
+ ...overrides,
1736
+ });
1737
+ }
1738
+
1739
+ it("shows the crosshair on a pan+zoom camera when enableTracking", async () => {
1740
+ const { client } = await buildConnectedSource([panZoomCam()]);
1741
+ const { queryByLabelText } = renderFeed(client, {
1742
+ flightId: 42,
1743
+ enableTracking: true,
1744
+ });
1745
+ expect(queryByLabelText("Auto-track")).toBeTruthy();
1746
+ });
1747
+
1748
+ it("hides the crosshair without enableTracking", async () => {
1749
+ const { client } = await buildConnectedSource([panZoomCam()]);
1750
+ const { queryByLabelText } = renderFeed(client, { flightId: 42 });
1751
+ expect(queryByLabelText("Auto-track")).toBeNull();
1752
+ });
1753
+
1754
+ it("hides the crosshair when the camera lacks pan or zoom", async () => {
1755
+ const { client } = await buildConnectedSource([
1756
+ makeCamera({ flightId: 42, supportsPan: true, supportsZoom: false }),
1757
+ ]);
1758
+ const { queryByLabelText } = renderFeed(client, {
1759
+ flightId: 42,
1760
+ enableTracking: true,
1761
+ });
1762
+ expect(queryByLabelText("Auto-track")).toBeNull();
1763
+ });
1764
+
1765
+ it("clicking a mode sends set-track-target with that mode", async () => {
1766
+ const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
1767
+ const { getByLabelText } = renderFeed(client, {
1768
+ flightId: 42,
1769
+ enableTracking: true,
1770
+ });
1771
+ fireEvent.click(getByLabelText("Auto-track"));
1772
+ fireEvent.click(
1773
+ await screen.findByRole("menuitemradio", { name: "Track active vessel" }),
1774
+ );
1775
+ expect(sidecar.lastCommand("set-track-target", 42)?.content.mode).toBe(
1776
+ TrackMode.ActiveVessel,
1777
+ );
1778
+ });
1779
+
1780
+ it("highlight reflects the SERVER trackMode, never the click (not optimistic)", async () => {
1781
+ const { client, sidecar } = await buildConnectedSource([panZoomCam()]);
1782
+ // Suppress the echo so a click alone cannot flip the highlight; only a
1783
+ // real server state change may.
1784
+ const spy = vi.spyOn(client, "setTrackTarget").mockResolvedValue();
1785
+ const { getByLabelText } = renderFeed(client, {
1786
+ flightId: 42,
1787
+ enableTracking: true,
1788
+ });
1789
+ expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("false");
1790
+
1791
+ fireEvent.click(getByLabelText("Auto-track"));
1792
+ fireEvent.click(
1793
+ await screen.findByRole("menuitemradio", { name: "Track target" }),
1794
+ );
1795
+ expect(spy).toHaveBeenCalledWith(42, TrackMode.Target);
1796
+ // No echo yet -> highlight must stay off.
1797
+ expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("false");
1798
+
1799
+ // Server confirms -> highlight turns on.
1800
+ await act(async () => {
1801
+ sidecar.updateCamera(42, { trackMode: TrackMode.Target });
1802
+ });
1803
+ expect(getByLabelText("Auto-track").getAttribute("aria-pressed")).toBe("true");
1804
+ });
1805
+
1806
+ it("clicking the active mode deselects to none", async () => {
1807
+ const { client, sidecar } = await buildConnectedSource([
1808
+ panZoomCam({ trackMode: TrackMode.ActiveVessel }),
1809
+ ]);
1810
+ const { getByLabelText } = renderFeed(client, {
1811
+ flightId: 42,
1812
+ enableTracking: true,
1813
+ });
1814
+ fireEvent.click(getByLabelText("Auto-track"));
1815
+ fireEvent.click(
1816
+ await screen.findByRole("menuitemradio", { name: "Track active vessel" }),
1817
+ );
1818
+ expect(sidecar.lastCommand("set-track-target", 42)?.content.mode).toBe(
1819
+ TrackMode.None,
1820
+ );
1821
+ });
1822
+
1823
+ it("disables the manual pan + zoom controls while tracking", async () => {
1824
+ const { client } = await buildConnectedSource([
1825
+ panZoomCam({ trackMode: TrackMode.Target }),
1826
+ ]);
1827
+ const { getByLabelText } = renderFeed(client, {
1828
+ flightId: 42,
1829
+ enableTracking: true,
1830
+ });
1831
+ expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(true);
1832
+ expect((getByLabelText("Zoom out") as HTMLButtonElement).disabled).toBe(true);
1833
+ expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(true);
1834
+ expect((getByLabelText("Pan right") as HTMLButtonElement).disabled).toBe(true);
1835
+ });
1836
+
1837
+ it("keeps manual pan + zoom enabled when not tracking", async () => {
1838
+ const { client } = await buildConnectedSource([
1839
+ panZoomCam({ trackMode: TrackMode.None }),
1840
+ ]);
1841
+ const { getByLabelText } = renderFeed(client, {
1842
+ flightId: 42,
1843
+ enableTracking: true,
1844
+ });
1845
+ expect((getByLabelText("Zoom in") as HTMLButtonElement).disabled).toBe(false);
1846
+ expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(false);
1847
+ });
1848
+ });