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

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,11 @@
1
+ import type { DisplayedFrameUt } from "@ksp-gonogo/kerbcast";
2
+ /**
3
+ * Mission time of the frame `video` is showing for camera `flightId`,
4
+ * updated on every presented frame (so the calling component re-renders at
5
+ * the video's frame rate). `source` says whether the UT is the frame's own
6
+ * capture stamp (`"exact"`) or the capture clock extrapolated
7
+ * (`"estimated"`). `null` until the first frame with a known UT, and while
8
+ * either argument is null. For per-frame work without re-rendering, use
9
+ * `client.watchDisplayedFrames` directly.
10
+ */
11
+ export declare function useDisplayedFrameUt(flightId: number | null, video: HTMLVideoElement | null): DisplayedFrameUt | null;
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useKerbcastClient } from "../context";
3
+ /**
4
+ * Mission time of the frame `video` is showing for camera `flightId`,
5
+ * updated on every presented frame (so the calling component re-renders at
6
+ * the video's frame rate). `source` says whether the UT is the frame's own
7
+ * capture stamp (`"exact"`) or the capture clock extrapolated
8
+ * (`"estimated"`). `null` until the first frame with a known UT, and while
9
+ * either argument is null. For per-frame work without re-rendering, use
10
+ * `client.watchDisplayedFrames` directly.
11
+ */
12
+ export function useDisplayedFrameUt(flightId, video) {
13
+ const client = useKerbcastClient();
14
+ const [frame, setFrame] = useState(null);
15
+ useEffect(() => {
16
+ setFrame(null);
17
+ if (flightId === null || !video)
18
+ return;
19
+ return client.watchDisplayedFrames(flightId, video, setFrame);
20
+ }, [client, flightId, video]);
21
+ return frame;
22
+ }
@@ -1,3 +1,8 @@
1
+ /** Report size for a `width` x `height` box: bucketed width, height at the box's aspect. */
2
+ export declare function displayReportSize(width: number, height: number): {
3
+ width: number;
4
+ height: number;
5
+ };
1
6
  export interface ReportDisplaySizeOptions {
2
7
  /**
3
8
  * Report while mounted. Default true. Set false for a fixed-resolution feed
@@ -3,10 +3,12 @@ import { useKerbcastDisplaySizes } from "../context";
3
3
  /*
4
4
  * Debounce + bucket for self-measured display-size reporting. The first
5
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.
6
+ * resolution until it is resized); later churn is debounced. The width is
7
+ * ceiled to a BUCKET_PX multiple: that meets-the-minimum-need (never
8
+ * under-serve) and gives a material-change threshold so a feed re-reports
9
+ * only when a resize crosses a bucket boundary. The height follows from the
10
+ * box's own aspect, rounded to even for H.264, so the frame fills the box
11
+ * instead of letterboxing inside it.
10
12
  */
11
13
  const DEBOUNCE_MS = 200;
12
14
  const BUCKET_PX = 16;
@@ -14,6 +16,14 @@ function bucket(px) {
14
16
  const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
15
17
  return b < BUCKET_PX ? BUCKET_PX : b;
16
18
  }
19
+ /** Report size for a `width` x `height` box: bucketed width, height at the box's aspect. */
20
+ export function displayReportSize(width, height) {
21
+ const w = bucket(width);
22
+ if (!(width > 0) || !(height > 0))
23
+ return { width: w, height: bucket(height) };
24
+ const h = Math.max(2, Math.round((w * height) / width / 2) * 2);
25
+ return { width: w, height: h };
26
+ }
17
27
  /**
18
28
  * Self-measure a feed element and drive the client's per-consumer
19
29
  * `reportDisplaySize` (via the context display-size registry, which collapses
@@ -48,9 +58,10 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
48
58
  let lastH = -1;
49
59
  let firstReported = false;
50
60
  const report = (width, height) => {
51
- const side = square ? Math.min(width, height) : 0;
52
- const bw = bucket(square ? side : width);
53
- const bh = bucket(square ? side : height);
61
+ const side = bucket(Math.min(width, height));
62
+ const { width: bw, height: bh } = square
63
+ ? { width: side, height: side }
64
+ : displayReportSize(width, height);
54
65
  if (bw === lastW && bh === lastH)
55
66
  return;
56
67
  lastW = bw;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Whether target brackets are shown for one command camera, remembered per
3
+ * camera in this browser. Defaults on. `key` is null for a feed with no
4
+ * command camera, which reads as on and ignores toggles.
5
+ */
6
+ export declare function useTargetBracketsPreference(key: string | null): [boolean, () => void];
@@ -0,0 +1,39 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ const STORAGE_PREFIX = "kerbcast:target-brackets:";
3
+ function readPreference(key) {
4
+ try {
5
+ return globalThis.localStorage?.getItem(STORAGE_PREFIX + key) !== "0";
6
+ }
7
+ catch {
8
+ return true;
9
+ }
10
+ }
11
+ function writePreference(key, on) {
12
+ try {
13
+ if (on)
14
+ globalThis.localStorage?.removeItem(STORAGE_PREFIX + key);
15
+ else
16
+ globalThis.localStorage?.setItem(STORAGE_PREFIX + key, "0");
17
+ }
18
+ catch {
19
+ /* Storage blocked or full: the toggle still works for this session. */
20
+ }
21
+ }
22
+ /**
23
+ * Whether target brackets are shown for one command camera, remembered per
24
+ * camera in this browser. Defaults on. `key` is null for a feed with no
25
+ * command camera, which reads as on and ignores toggles.
26
+ */
27
+ export function useTargetBracketsPreference(key) {
28
+ const [overrides, setOverrides] = useState({});
29
+ const stored = useMemo(() => (key === null ? true : readPreference(key)), [key]);
30
+ const on = key !== null && key in overrides ? overrides[key] : stored;
31
+ const toggle = useCallback(() => {
32
+ if (key === null)
33
+ return;
34
+ const next = !on;
35
+ writePreference(key, next);
36
+ setOverrides((o) => ({ ...o, [key]: next }));
37
+ }, [key, on]);
38
+ return [on, toggle];
39
+ }
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export type { KerbcastProviderProps, KerbcastSubscriptions, KerbcastDisplaySizes
10
10
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
11
11
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
12
12
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
13
+ export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
13
14
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
14
15
  export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
15
16
  export { useRecordings } from "./hooks/useRecordings";
@@ -22,6 +23,8 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
22
23
  export type { CameraLifecycle } from "./lifecycle";
23
24
  export { CameraFeed } from "./CameraFeed";
24
25
  export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
26
+ export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
27
+ export type { BracketGeometry, BracketPoint, CommandBracketContext, CommandBracketCorner, CommandBracketCornerOverride, CommandBracketCornerOverrides, CommandBracketCornerStyle, CommandBracketOverlay, ContentRect, } from "./CommandBrackets";
25
28
  export { FeedActionBar } from "./FeedActionBar";
26
29
  export type { FeedActionBarEntry } from "./FeedActionBar";
27
30
  export { KerbalFaceFeed } from "./KerbalFaceFeed";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes,
11
11
  export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
12
  export { useKerbcastStream } from "./hooks/useKerbcastStream";
13
13
  export { useKerbcastClock } from "./hooks/useKerbcastClock";
14
+ export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
14
15
  export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
15
16
  export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
17
  export { useRecordings } from "./hooks/useRecordings";
@@ -24,6 +25,8 @@ export { buildCameraLabeler } from "./cameraLabels";
24
25
  export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
25
26
  // CameraFeed component
26
27
  export { CameraFeed } from "./CameraFeed";
28
+ // Target brackets geometry and the overlay API for drawing on top of them
29
+ export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
27
30
  // FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
28
31
  // close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
29
32
  // overflow (⋮) engine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.10.2",
3
+ "version": "1.11.0-rc.4",
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.10.2"
48
+ "@ksp-gonogo/kerbcast": "^1.11.0-rc.4"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",
@@ -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,
@@ -39,6 +50,7 @@ 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";
@@ -225,6 +237,9 @@ function toInit(c: CameraStateLike): MockCameraInit {
225
237
  viewerQuality: c.viewerQuality as QualityPreset | undefined,
226
238
  qualityLimitedBy: c.qualityLimitedBy as string | undefined,
227
239
  trackMode: c.trackMode as TrackMode | undefined,
240
+ kind: c.kind as CameraKind | undefined,
241
+ commandCentre: c.commandCentre as CommandCentreInfo | undefined,
242
+ commandTrack: c.commandTrack as CommandTrackState | undefined,
228
243
  };
229
244
  }
230
245
 
@@ -282,6 +297,9 @@ function renderFeed(
282
297
  showStatic?: boolean;
283
298
  showStandbyIcon?: boolean;
284
299
  showActions?: boolean;
300
+ enableTargetBrackets?: boolean;
301
+ targetBracketOverlay?: CameraFeedProps["targetBracketOverlay"];
302
+ displayUt?: number;
285
303
  ref?: React.Ref<CameraFeedHandle>;
286
304
  },
287
305
  ) {
@@ -2483,3 +2501,274 @@ describe("CameraFeed - recording locks source and close", () => {
2483
2501
  expect(onClose).toHaveBeenCalledTimes(1);
2484
2502
  });
2485
2503
  });
2504
+
2505
+ // ---------------------------------------------------------------------------
2506
+ // Command camera target brackets
2507
+ // ---------------------------------------------------------------------------
2508
+
2509
+ describe("CameraFeed - command camera target brackets", () => {
2510
+ const STATION: CommandCentreInfo = {
2511
+ stationId: "ground:KSC",
2512
+ nodeName: "KSC",
2513
+ displayName: "Kerbal Space Center",
2514
+ body: "Kerbin",
2515
+ latitude: -0.1,
2516
+ longitude: -74.6,
2517
+ altitude: 95,
2518
+ subjectPersistentId: 0,
2519
+ trackingLevel: 3,
2520
+ targets: [],
2521
+ };
2522
+
2523
+ function commandTrack(overrides: Partial<CommandTrackState> = {}): CommandTrackState {
2524
+ return {
2525
+ ut: 1000,
2526
+ hasFix: true,
2527
+ rangeM: 11_507_905,
2528
+ occluded: false,
2529
+ occluder: "",
2530
+ sunlit: true,
2531
+ bracketHalfPx: 4,
2532
+ frameHeightPx: 576,
2533
+ framingFits: true,
2534
+ events: [],
2535
+ ...overrides,
2536
+ };
2537
+ }
2538
+
2539
+ function commandCamera(track: CommandTrackState | undefined = commandTrack()) {
2540
+ return makeCamera({
2541
+ flightId: 301,
2542
+ kind: CameraKind.Command,
2543
+ cameraName: "KSC tracking",
2544
+ commandCentre: STATION,
2545
+ commandTrack: track,
2546
+ });
2547
+ }
2548
+
2549
+ beforeEach(() => localStorage.clear());
2550
+
2551
+ it("shows the brackets readout and a pressed toggle on a command feed", async () => {
2552
+ const { client } = await buildConnectedSource([commandCamera()]);
2553
+ renderFeed(client, { flightId: 301 });
2554
+ expect(screen.getByText("IN VIEW · 11,508 km")).toBeTruthy();
2555
+ expect(
2556
+ screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
2557
+ ).toBe("true");
2558
+ });
2559
+
2560
+ it("offers no toggle on a part feed", async () => {
2561
+ const { client } = await buildConnectedSource();
2562
+ renderFeed(client, { flightId: 42 });
2563
+ expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
2564
+ });
2565
+
2566
+ it("hides the brackets when toggled off and remembers it per camera", async () => {
2567
+ const { client } = await buildConnectedSource([commandCamera()]);
2568
+ const first = renderFeed(client, { flightId: 301 });
2569
+ fireEvent.click(screen.getByRole("button", { name: "Target brackets" }));
2570
+ expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2571
+ expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBe("0");
2572
+ first.unmount();
2573
+
2574
+ renderFeed(client, { flightId: 301 });
2575
+ expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2576
+ expect(
2577
+ screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
2578
+ ).toBe("false");
2579
+ });
2580
+
2581
+ it("still labels a blanked feed with the brackets toggled off", async () => {
2582
+ localStorage.setItem("kerbcast:target-brackets:ground:KSC", "0");
2583
+ const { client, sidecar } = await buildConnectedSource([commandCamera()]);
2584
+ renderFeed(client, { flightId: 301 });
2585
+ expect(screen.queryByRole("status", { name: "Feed blanked" })).toBeNull();
2586
+ await act(async () => {
2587
+ sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for gonogo" }) });
2588
+ });
2589
+ expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
2590
+ "Waiting for gonogo",
2591
+ );
2592
+ });
2593
+
2594
+ it("shows the transition in force at the displayed UT", async () => {
2595
+ const track = commandTrack({
2596
+ authority: { owner: "gonogo" },
2597
+ events: [
2598
+ { ut: 990, occluded: true, occluder: "Mun", sunlit: true },
2599
+ { ut: 999, occluded: false, occluder: "", sunlit: true },
2600
+ ],
2601
+ });
2602
+ const { client } = await buildConnectedSource([commandCamera(track)]);
2603
+ const view = renderFeed(client, { flightId: 301, displayUt: 995 });
2604
+ expect(screen.getByText("OCCLUDED MUN · 11,508 km")).toBeTruthy();
2605
+ view.rerender(
2606
+ <KerbcastProvider client={client}>
2607
+ <CameraFeed flightId={301} displayUt={999.5} />
2608
+ </KerbcastProvider>,
2609
+ );
2610
+ expect(screen.getByText("IN VIEW · 11,508 km")).toBeTruthy();
2611
+ });
2612
+
2613
+ it("passes the target bracket overlay through to the brackets", async () => {
2614
+ const { client } = await buildConnectedSource([commandCamera()]);
2615
+ const seenUts: number[] = [];
2616
+ const { container } = renderFeed(client, {
2617
+ flightId: 301,
2618
+ displayUt: 1060,
2619
+ targetBracketOverlay: {
2620
+ render: (ctx) => {
2621
+ seenUts.push(ctx.displayUt);
2622
+ return <circle data-testid="overlay-mark" cx={ctx.geometry.cx} cy={ctx.geometry.cy} r={2} />;
2623
+ },
2624
+ corners: { bottomRight: { hidden: true } },
2625
+ readout: (text, ctx) => `${text} · T+${ctx.displayUt}`,
2626
+ },
2627
+ });
2628
+ expect(screen.getByTestId("overlay-mark")).toBeTruthy();
2629
+ expect(seenUts).toContain(1060);
2630
+ expect(container.querySelector('path[data-corner="bottomRight"]')).toBeNull();
2631
+ expect(container.querySelector('path[data-corner="topLeft"]')).not.toBeNull();
2632
+ expect(screen.getByText(/ · T\+1060$/)).toBeTruthy();
2633
+ });
2634
+
2635
+ describe("target picker", () => {
2636
+ const TARGETS: CommandCentreInfo = {
2637
+ ...STATION,
2638
+ subjectPersistentId: 11,
2639
+ rangeM: 12_300,
2640
+ trackingLevel: 1,
2641
+ targets: [
2642
+ {
2643
+ persistentId: 11,
2644
+ vesselName: "Kerbal X",
2645
+ vesselType: "Ship",
2646
+ rangeM: 12_300,
2647
+ eligibility: CommandTargetEligibility.Ok,
2648
+ eligibilityText: "Trackable",
2649
+ },
2650
+ {
2651
+ persistentId: 22,
2652
+ vesselName: "Relay 1",
2653
+ vesselType: "Relay",
2654
+ rangeM: 1_500_000,
2655
+ eligibility: CommandTargetEligibility.Ok,
2656
+ eligibilityText: "Trackable",
2657
+ },
2658
+ {
2659
+ persistentId: 33,
2660
+ vesselName: "Ast. HSJ-227",
2661
+ vesselType: "SpaceObject",
2662
+ eligibility: CommandTargetEligibility.LevelTooLow,
2663
+ eligibilityText: "Upgrade the Tracking Station",
2664
+ },
2665
+ ],
2666
+ };
2667
+
2668
+ function targetCamera() {
2669
+ return makeCamera({
2670
+ flightId: 301,
2671
+ kind: CameraKind.Command,
2672
+ cameraName: "KSC tracking",
2673
+ commandCentre: TARGETS,
2674
+ commandTrack: commandTrack(),
2675
+ });
2676
+ }
2677
+
2678
+ it("lists targets, marks the current one and disables ineligible ones", async () => {
2679
+ const { client } = await buildConnectedSource([targetCamera()]);
2680
+ renderFeed(client, { flightId: 301 });
2681
+ const trigger = screen.getByRole("button", { name: "Choose target" });
2682
+ expect(trigger.getAttribute("title")).toBe("Watching Kerbal X");
2683
+ fireEvent.click(trigger);
2684
+ const items = screen.getAllByRole("menuitemradio");
2685
+ expect(items.map((i) => i.textContent)).toEqual([
2686
+ "Kerbal X · 12 km",
2687
+ "Relay 1 · 1,500 km",
2688
+ "Ast. HSJ-227 · Upgrade the Tracking Station",
2689
+ ]);
2690
+ expect(items[0]!.getAttribute("aria-checked")).toBe("true");
2691
+ expect(items[1]!.getAttribute("aria-checked")).toBe("false");
2692
+ expect((items[2] as HTMLButtonElement).disabled).toBe(true);
2693
+ expect(items[2]!.getAttribute("title")).toBe("Upgrade the Tracking Station");
2694
+ });
2695
+
2696
+ it("keeps the quality menu reachable when the row overflows", async () => {
2697
+ const { client } = await buildConnectedSource([targetCamera()]);
2698
+ renderFeed(client, {
2699
+ flightId: 301,
2700
+ enableQualityControl: true,
2701
+ enableFullscreen: true,
2702
+ enablePictureInPicture: true,
2703
+ actions: [
2704
+ { id: "a", label: "Extra A", icon: null, onClick: () => {} },
2705
+ { id: "b", label: "Extra B", icon: null, onClick: () => {} },
2706
+ ],
2707
+ });
2708
+ fireEvent.click(screen.getByRole("button", { name: /quality/i }));
2709
+ expect(screen.getByRole("menu", { name: "Quality" })).toBeTruthy();
2710
+ });
2711
+
2712
+ it("keeps the target menu reachable when the row overflows", async () => {
2713
+ const { client } = await buildConnectedSource([targetCamera()]);
2714
+ renderFeed(client, {
2715
+ flightId: 301,
2716
+ enableFullscreen: true,
2717
+ enablePictureInPicture: true,
2718
+ actions: [
2719
+ { id: "a", label: "Extra A", icon: null, onClick: () => {} },
2720
+ { id: "b", label: "Extra B", icon: null, onClick: () => {} },
2721
+ ],
2722
+ });
2723
+ fireEvent.click(screen.getByRole("button", { name: "Choose target" }));
2724
+ expect(screen.getAllByRole("menuitemradio")).toHaveLength(3);
2725
+ });
2726
+
2727
+ it("sends the choice and follows the published target", async () => {
2728
+ const { client, sidecar } = await buildConnectedSource([targetCamera()]);
2729
+ renderFeed(client, { flightId: 301 });
2730
+ fireEvent.click(screen.getByRole("button", { name: "Choose target" }));
2731
+ await act(async () => {
2732
+ fireEvent.click(screen.getByRole("menuitemradio", { name: /Relay 1/ }));
2733
+ });
2734
+ expect(sidecar.lastCommand("set-command-aim", 301)?.content).toEqual({
2735
+ flightId: 301,
2736
+ aim: { kind: "vessel", persistentId: 22 },
2737
+ });
2738
+ expect(
2739
+ screen.getByRole("button", { name: "Choose target" }).getAttribute("title"),
2740
+ ).toBe("Watching Relay 1");
2741
+ });
2742
+
2743
+ it("is hidden with no targets, with manual controls disabled or under the lock", async () => {
2744
+ const { client } = await buildConnectedSource([targetCamera()]);
2745
+ const view = renderFeed(client, { flightId: 301, disableManualControls: true });
2746
+ expect(screen.queryByRole("button", { name: "Choose target" })).toBeNull();
2747
+ view.unmount();
2748
+
2749
+ const locked = await buildConnectedSource([
2750
+ makeCamera({
2751
+ flightId: 301,
2752
+ kind: CameraKind.Command,
2753
+ cameraName: "KSC tracking",
2754
+ commandCentre: TARGETS,
2755
+ commandTrack: commandTrack({ authority: { owner: "gonogo" } }),
2756
+ }),
2757
+ ]);
2758
+ const lockedView = renderFeed(locked.client, { flightId: 301 });
2759
+ expect(screen.queryByRole("button", { name: "Choose target" })).toBeNull();
2760
+ lockedView.unmount();
2761
+
2762
+ const empty = await buildConnectedSource([commandCamera()]);
2763
+ renderFeed(empty.client, { flightId: 301 });
2764
+ expect(screen.queryByRole("button", { name: "Choose target" })).toBeNull();
2765
+ });
2766
+ });
2767
+
2768
+ it("draws nothing with enableTargetBrackets off", async () => {
2769
+ const { client } = await buildConnectedSource([commandCamera()]);
2770
+ renderFeed(client, { flightId: 301, enableTargetBrackets: false });
2771
+ expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
2772
+ expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
2773
+ });
2774
+ });