@ksp-gonogo/kerbcast-react 1.10.2 → 1.11.0-rc.10

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.
@@ -22,8 +22,19 @@
22
22
  * - Station (brokered) mode -- gonogo-only KerbcastDataSource.attachBroker.
23
23
  */
24
24
 
25
- import { KerbcastClient, QualityPreset, TrackMode } from "@ksp-gonogo/kerbcast";
26
- import type { CameraLifecycle, ClientMessage } from "@ksp-gonogo/kerbcast";
25
+ import {
26
+ CameraKind,
27
+ CommandTargetEligibility,
28
+ KerbcastClient,
29
+ QualityPreset,
30
+ TrackMode,
31
+ } from "@ksp-gonogo/kerbcast";
32
+ import type {
33
+ CameraLifecycle,
34
+ ClientMessage,
35
+ CommandCentreInfo,
36
+ CommandTrackState,
37
+ } from "@ksp-gonogo/kerbcast";
27
38
  import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
28
39
  import {
29
40
  act,
@@ -33,16 +44,18 @@ import {
33
44
  screen,
34
45
  waitFor,
35
46
  } from "@testing-library/react";
36
- import { createRef } from "react";
47
+ import { createRef, useEffect } from "react";
37
48
  import { useState } from "react";
38
49
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
39
50
  import {
40
51
  CameraFeed,
41
52
  type CameraFeedHandle,
53
+ type CameraFeedProps,
42
54
  type CameraStreamHook,
43
55
  type FeedAction,
44
56
  } from "./CameraFeed";
45
57
  import { KerbcastProvider } from "./context";
58
+ import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
46
59
 
47
60
  /*
48
61
  * The REC control reads/drives `useRecordings()` (see CameraFeed - REC below).
@@ -208,6 +221,8 @@ function toInit(c: CameraStateLike): MockCameraInit {
208
221
  renderHeight: c.renderHeight as number | undefined,
209
222
  operatorWidth: c.operatorWidth as number | undefined,
210
223
  operatorHeight: c.operatorHeight as number | undefined,
224
+ maxWidth: c.maxWidth as number | undefined,
225
+ maxHeight: c.maxHeight as number | undefined,
211
226
  supportsZoom: c.supportsZoom as boolean | undefined,
212
227
  fov: c.fov as number | undefined,
213
228
  fovMin: c.fovMin as number | undefined,
@@ -225,6 +240,9 @@ function toInit(c: CameraStateLike): MockCameraInit {
225
240
  viewerQuality: c.viewerQuality as QualityPreset | undefined,
226
241
  qualityLimitedBy: c.qualityLimitedBy as string | undefined,
227
242
  trackMode: c.trackMode as TrackMode | undefined,
243
+ kind: c.kind as CameraKind | undefined,
244
+ commandCentre: c.commandCentre as CommandCentreInfo | undefined,
245
+ commandTrack: c.commandTrack as CommandTrackState | undefined,
228
246
  };
229
247
  }
230
248
 
@@ -282,6 +300,9 @@ function renderFeed(
282
300
  showStatic?: boolean;
283
301
  showStandbyIcon?: boolean;
284
302
  showActions?: boolean;
303
+ enableTargetBrackets?: boolean;
304
+ targetBracketOverlay?: CameraFeedProps["targetBracketOverlay"];
305
+ displayUt?: number;
285
306
  ref?: React.Ref<CameraFeedHandle>;
286
307
  },
287
308
  ) {
@@ -1480,8 +1501,10 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
1480
1501
  resizeCallback = undefined;
1481
1502
  });
1482
1503
 
1483
- it("reports the measured display size (report-display-size, real w x h)", async () => {
1484
- const { client, sidecar } = await buildConnectedSource();
1504
+ it("reports the measured display size at 16:9 (report-display-size)", async () => {
1505
+ const { client, sidecar } = await buildConnectedSource([
1506
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam", maxWidth: 1280, maxHeight: 720 }),
1507
+ ]);
1485
1508
 
1486
1509
  renderFeed(client, { flightId: 42 });
1487
1510
 
@@ -1495,15 +1518,45 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
1495
1518
 
1496
1519
  const msg = sidecar.lastCommand("report-display-size");
1497
1520
  expect(msg).toBeTruthy();
1498
- // Measured w x h (no synthetic 16:9 crop), bucketed to a 16-multiple:
1499
- // 400 -> 400, 224 -> 224.
1521
+ // The 16:9 picture that fits 400x224 is ~398 wide: bucketed to 400, height
1522
+ // 225 rounded to even.
1500
1523
  expect(msg?.content.width).toBe(400);
1501
- expect(msg?.content.height).toBe(224);
1524
+ expect(msg?.content.height).toBe(226);
1502
1525
  // Measurement no longer drives the operator set-render-size (that path is
1503
1526
  // the manual quality cap only).
1504
1527
  expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
1505
1528
  });
1506
1529
 
1530
+ it("asks for the camera's configured shape, not the box's, and gives the feed that shape", async () => {
1531
+ const { client, sidecar } = await buildConnectedSource([
1532
+ makeCamera({ flightId: 42, cameraName: "Starboard Cam", maxWidth: 1024, maxHeight: 768 }),
1533
+ ]);
1534
+
1535
+ const { container } = renderFeed(client, { flightId: 42 });
1536
+
1537
+ await act(async () => {
1538
+ resizeCallback?.(
1539
+ [{ contentRect: { width: 400, height: 224 } }] as ResizeObserverEntry[],
1540
+ {} as ResizeObserver,
1541
+ );
1542
+ });
1543
+
1544
+ // The 4:3 picture that fits 400x224 is ~299 wide: 304x228.
1545
+ const msg = sidecar.lastCommand("report-display-size");
1546
+ expect(msg?.content).toMatchObject({ width: 304, height: 228 });
1547
+ const box = container.firstElementChild as HTMLElement;
1548
+ expect(box.style.getPropertyValue("--kc-feed-w")).toBe("1024");
1549
+ expect(box.style.getPropertyValue("--kc-feed-h")).toBe("768");
1550
+ });
1551
+
1552
+ it("falls back to 16:9 while no configured size is known", async () => {
1553
+ const { client } = await buildConnectedSource([]);
1554
+ const { container } = renderFeed(client, { flightId: null });
1555
+ const box = container.firstElementChild as HTMLElement;
1556
+ expect(box.style.getPropertyValue("--kc-feed-w")).toBe("16");
1557
+ expect(box.style.getPropertyValue("--kc-feed-h")).toBe("9");
1558
+ });
1559
+
1507
1560
  it("does not report when renderSize is none", async () => {
1508
1561
  const { client, sidecar } = await buildConnectedSource();
1509
1562
 
@@ -1515,6 +1568,212 @@ describe("CameraFeed - ResizeObserver render-size feedback", () => {
1515
1568
  });
1516
1569
  });
1517
1570
 
1571
+ // ---------------------------------------------------------------------------
1572
+ // Render size in a narrow, tall tile, with a frame sized from the stream
1573
+ // ---------------------------------------------------------------------------
1574
+
1575
+ describe("CameraFeed - render size in a narrow, tall tile", () => {
1576
+ const PHONE_HALF_TILE = { width: 170, height: 400 };
1577
+ let tile = PHONE_HALF_TILE;
1578
+ let watched: Array<{ el: Element; cb: ResizeObserverCallback; last: string | null }> = [];
1579
+ const originalResizeObserver = globalThis.ResizeObserver;
1580
+
1581
+ /* An element sized in px by its own style has that size, anything inside it fills it, anything else is the tile. */
1582
+ function boxOf(el: Element): { width: number; height: number } {
1583
+ for (let at: Element | null = el; at; at = at.parentElement) {
1584
+ const style = (at as HTMLElement).style;
1585
+ if (style?.width.endsWith("px") && style.height.endsWith("px")) {
1586
+ return { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
1587
+ }
1588
+ }
1589
+ return tile;
1590
+ }
1591
+
1592
+ function layout() {
1593
+ for (const w of watched) {
1594
+ const box = boxOf(w.el);
1595
+ const key = `${box.width}x${box.height}`;
1596
+ if (key === w.last) continue;
1597
+ w.last = key;
1598
+ w.cb(
1599
+ [{ target: w.el, contentRect: box as DOMRectReadOnly } as ResizeObserverEntry],
1600
+ {} as ResizeObserver,
1601
+ );
1602
+ }
1603
+ }
1604
+
1605
+ async function run(ms: number) {
1606
+ for (let t = 0; t < ms; t += 16) {
1607
+ await act(async () => {
1608
+ vi.advanceTimersByTime(16);
1609
+ });
1610
+ act(() => layout());
1611
+ }
1612
+ }
1613
+
1614
+ beforeEach(() => {
1615
+ vi.useFakeTimers();
1616
+ tile = PHONE_HALF_TILE;
1617
+ watched = [];
1618
+ globalThis.ResizeObserver = class {
1619
+ private readonly cb: ResizeObserverCallback;
1620
+ constructor(cb: ResizeObserverCallback) {
1621
+ this.cb = cb;
1622
+ }
1623
+ observe(el: Element) {
1624
+ watched.push({ el, cb: this.cb, last: null });
1625
+ }
1626
+ unobserve() {}
1627
+ disconnect() {
1628
+ watched = watched.filter((w) => w.cb !== this.cb);
1629
+ }
1630
+ } as unknown as typeof ResizeObserver;
1631
+ });
1632
+
1633
+ afterEach(() => {
1634
+ vi.useRealTimers();
1635
+ globalThis.ResizeObserver = originalResizeObserver;
1636
+ });
1637
+
1638
+ /*
1639
+ * The natural way to keep a video's shape: a frame whose height comes from
1640
+ * the playing video's size, or the camera's render size before it plays.
1641
+ */
1642
+ function StreamShapedFrame({ client }: { client: KerbcastClient }) {
1643
+ const [root, setRoot] = useState<HTMLDivElement | null>(null);
1644
+ const [, bump] = useState(0);
1645
+ useEffect(() => {
1646
+ if (!root) return;
1647
+ const sync = () => bump((n) => n + 1);
1648
+ root.addEventListener("resize", sync, true);
1649
+ root.addEventListener("loadedmetadata", sync, true);
1650
+ return () => {
1651
+ root.removeEventListener("resize", sync, true);
1652
+ root.removeEventListener("loadedmetadata", sync, true);
1653
+ };
1654
+ }, [root]);
1655
+ return (
1656
+ <KerbcastProvider client={client}>
1657
+ <Shaped root={root} setRoot={setRoot} />
1658
+ </KerbcastProvider>
1659
+ );
1660
+ }
1661
+
1662
+ function Shaped({
1663
+ root,
1664
+ setRoot,
1665
+ }: {
1666
+ root: HTMLDivElement | null;
1667
+ setRoot: (el: HTMLDivElement | null) => void;
1668
+ }) {
1669
+ const camera = useKerbcastCameras().find((c) => c.flightId === 42);
1670
+ const video = root?.querySelector("video");
1671
+ const aspect =
1672
+ video && video.videoWidth > 0
1673
+ ? video.videoWidth / video.videoHeight
1674
+ : camera
1675
+ ? camera.renderWidth / camera.renderHeight
1676
+ : 16 / 9;
1677
+ const width = Math.min(tile.width, tile.height * aspect);
1678
+ return (
1679
+ <div
1680
+ ref={setRoot}
1681
+ style={{ width: `${Math.floor(width)}px`, height: `${Math.floor(width / aspect)}px` }}
1682
+ >
1683
+ <CameraFeed flightId={42} />
1684
+ </div>
1685
+ );
1686
+ }
1687
+
1688
+ async function connect() {
1689
+ return buildConnectedSource([
1690
+ makeCamera({
1691
+ flightId: 42,
1692
+ cameraName: "Starboard Cam",
1693
+ renderWidth: 1280,
1694
+ renderHeight: 720,
1695
+ maxWidth: 1280,
1696
+ maxHeight: 720,
1697
+ }),
1698
+ ]);
1699
+ }
1700
+
1701
+ function sizeReports(sidecar: MockSidecar) {
1702
+ return sidecar.commands
1703
+ .filter((c) => c.type === "report-display-size")
1704
+ .map((c) => (c as { content: { flightId: number; width: number; height: number } }).content);
1705
+ }
1706
+
1707
+ async function mountShaped() {
1708
+ const { client, sidecar } = await connect();
1709
+ const view = render(<StreamShapedFrame client={client} />);
1710
+ await run(500);
1711
+ const video = view.container.querySelector("video");
1712
+ if (!video) throw new Error("no video element");
1713
+ return { sidecar, video, view, client };
1714
+ }
1715
+
1716
+ async function streamBecomes(
1717
+ sidecar: MockSidecar,
1718
+ video: HTMLVideoElement,
1719
+ width: number,
1720
+ height: number,
1721
+ ) {
1722
+ Object.defineProperty(video, "videoWidth", { value: width, configurable: true });
1723
+ Object.defineProperty(video, "videoHeight", { value: height, configurable: true });
1724
+ await act(async () => {
1725
+ sidecar.updateCamera(42, { renderWidth: width, renderHeight: height });
1726
+ video.dispatchEvent(new Event("loadedmetadata"));
1727
+ video.dispatchEvent(new Event("resize"));
1728
+ });
1729
+ await run(300);
1730
+ }
1731
+
1732
+ it("asks a layout-sized tile for one size at the camera's shape", async () => {
1733
+ const { client, sidecar } = await connect();
1734
+ renderFeed(client, { flightId: 42 });
1735
+ await run(500);
1736
+ expect(sizeReports(sidecar)).toEqual([{ flightId: 42, width: 176, height: 100 }]);
1737
+ });
1738
+
1739
+ it("sends no request when the stream comes back smaller, larger or another shape", async () => {
1740
+ const { sidecar, video } = await mountShaped();
1741
+ const before = sizeReports(sidecar).length;
1742
+ for (const [w, h] of [
1743
+ [96, 54],
1744
+ [1920, 1080],
1745
+ [160, 120],
1746
+ [176, 400],
1747
+ ] as const) {
1748
+ await streamBecomes(sidecar, video, w, h);
1749
+ await run(2000);
1750
+ }
1751
+ expect(sizeReports(sidecar).length - before).toBe(0);
1752
+ });
1753
+
1754
+ it("sends nothing while the tile holds still and the stream flips between two sizes", async () => {
1755
+ const { sidecar, video } = await mountShaped();
1756
+ const before = sizeReports(sidecar);
1757
+ for (let i = 0; i < 6; i++) {
1758
+ await streamBecomes(sidecar, video, 176, i % 2 ? 128 : 96);
1759
+ }
1760
+ await run(3000);
1761
+ expect(sizeReports(sidecar)).toEqual(before);
1762
+ });
1763
+
1764
+ it("sends exactly one request, sized from the tile, for a resize dragged across several sizes", async () => {
1765
+ const { sidecar, view, client } = await mountShaped();
1766
+ const before = sizeReports(sidecar).length;
1767
+ for (const width of [240, 280, 320]) {
1768
+ tile = { width, height: 400 };
1769
+ view.rerender(<StreamShapedFrame client={client} />);
1770
+ act(() => layout());
1771
+ }
1772
+ await run(1000);
1773
+ expect(sizeReports(sidecar).slice(before)).toEqual([{ flightId: 42, width: 320, height: 180 }]);
1774
+ });
1775
+ });
1776
+
1518
1777
  // ---------------------------------------------------------------------------
1519
1778
  // showActions
1520
1779
  // ---------------------------------------------------------------------------
@@ -2483,3 +2742,274 @@ describe("CameraFeed - recording locks source and close", () => {
2483
2742
  expect(onClose).toHaveBeenCalledTimes(1);
2484
2743
  });
2485
2744
  });
2745
+
2746
+ // ---------------------------------------------------------------------------
2747
+ // Command camera target brackets
2748
+ // ---------------------------------------------------------------------------
2749
+
2750
+ describe("CameraFeed - command camera target brackets", () => {
2751
+ const STATION: CommandCentreInfo = {
2752
+ stationId: "ground:KSC",
2753
+ nodeName: "KSC",
2754
+ displayName: "Kerbal Space Center",
2755
+ body: "Kerbin",
2756
+ latitude: -0.1,
2757
+ longitude: -74.6,
2758
+ altitude: 95,
2759
+ subjectPersistentId: 0,
2760
+ trackingLevel: 3,
2761
+ targets: [],
2762
+ };
2763
+
2764
+ function commandTrack(overrides: Partial<CommandTrackState> = {}): CommandTrackState {
2765
+ return {
2766
+ ut: 1000,
2767
+ hasFix: true,
2768
+ rangeM: 11_507_905,
2769
+ occluded: false,
2770
+ occluder: "",
2771
+ sunlit: true,
2772
+ bracketHalfPx: 4,
2773
+ frameHeightPx: 576,
2774
+ framingFits: true,
2775
+ events: [],
2776
+ ...overrides,
2777
+ };
2778
+ }
2779
+
2780
+ function commandCamera(track: CommandTrackState | undefined = commandTrack()) {
2781
+ return makeCamera({
2782
+ flightId: 301,
2783
+ kind: CameraKind.Command,
2784
+ cameraName: "KSC tracking",
2785
+ commandCentre: STATION,
2786
+ commandTrack: track,
2787
+ });
2788
+ }
2789
+
2790
+ beforeEach(() => localStorage.clear());
2791
+
2792
+ it("shows the brackets readout and a pressed toggle on a command feed", async () => {
2793
+ const { client } = await buildConnectedSource([commandCamera()]);
2794
+ renderFeed(client, { flightId: 301 });
2795
+ expect(screen.getByText("IN VIEW · 11,508 km")).toBeTruthy();
2796
+ expect(
2797
+ screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
2798
+ ).toBe("true");
2799
+ });
2800
+
2801
+ it("offers no toggle on a part feed", async () => {
2802
+ const { client } = await buildConnectedSource();
2803
+ renderFeed(client, { flightId: 42 });
2804
+ expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
2805
+ });
2806
+
2807
+ it("hides the brackets when toggled off and remembers it per camera", async () => {
2808
+ const { client } = await buildConnectedSource([commandCamera()]);
2809
+ const first = renderFeed(client, { flightId: 301 });
2810
+ fireEvent.click(screen.getByRole("button", { name: "Target brackets" }));
2811
+ expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2812
+ expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBe("0");
2813
+ first.unmount();
2814
+
2815
+ renderFeed(client, { flightId: 301 });
2816
+ expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2817
+ expect(
2818
+ screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
2819
+ ).toBe("false");
2820
+ });
2821
+
2822
+ it("still labels a blanked feed with the brackets toggled off", async () => {
2823
+ localStorage.setItem("kerbcast:target-brackets:ground:KSC", "0");
2824
+ const { client, sidecar } = await buildConnectedSource([commandCamera()]);
2825
+ renderFeed(client, { flightId: 301 });
2826
+ expect(screen.queryByRole("status", { name: "Feed blanked" })).toBeNull();
2827
+ await act(async () => {
2828
+ sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for gonogo" }) });
2829
+ });
2830
+ expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
2831
+ "Waiting for gonogo",
2832
+ );
2833
+ });
2834
+
2835
+ it("shows the transition in force at the displayed UT", async () => {
2836
+ const track = commandTrack({
2837
+ authority: { owner: "gonogo" },
2838
+ events: [
2839
+ { ut: 990, occluded: true, occluder: "Mun", sunlit: true },
2840
+ { ut: 999, occluded: false, occluder: "", sunlit: true },
2841
+ ],
2842
+ });
2843
+ const { client } = await buildConnectedSource([commandCamera(track)]);
2844
+ const view = renderFeed(client, { flightId: 301, displayUt: 995 });
2845
+ expect(screen.getByText("OCCLUDED MUN · 11,508 km")).toBeTruthy();
2846
+ view.rerender(
2847
+ <KerbcastProvider client={client}>
2848
+ <CameraFeed flightId={301} displayUt={999.5} />
2849
+ </KerbcastProvider>,
2850
+ );
2851
+ expect(screen.getByText("IN VIEW · 11,508 km")).toBeTruthy();
2852
+ });
2853
+
2854
+ it("passes the target bracket overlay through to the brackets", async () => {
2855
+ const { client } = await buildConnectedSource([commandCamera()]);
2856
+ const seenUts: number[] = [];
2857
+ const { container } = renderFeed(client, {
2858
+ flightId: 301,
2859
+ displayUt: 1060,
2860
+ targetBracketOverlay: {
2861
+ render: (ctx) => {
2862
+ seenUts.push(ctx.displayUt);
2863
+ return <circle data-testid="overlay-mark" cx={ctx.geometry.cx} cy={ctx.geometry.cy} r={2} />;
2864
+ },
2865
+ corners: { bottomRight: { hidden: true } },
2866
+ readout: (text, ctx) => `${text} · T+${ctx.displayUt}`,
2867
+ },
2868
+ });
2869
+ expect(screen.getByTestId("overlay-mark")).toBeTruthy();
2870
+ expect(seenUts).toContain(1060);
2871
+ expect(container.querySelector('path[data-corner="bottomRight"]')).toBeNull();
2872
+ expect(container.querySelector('path[data-corner="topLeft"]')).not.toBeNull();
2873
+ expect(screen.getByText(/ · T\+1060$/)).toBeTruthy();
2874
+ });
2875
+
2876
+ describe("target picker", () => {
2877
+ const TARGETS: CommandCentreInfo = {
2878
+ ...STATION,
2879
+ subjectPersistentId: 11,
2880
+ rangeM: 12_300,
2881
+ trackingLevel: 1,
2882
+ targets: [
2883
+ {
2884
+ persistentId: 11,
2885
+ vesselName: "Kerbal X",
2886
+ vesselType: "Ship",
2887
+ rangeM: 12_300,
2888
+ eligibility: CommandTargetEligibility.Ok,
2889
+ eligibilityText: "Trackable",
2890
+ },
2891
+ {
2892
+ persistentId: 22,
2893
+ vesselName: "Relay 1",
2894
+ vesselType: "Relay",
2895
+ rangeM: 1_500_000,
2896
+ eligibility: CommandTargetEligibility.Ok,
2897
+ eligibilityText: "Trackable",
2898
+ },
2899
+ {
2900
+ persistentId: 33,
2901
+ vesselName: "Ast. HSJ-227",
2902
+ vesselType: "SpaceObject",
2903
+ eligibility: CommandTargetEligibility.LevelTooLow,
2904
+ eligibilityText: "Upgrade the Tracking Station",
2905
+ },
2906
+ ],
2907
+ };
2908
+
2909
+ function targetCamera() {
2910
+ return makeCamera({
2911
+ flightId: 301,
2912
+ kind: CameraKind.Command,
2913
+ cameraName: "KSC tracking",
2914
+ commandCentre: TARGETS,
2915
+ commandTrack: commandTrack(),
2916
+ });
2917
+ }
2918
+
2919
+ it("lists targets, marks the current one and disables ineligible ones", async () => {
2920
+ const { client } = await buildConnectedSource([targetCamera()]);
2921
+ renderFeed(client, { flightId: 301 });
2922
+ const trigger = screen.getByRole("button", { name: "Choose target" });
2923
+ expect(trigger.getAttribute("title")).toBe("Watching Kerbal X");
2924
+ fireEvent.click(trigger);
2925
+ const items = screen.getAllByRole("menuitemradio");
2926
+ expect(items.map((i) => i.textContent)).toEqual([
2927
+ "Kerbal X · 12 km",
2928
+ "Relay 1 · 1,500 km",
2929
+ "Ast. HSJ-227 · Upgrade the Tracking Station",
2930
+ ]);
2931
+ expect(items[0]!.getAttribute("aria-checked")).toBe("true");
2932
+ expect(items[1]!.getAttribute("aria-checked")).toBe("false");
2933
+ expect((items[2] as HTMLButtonElement).disabled).toBe(true);
2934
+ expect(items[2]!.getAttribute("title")).toBe("Upgrade the Tracking Station");
2935
+ });
2936
+
2937
+ it("keeps the quality menu reachable when the row overflows", async () => {
2938
+ const { client } = await buildConnectedSource([targetCamera()]);
2939
+ renderFeed(client, {
2940
+ flightId: 301,
2941
+ enableQualityControl: true,
2942
+ enableFullscreen: true,
2943
+ enablePictureInPicture: true,
2944
+ actions: [
2945
+ { id: "a", label: "Extra A", icon: null, onClick: () => {} },
2946
+ { id: "b", label: "Extra B", icon: null, onClick: () => {} },
2947
+ ],
2948
+ });
2949
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
2950
+ expect(screen.getByRole("menu", { name: "Quality" })).toBeTruthy();
2951
+ });
2952
+
2953
+ it("keeps the target menu reachable when the row overflows", async () => {
2954
+ const { client } = await buildConnectedSource([targetCamera()]);
2955
+ renderFeed(client, {
2956
+ flightId: 301,
2957
+ enableFullscreen: true,
2958
+ enablePictureInPicture: true,
2959
+ actions: [
2960
+ { id: "a", label: "Extra A", icon: null, onClick: () => {} },
2961
+ { id: "b", label: "Extra B", icon: null, onClick: () => {} },
2962
+ ],
2963
+ });
2964
+ fireEvent.click(screen.getByRole("button", { name: "Choose target" }));
2965
+ expect(screen.getAllByRole("menuitemradio")).toHaveLength(3);
2966
+ });
2967
+
2968
+ it("sends the choice and follows the published target", async () => {
2969
+ const { client, sidecar } = await buildConnectedSource([targetCamera()]);
2970
+ renderFeed(client, { flightId: 301 });
2971
+ fireEvent.click(screen.getByRole("button", { name: "Choose target" }));
2972
+ await act(async () => {
2973
+ fireEvent.click(screen.getByRole("menuitemradio", { name: /Relay 1/ }));
2974
+ });
2975
+ expect(sidecar.lastCommand("set-command-aim", 301)?.content).toEqual({
2976
+ flightId: 301,
2977
+ aim: { kind: "vessel", persistentId: 22 },
2978
+ });
2979
+ expect(
2980
+ screen.getByRole("button", { name: "Choose target" }).getAttribute("title"),
2981
+ ).toBe("Watching Relay 1");
2982
+ });
2983
+
2984
+ it("is hidden with no targets, with manual controls disabled or under the lock", async () => {
2985
+ const { client } = await buildConnectedSource([targetCamera()]);
2986
+ const view = renderFeed(client, { flightId: 301, disableManualControls: true });
2987
+ expect(screen.queryByRole("button", { name: "Choose target" })).toBeNull();
2988
+ view.unmount();
2989
+
2990
+ const locked = await buildConnectedSource([
2991
+ makeCamera({
2992
+ flightId: 301,
2993
+ kind: CameraKind.Command,
2994
+ cameraName: "KSC tracking",
2995
+ commandCentre: TARGETS,
2996
+ commandTrack: commandTrack({ authority: { owner: "gonogo" } }),
2997
+ }),
2998
+ ]);
2999
+ const lockedView = renderFeed(locked.client, { flightId: 301 });
3000
+ expect(screen.queryByRole("button", { name: "Choose target" })).toBeNull();
3001
+ lockedView.unmount();
3002
+
3003
+ const empty = await buildConnectedSource([commandCamera()]);
3004
+ renderFeed(empty.client, { flightId: 301 });
3005
+ expect(screen.queryByRole("button", { name: "Choose target" })).toBeNull();
3006
+ });
3007
+ });
3008
+
3009
+ it("draws nothing with enableTargetBrackets off", async () => {
3010
+ const { client } = await buildConnectedSource([commandCamera()]);
3011
+ renderFeed(client, { flightId: 301, enableTargetBrackets: false });
3012
+ expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
3013
+ expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
3014
+ });
3015
+ });