@ksp-gonogo/kerbcast-react 1.10.3 → 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.
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +106 -5
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +5 -0
- package/dist/hooks/useReportDisplaySize.js +18 -7
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +291 -2
- package/src/CameraFeed.tsx +234 -4
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.test.tsx +10 -4
- package/src/hooks/useReportDisplaySize.ts +18 -7
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
|
@@ -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.
|
|
7
|
-
* to a BUCKET_PX multiple: that meets-the-minimum-need (never
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 =
|
|
52
|
-
const bw
|
|
53
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.11.0-rc.4"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -22,8 +22,19 @@
|
|
|
22
22
|
* - Station (brokered) mode -- gonogo-only KerbcastDataSource.attachBroker.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
26
|
-
|
|
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
|
+
});
|