@ksp-gonogo/kerbcast-react 1.11.1 → 1.12.0-rc.2
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/README.md +71 -0
- package/dist/AnchorMarkers.d.ts +128 -0
- package/dist/AnchorMarkers.js +162 -0
- package/dist/CameraFeed.d.ts +45 -4
- package/dist/CameraFeed.js +163 -23
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- package/dist/FeedActionBar.d.ts +7 -0
- package/dist/FeedActionBar.js +4 -4
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +1 -1
- package/dist/RecordingsTray.d.ts +6 -0
- package/dist/RecordingsTray.js +17 -1
- package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
- package/dist/hooks/useCommandDisplayUt.js +57 -16
- package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
- package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
- package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
- package/dist/hooks/useTargetBracketsPreference.js +18 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/menuPositioning.d.ts +8 -2
- package/dist/menuPositioning.js +30 -5
- package/dist/sharedAnchors.d.ts +3 -0
- package/dist/sharedAnchors.js +61 -0
- package/dist/videoBox.d.ts +10 -0
- package/dist/videoBox.js +40 -0
- package/package.json +2 -2
- package/src/AnchorMarkers.test.tsx +173 -0
- package/src/AnchorMarkers.tsx +341 -0
- package/src/CameraFeed.test.tsx +687 -6
- package/src/CameraFeed.tsx +240 -27
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- package/src/FeedActionBar.test.ts +22 -0
- package/src/FeedActionBar.tsx +11 -4
- package/src/KerbalFaceFeed.tsx +1 -1
- package/src/RecordingsTray.test.tsx +15 -0
- package/src/RecordingsTray.tsx +25 -1
- package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
- package/src/hooks/useCommandDisplayUt.ts +61 -15
- package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
- package/src/hooks/useRecordings.test.tsx +6 -3
- package/src/hooks/useTargetBracketsPreference.ts +15 -5
- package/src/index.ts +23 -1
- package/src/menuPositioning.ts +31 -5
- package/src/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
AnchorKind,
|
|
26
27
|
CameraKind,
|
|
27
28
|
CommandTargetEligibility,
|
|
28
29
|
KerbcastClient,
|
|
@@ -30,10 +31,12 @@ import {
|
|
|
30
31
|
TrackMode,
|
|
31
32
|
} from "@ksp-gonogo/kerbcast";
|
|
32
33
|
import type {
|
|
34
|
+
Anchor,
|
|
33
35
|
CameraLifecycle,
|
|
34
36
|
ClientMessage,
|
|
35
37
|
CommandCentreInfo,
|
|
36
38
|
CommandTrackState,
|
|
39
|
+
DisplayedFrameUt,
|
|
37
40
|
} from "@ksp-gonogo/kerbcast";
|
|
38
41
|
import { type MockCameraInit, MockSidecar } from "@ksp-gonogo/kerbcast/testing";
|
|
39
42
|
import {
|
|
@@ -995,6 +998,107 @@ describe("CameraFeed - stall presentation (showStatic)", () => {
|
|
|
995
998
|
}
|
|
996
999
|
});
|
|
997
1000
|
|
|
1001
|
+
it("data-kc-signal follows a feed into and out of a stall without remounting its video", async () => {
|
|
1002
|
+
const env = installStallEnv();
|
|
1003
|
+
try {
|
|
1004
|
+
const { client, sidecar } = await buildConnectedSource();
|
|
1005
|
+
const { container } = renderFeed(client, { flightId: 42 });
|
|
1006
|
+
const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
|
|
1007
|
+
expect(signal()).toBe("none");
|
|
1008
|
+
|
|
1009
|
+
await act(async () => {
|
|
1010
|
+
sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
|
|
1011
|
+
});
|
|
1012
|
+
env.setNow(1000);
|
|
1013
|
+
await act(async () => {
|
|
1014
|
+
env.fireFrame();
|
|
1015
|
+
env.drawOnce();
|
|
1016
|
+
});
|
|
1017
|
+
expect(signal()).toBe("live");
|
|
1018
|
+
const video = container.querySelector("video");
|
|
1019
|
+
|
|
1020
|
+
env.setNow(1601);
|
|
1021
|
+
await act(async () => {
|
|
1022
|
+
env.drawOnce();
|
|
1023
|
+
});
|
|
1024
|
+
expect(signal()).toBe("stalled");
|
|
1025
|
+
expect(container.querySelector("video")).toBe(video);
|
|
1026
|
+
|
|
1027
|
+
await act(async () => {
|
|
1028
|
+
env.fireFrame();
|
|
1029
|
+
env.drawOnce();
|
|
1030
|
+
});
|
|
1031
|
+
expect(signal()).toBe("live");
|
|
1032
|
+
expect(container.querySelector("video")).toBe(video);
|
|
1033
|
+
} finally {
|
|
1034
|
+
env.restore();
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it("data-kc-signal reads stalled for a source that never delivers a frame", async () => {
|
|
1039
|
+
const env = installStallEnv();
|
|
1040
|
+
try {
|
|
1041
|
+
const { client, sidecar } = await buildConnectedSource();
|
|
1042
|
+
const { container } = renderFeed(client, { flightId: 42 });
|
|
1043
|
+
const signal = () => container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
|
|
1044
|
+
|
|
1045
|
+
env.setNow(1000);
|
|
1046
|
+
await act(async () => {
|
|
1047
|
+
sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
|
|
1048
|
+
env.drawOnce();
|
|
1049
|
+
});
|
|
1050
|
+
env.setNow(1601);
|
|
1051
|
+
await act(async () => {
|
|
1052
|
+
env.drawOnce();
|
|
1053
|
+
});
|
|
1054
|
+
expect(signal()).toBe("stalled");
|
|
1055
|
+
} finally {
|
|
1056
|
+
env.restore();
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it("data-kc-signal reads stalled after a camera switch until the new camera's first frame", async () => {
|
|
1061
|
+
const env = installStallEnv();
|
|
1062
|
+
try {
|
|
1063
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
1064
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
1065
|
+
makeCamera({ flightId: 43, cameraName: "Port Cam" }),
|
|
1066
|
+
]);
|
|
1067
|
+
const view = renderFeed(client, { flightId: 42 });
|
|
1068
|
+
const signal = () => view.container.querySelector("[data-kc-signal]")?.getAttribute("data-kc-signal");
|
|
1069
|
+
await act(async () => {
|
|
1070
|
+
sidecar.deliverTrack(sidecar.slotMidFor(42) as string, {} as MediaStreamTrack);
|
|
1071
|
+
});
|
|
1072
|
+
env.setNow(1000);
|
|
1073
|
+
await act(async () => {
|
|
1074
|
+
env.fireFrame();
|
|
1075
|
+
env.drawOnce();
|
|
1076
|
+
});
|
|
1077
|
+
expect(signal()).toBe("live");
|
|
1078
|
+
|
|
1079
|
+
/* The freed slot passes straight to the new camera, so there is a
|
|
1080
|
+
source at once but no frame of it yet. */
|
|
1081
|
+
view.rerender(
|
|
1082
|
+
<KerbcastProvider client={client}>
|
|
1083
|
+
<CameraFeed flightId={43} />
|
|
1084
|
+
</KerbcastProvider>,
|
|
1085
|
+
);
|
|
1086
|
+
env.setNow(1601);
|
|
1087
|
+
await act(async () => {
|
|
1088
|
+
env.drawOnce();
|
|
1089
|
+
});
|
|
1090
|
+
expect(signal()).toBe("stalled");
|
|
1091
|
+
|
|
1092
|
+
await act(async () => {
|
|
1093
|
+
env.fireFrame();
|
|
1094
|
+
env.drawOnce();
|
|
1095
|
+
});
|
|
1096
|
+
expect(signal()).toBe("live");
|
|
1097
|
+
} finally {
|
|
1098
|
+
env.restore();
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
|
|
998
1102
|
it("SIGNAL LOST supersedes the stale badge when the camera is destroyed", async () => {
|
|
999
1103
|
const env = installStallEnv();
|
|
1000
1104
|
try {
|
|
@@ -2747,9 +2851,54 @@ describe("CameraFeed - recording locks source and close", () => {
|
|
|
2747
2851
|
// Command camera target brackets
|
|
2748
2852
|
// ---------------------------------------------------------------------------
|
|
2749
2853
|
|
|
2854
|
+
|
|
2855
|
+
describe("CameraFeed: no video slot", () => {
|
|
2856
|
+
const NOTICE = /Not streaming: too many feeds are open/;
|
|
2857
|
+
const ids = [1, 2, 3, 4, 5];
|
|
2858
|
+
const feeds = (client: KerbcastClient, shown: number[], label?: string | null) => (
|
|
2859
|
+
<KerbcastProvider client={client}>
|
|
2860
|
+
{shown.map((id) => (
|
|
2861
|
+
<CameraFeed
|
|
2862
|
+
key={id}
|
|
2863
|
+
flightId={id}
|
|
2864
|
+
{...(label !== undefined ? { noSlotLabel: label } : {})}
|
|
2865
|
+
/>
|
|
2866
|
+
))}
|
|
2867
|
+
</KerbcastProvider>
|
|
2868
|
+
);
|
|
2869
|
+
|
|
2870
|
+
it("says so on the feed that found every slot taken, until another feed closes", async () => {
|
|
2871
|
+
const { client } = await buildConnectedSource(
|
|
2872
|
+
ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
|
|
2873
|
+
);
|
|
2874
|
+
const view = render(feeds(client, ids));
|
|
2875
|
+
await act(async () => {});
|
|
2876
|
+
expect(screen.getAllByText(NOTICE)).toHaveLength(1);
|
|
2877
|
+
expect(client.camera(5).waitingForSlot).toBe(true);
|
|
2878
|
+
|
|
2879
|
+
view.rerender(feeds(client, [2, 3, 4, 5]));
|
|
2880
|
+
await act(async () => {});
|
|
2881
|
+
expect(screen.queryByText(NOTICE)).toBeNull();
|
|
2882
|
+
expect(client.camera(5).waitingForSlot).toBe(false);
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
it("takes a host's own words, or none", async () => {
|
|
2886
|
+
const { client } = await buildConnectedSource(
|
|
2887
|
+
ids.map((id) => makeCamera({ flightId: id, cameraName: `Cam ${id}` })),
|
|
2888
|
+
);
|
|
2889
|
+
const view = render(feeds(client, ids, "No room"));
|
|
2890
|
+
await act(async () => {});
|
|
2891
|
+
expect(screen.getByText("No room")).toBeTruthy();
|
|
2892
|
+
view.rerender(feeds(client, ids, null));
|
|
2893
|
+
expect(screen.queryByText("No room")).toBeNull();
|
|
2894
|
+
expect(screen.queryByText(NOTICE)).toBeNull();
|
|
2895
|
+
});
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2750
2898
|
describe("CameraFeed - command camera target brackets", () => {
|
|
2751
2899
|
const STATION: CommandCentreInfo = {
|
|
2752
2900
|
stationId: "ground:KSC",
|
|
2901
|
+
siteKey: "KSC",
|
|
2753
2902
|
nodeName: "KSC",
|
|
2754
2903
|
displayName: "Kerbal Space Center",
|
|
2755
2904
|
body: "Kerbin",
|
|
@@ -2809,7 +2958,7 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2809
2958
|
const first = renderFeed(client, { flightId: 301 });
|
|
2810
2959
|
fireEvent.click(screen.getByRole("button", { name: "Target brackets" }));
|
|
2811
2960
|
expect(screen.queryByText("IN VIEW · 11,508 km")).toBeNull();
|
|
2812
|
-
expect(localStorage.getItem("kerbcast:target-brackets:
|
|
2961
|
+
expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
|
|
2813
2962
|
first.unmount();
|
|
2814
2963
|
|
|
2815
2964
|
renderFeed(client, { flightId: 301 });
|
|
@@ -2819,22 +2968,87 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2819
2968
|
).toBe("false");
|
|
2820
2969
|
});
|
|
2821
2970
|
|
|
2822
|
-
it("
|
|
2971
|
+
it("carries a brackets-off choice saved under the 1.11 key over to the site key", async () => {
|
|
2823
2972
|
localStorage.setItem("kerbcast:target-brackets:ground:KSC", "0");
|
|
2973
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
2974
|
+
renderFeed(client, { flightId: 301 });
|
|
2975
|
+
expect(
|
|
2976
|
+
screen.getByRole("button", { name: "Target brackets" }).getAttribute("aria-pressed"),
|
|
2977
|
+
).toBe("false");
|
|
2978
|
+
expect(localStorage.getItem("kerbcast:target-brackets:KSC")).toBe("0");
|
|
2979
|
+
expect(localStorage.getItem("kerbcast:target-brackets:ground:KSC")).toBeNull();
|
|
2980
|
+
});
|
|
2981
|
+
|
|
2982
|
+
it("still labels a blanked feed with the brackets toggled off", async () => {
|
|
2983
|
+
localStorage.setItem("kerbcast:target-brackets:KSC", "0");
|
|
2824
2984
|
const { client, sidecar } = await buildConnectedSource([commandCamera()]);
|
|
2825
2985
|
renderFeed(client, { flightId: 301 });
|
|
2826
2986
|
expect(screen.queryByRole("status", { name: "Feed blanked" })).toBeNull();
|
|
2827
2987
|
await act(async () => {
|
|
2828
|
-
sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for
|
|
2988
|
+
sidecar.updateCamera(301, { commandTrack: commandTrack({ blank: "Waiting for mission control" }) });
|
|
2829
2989
|
});
|
|
2830
2990
|
expect(screen.getByRole("status", { name: "Feed blanked" }).textContent).toBe(
|
|
2831
|
-
"Waiting for
|
|
2991
|
+
"Waiting for mission control",
|
|
2832
2992
|
);
|
|
2833
2993
|
});
|
|
2834
2994
|
|
|
2995
|
+
describe("waiting for a target", () => {
|
|
2996
|
+
const waitingFrame = (waitingForTarget: boolean): DisplayedFrameUt => ({
|
|
2997
|
+
flightId: 301,
|
|
2998
|
+
ut: 1000,
|
|
2999
|
+
epoch: 0,
|
|
3000
|
+
source: "exact",
|
|
3001
|
+
rtpTimestamp: 1,
|
|
3002
|
+
presentedAt: 0,
|
|
3003
|
+
command: {
|
|
3004
|
+
hasFix: false,
|
|
3005
|
+
frameHeightPx: 576,
|
|
3006
|
+
framingFits: true,
|
|
3007
|
+
occluded: false,
|
|
3008
|
+
occluder: "",
|
|
3009
|
+
sunlit: false,
|
|
3010
|
+
subjectPersistentId: 0,
|
|
3011
|
+
waitingForTarget,
|
|
3012
|
+
},
|
|
3013
|
+
});
|
|
3014
|
+
const feed = (client: KerbcastClient, frame: DisplayedFrameUt, label?: string | null) => (
|
|
3015
|
+
<KerbcastProvider client={client}>
|
|
3016
|
+
<CameraFeed
|
|
3017
|
+
flightId={301}
|
|
3018
|
+
displayFrame={frame}
|
|
3019
|
+
{...(label !== undefined ? { waitingForTargetLabel: label } : {})}
|
|
3020
|
+
/>
|
|
3021
|
+
</KerbcastProvider>
|
|
3022
|
+
);
|
|
3023
|
+
|
|
3024
|
+
it("says so while the frame does, and stops once the target arrives", async () => {
|
|
3025
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
3026
|
+
const view = render(feed(client, waitingFrame(true)));
|
|
3027
|
+
expect(screen.getByText("Waiting for target")).toBeTruthy();
|
|
3028
|
+
view.rerender(feed(client, waitingFrame(false)));
|
|
3029
|
+
expect(screen.queryByText("Waiting for target")).toBeNull();
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
it("takes a host's own words, or none", async () => {
|
|
3033
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
3034
|
+
const view = render(feed(client, waitingFrame(true), "Acquiring"));
|
|
3035
|
+
expect(screen.getByText("Acquiring")).toBeTruthy();
|
|
3036
|
+
view.rerender(feed(client, waitingFrame(true), null));
|
|
3037
|
+
expect(screen.queryByText("Acquiring")).toBeNull();
|
|
3038
|
+
expect(screen.queryByText("Waiting for target")).toBeNull();
|
|
3039
|
+
});
|
|
3040
|
+
|
|
3041
|
+
it("gives way to a blank", async () => {
|
|
3042
|
+
const { client } = await buildConnectedSource([commandCamera()]);
|
|
3043
|
+
const frame = waitingFrame(true);
|
|
3044
|
+
render(feed(client, { ...frame, command: { ...frame.command!, blank: "Held" } }));
|
|
3045
|
+
expect(screen.getByRole("status", { name: "Feed blanked" })).toBeTruthy();
|
|
3046
|
+
expect(screen.queryByText("Waiting for target")).toBeNull();
|
|
3047
|
+
});
|
|
3048
|
+
});
|
|
3049
|
+
|
|
2835
3050
|
it("shows the transition in force at the displayed UT", async () => {
|
|
2836
3051
|
const track = commandTrack({
|
|
2837
|
-
authority: { owner: "gonogo" },
|
|
2838
3052
|
events: [
|
|
2839
3053
|
{ ut: 990, occluded: true, occluder: "Mun", sunlit: true },
|
|
2840
3054
|
{ ut: 999, occluded: false, occluder: "", sunlit: true },
|
|
@@ -2873,6 +3087,46 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2873
3087
|
expect(screen.getByText(/ · T\+1060$/)).toBeTruthy();
|
|
2874
3088
|
});
|
|
2875
3089
|
|
|
3090
|
+
it("gives the brackets the subject's anchor from the displayed frame's stamp", async () => {
|
|
3091
|
+
const { client, sidecar } = await buildConnectedSource([commandCamera()]);
|
|
3092
|
+
const subjects: (string | undefined)[] = [];
|
|
3093
|
+
renderFeed(client, {
|
|
3094
|
+
flightId: 301,
|
|
3095
|
+
targetBracketOverlay: {
|
|
3096
|
+
render: (ctx) => {
|
|
3097
|
+
subjects.push(ctx.subject ? `${ctx.subject.owner}/${ctx.subject.id}` : undefined);
|
|
3098
|
+
return null;
|
|
3099
|
+
},
|
|
3100
|
+
},
|
|
3101
|
+
});
|
|
3102
|
+
await act(async () => {
|
|
3103
|
+
sidecar.fireFrameStamp({
|
|
3104
|
+
flightId: 301,
|
|
3105
|
+
rtpTimestamp: 1,
|
|
3106
|
+
captureUt: 1000,
|
|
3107
|
+
captureEpoch: 0,
|
|
3108
|
+
width: 1024,
|
|
3109
|
+
height: 576,
|
|
3110
|
+
anchors: [
|
|
3111
|
+
{
|
|
3112
|
+
id: "subject",
|
|
3113
|
+
owner: "kerbcast",
|
|
3114
|
+
x: 100,
|
|
3115
|
+
y: 100,
|
|
3116
|
+
inFrame: true,
|
|
3117
|
+
behind: false,
|
|
3118
|
+
distanceM: 11_507_905,
|
|
3119
|
+
apparentRadiusPx: 4,
|
|
3120
|
+
angleOffAxis: 3,
|
|
3121
|
+
occluded: false,
|
|
3122
|
+
},
|
|
3123
|
+
],
|
|
3124
|
+
});
|
|
3125
|
+
});
|
|
3126
|
+
/* Without frame callbacks (jsdom) a stamp is taken as on screen once it is old enough. */
|
|
3127
|
+
await waitFor(() => expect(subjects).toContain("kerbcast/subject"), { timeout: 2000 });
|
|
3128
|
+
});
|
|
3129
|
+
|
|
2876
3130
|
describe("target picker", () => {
|
|
2877
3131
|
const TARGETS: CommandCentreInfo = {
|
|
2878
3132
|
...STATION,
|
|
@@ -2993,7 +3247,7 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
2993
3247
|
kind: CameraKind.Command,
|
|
2994
3248
|
cameraName: "KSC tracking",
|
|
2995
3249
|
commandCentre: TARGETS,
|
|
2996
|
-
commandTrack: commandTrack({ authority: { owner: "
|
|
3250
|
+
commandTrack: commandTrack({ authority: { owner: "mission-control" } }),
|
|
2997
3251
|
}),
|
|
2998
3252
|
]);
|
|
2999
3253
|
const lockedView = renderFeed(locked.client, { flightId: 301 });
|
|
@@ -3013,3 +3267,430 @@ describe("CameraFeed - command camera target brackets", () => {
|
|
|
3013
3267
|
expect(screen.queryByRole("button", { name: "Target brackets" })).toBeNull();
|
|
3014
3268
|
});
|
|
3015
3269
|
});
|
|
3270
|
+
|
|
3271
|
+
describe("CameraFeed - anchors", () => {
|
|
3272
|
+
const port = { id: "port", kind: AnchorKind.Part, vessel: 11, part: 22, orientation: true };
|
|
3273
|
+
|
|
3274
|
+
function Feed({ client, flightId, anchors }: { client: KerbcastClient; flightId: number; anchors?: Anchor[] }) {
|
|
3275
|
+
return (
|
|
3276
|
+
<KerbcastProvider client={client}>
|
|
3277
|
+
<CameraFeed flightId={flightId} anchors={anchors} />
|
|
3278
|
+
</KerbcastProvider>
|
|
3279
|
+
);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
it("sets its anchors on the displayed camera and clears them when it moves or unmounts", async () => {
|
|
3283
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
3284
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
3285
|
+
makeCamera({ flightId: 43, cameraName: "Port Cam" }),
|
|
3286
|
+
]);
|
|
3287
|
+
const view = render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3288
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
|
|
3289
|
+
|
|
3290
|
+
/* A new array with the same anchors is no change. */
|
|
3291
|
+
const sent = () => sidecar.commands.filter((c) => c.type === "set-anchors").length;
|
|
3292
|
+
const before = sent();
|
|
3293
|
+
view.rerender(<Feed client={client} flightId={42} anchors={[{ ...port }]} />);
|
|
3294
|
+
expect(sent()).toBe(before);
|
|
3295
|
+
|
|
3296
|
+
view.rerender(<Feed client={client} flightId={43} anchors={[port]} />);
|
|
3297
|
+
await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([port]));
|
|
3298
|
+
expect(sidecar.anchorsFor(42)).toEqual([]);
|
|
3299
|
+
|
|
3300
|
+
view.unmount();
|
|
3301
|
+
await waitFor(() => expect(sidecar.anchorsFor(43)).toEqual([]));
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
it("gives two feeds on one camera both their anchors, until each unmounts", async () => {
|
|
3305
|
+
const { client, sidecar } = await buildConnectedSource([
|
|
3306
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
3307
|
+
]);
|
|
3308
|
+
const dock = { id: "dock", kind: AnchorKind.Vessel, vessel: 12 };
|
|
3309
|
+
const first = render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3310
|
+
const second = render(<Feed client={client} flightId={42} anchors={[port, dock]} />);
|
|
3311
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
|
|
3312
|
+
|
|
3313
|
+
first.unmount();
|
|
3314
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port, dock]));
|
|
3315
|
+
second.rerender(<Feed client={client} flightId={42} anchors={[dock]} />);
|
|
3316
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([dock]));
|
|
3317
|
+
second.unmount();
|
|
3318
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([]));
|
|
3319
|
+
});
|
|
3320
|
+
|
|
3321
|
+
it("warns when two feeds on one camera use one anchor id for different things", async () => {
|
|
3322
|
+
const { client, sidecar } = await buildConnectedSource([makeCamera({ flightId: 42 })]);
|
|
3323
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
3324
|
+
const other = { ...port, part: 23 };
|
|
3325
|
+
render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3326
|
+
render(<Feed client={client} flightId={42} anchors={[port]} />);
|
|
3327
|
+
expect(warn).not.toHaveBeenCalled();
|
|
3328
|
+
render(<Feed client={client} flightId={42} anchors={[other]} />);
|
|
3329
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("id port"));
|
|
3330
|
+
await waitFor(() => expect(sidecar.anchorsFor(42)).toEqual([port]));
|
|
3331
|
+
warn.mockRestore();
|
|
3332
|
+
});
|
|
3333
|
+
});
|
|
3334
|
+
|
|
3335
|
+
describe("CameraFeed - fullscreen exit without a mouse", () => {
|
|
3336
|
+
type FsDoc = Document & {
|
|
3337
|
+
fullscreenEnabled?: boolean;
|
|
3338
|
+
fullscreenElement?: Element | null;
|
|
3339
|
+
exitFullscreen?: () => Promise<void>;
|
|
3340
|
+
};
|
|
3341
|
+
const fsDoc = document as FsDoc;
|
|
3342
|
+
let fsElement: Element | null = null;
|
|
3343
|
+
const exitSpy = vi.fn(async () => {
|
|
3344
|
+
fsElement = null;
|
|
3345
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
3346
|
+
});
|
|
3347
|
+
|
|
3348
|
+
beforeEach(() => {
|
|
3349
|
+
fsElement = null;
|
|
3350
|
+
exitSpy.mockClear();
|
|
3351
|
+
Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
|
|
3352
|
+
Object.defineProperty(fsDoc, "fullscreenElement", {
|
|
3353
|
+
get: () => fsElement,
|
|
3354
|
+
configurable: true,
|
|
3355
|
+
});
|
|
3356
|
+
Object.defineProperty(fsDoc, "exitFullscreen", { value: exitSpy, configurable: true });
|
|
3357
|
+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
|
|
3358
|
+
value: async function (this: HTMLElement) {
|
|
3359
|
+
fsElement = this;
|
|
3360
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
3361
|
+
},
|
|
3362
|
+
configurable: true,
|
|
3363
|
+
});
|
|
3364
|
+
});
|
|
3365
|
+
|
|
3366
|
+
afterEach(() => {
|
|
3367
|
+
delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
|
|
3368
|
+
delete (fsDoc as { fullscreenElement?: Element | null }).fullscreenElement;
|
|
3369
|
+
delete (fsDoc as { exitFullscreen?: unknown }).exitFullscreen;
|
|
3370
|
+
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
|
3371
|
+
delete (document as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
|
|
3372
|
+
});
|
|
3373
|
+
|
|
3374
|
+
async function enterFullscreen() {
|
|
3375
|
+
const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
|
|
3376
|
+
Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
|
|
3377
|
+
const { client } = await buildConnectedSource([
|
|
3378
|
+
makeCamera({
|
|
3379
|
+
flightId: 42,
|
|
3380
|
+
cameraName: "Launchpad Cam",
|
|
3381
|
+
supportsPan: true,
|
|
3382
|
+
supportsZoom: true,
|
|
3383
|
+
panPitchMin: -90,
|
|
3384
|
+
panPitchMax: 90,
|
|
3385
|
+
}),
|
|
3386
|
+
]);
|
|
3387
|
+
/* Crowded enough (REC + tracking + PiP + fullscreen) that fullscreen
|
|
3388
|
+
starts out in the ⋮ overflow. */
|
|
3389
|
+
const utils = renderFeed(client, {
|
|
3390
|
+
flightId: 42,
|
|
3391
|
+
enableRecording: true,
|
|
3392
|
+
enableTracking: true,
|
|
3393
|
+
enableFullscreen: true,
|
|
3394
|
+
enablePictureInPicture: true,
|
|
3395
|
+
});
|
|
3396
|
+
fireEvent.click(utils.getByLabelText("More actions"));
|
|
3397
|
+
await act(async () => {
|
|
3398
|
+
fireEvent.click(utils.getByLabelText("Enter fullscreen"));
|
|
3399
|
+
});
|
|
3400
|
+
return utils;
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
it("keeps the exit inline and marked as the always-visible control", async () => {
|
|
3404
|
+
const { getByLabelText } = await enterFullscreen();
|
|
3405
|
+
const exit = getByLabelText("Exit fullscreen");
|
|
3406
|
+
expect(exit.hasAttribute("data-kc-fullscreen-exit")).toBe(true);
|
|
3407
|
+
expect(exit.closest("[role=menu]")).toBeNull();
|
|
3408
|
+
expect(exit.closest("[data-fullscreen]")).toBe(fsElement);
|
|
3409
|
+
expect(exit.tabIndex).toBe(0);
|
|
3410
|
+
/* The ⋮ menu hands focus back to its trigger, which reveals the whole
|
|
3411
|
+
bar by focus-within; at rest only the exit shows. */
|
|
3412
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
3413
|
+
expect(getComputedStyle(exit).opacity).not.toBe("0");
|
|
3414
|
+
expect(getComputedStyle(exit).pointerEvents).not.toBe("none");
|
|
3415
|
+
expect(getComputedStyle(getByLabelText("Auto-track")).opacity).toBe("0");
|
|
3416
|
+
|
|
3417
|
+
await act(async () => {
|
|
3418
|
+
fireEvent.click(exit);
|
|
3419
|
+
});
|
|
3420
|
+
expect(exitSpy).toHaveBeenCalledTimes(1);
|
|
3421
|
+
expect(document.querySelector("[data-kc-fullscreen-exit]")).toBeNull();
|
|
3422
|
+
expect(document.querySelector("[data-fullscreen]")).toBeNull();
|
|
3423
|
+
});
|
|
3424
|
+
|
|
3425
|
+
it("entering fullscreen adds the exit without moving the rest of the bar", async () => {
|
|
3426
|
+
const barLabels = () => {
|
|
3427
|
+
const row = screen.getByLabelText("More actions").parentElement as HTMLElement;
|
|
3428
|
+
return Array.from(row.children).map((c) => c.getAttribute("aria-label") ?? "steps");
|
|
3429
|
+
};
|
|
3430
|
+
const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
|
|
3431
|
+
Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
|
|
3432
|
+
const { client } = await buildConnectedSource([
|
|
3433
|
+
makeCamera({
|
|
3434
|
+
flightId: 42,
|
|
3435
|
+
cameraName: "Launchpad Cam",
|
|
3436
|
+
supportsPan: true,
|
|
3437
|
+
supportsZoom: true,
|
|
3438
|
+
panPitchMin: -90,
|
|
3439
|
+
panPitchMax: 90,
|
|
3440
|
+
}),
|
|
3441
|
+
]);
|
|
3442
|
+
const { getByLabelText } = renderFeed(client, {
|
|
3443
|
+
flightId: 42,
|
|
3444
|
+
enableRecording: true,
|
|
3445
|
+
enableTracking: true,
|
|
3446
|
+
enableFullscreen: true,
|
|
3447
|
+
enablePictureInPicture: true,
|
|
3448
|
+
trailingActions: [{ id: "x", label: "Close", icon: null, onClick: () => {} }],
|
|
3449
|
+
});
|
|
3450
|
+
const before = barLabels();
|
|
3451
|
+
expect(before).toContain("More actions");
|
|
3452
|
+
expect(before).not.toContain("Picture in picture");
|
|
3453
|
+
|
|
3454
|
+
fireEvent.click(getByLabelText("More actions"));
|
|
3455
|
+
await act(async () => {
|
|
3456
|
+
fireEvent.click(getByLabelText("Enter fullscreen"));
|
|
3457
|
+
});
|
|
3458
|
+
const after = barLabels();
|
|
3459
|
+
expect(after.filter((l) => l !== "Exit fullscreen")).toEqual(before);
|
|
3460
|
+
expect(after.indexOf("Exit fullscreen")).toBe(after.indexOf("More actions") - 1);
|
|
3461
|
+
});
|
|
3462
|
+
|
|
3463
|
+
it("leaves an Escape another handler already consumed alone", async () => {
|
|
3464
|
+
await enterFullscreen();
|
|
3465
|
+
const consumed = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
|
|
3466
|
+
consumed.preventDefault();
|
|
3467
|
+
await act(async () => {
|
|
3468
|
+
document.dispatchEvent(consumed);
|
|
3469
|
+
});
|
|
3470
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
3471
|
+
});
|
|
3472
|
+
|
|
3473
|
+
it("a tap on the video reveals the rest of the bar", async () => {
|
|
3474
|
+
const { getByLabelText } = await enterFullscreen();
|
|
3475
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
3476
|
+
expect(getComputedStyle(getByLabelText("Auto-track")).opacity).toBe("0");
|
|
3477
|
+
const video = document.querySelector("video") as HTMLVideoElement;
|
|
3478
|
+
fireEvent.click(video);
|
|
3479
|
+
expect(getComputedStyle(getByLabelText("Auto-track")).opacity).not.toBe("0");
|
|
3480
|
+
});
|
|
3481
|
+
|
|
3482
|
+
it("Escape leaves fullscreen", async () => {
|
|
3483
|
+
const { queryByLabelText } = await enterFullscreen();
|
|
3484
|
+
await act(async () => {
|
|
3485
|
+
fireEvent.keyDown(document, { key: "Escape" });
|
|
3486
|
+
});
|
|
3487
|
+
expect(exitSpy).toHaveBeenCalledTimes(1);
|
|
3488
|
+
expect(queryByLabelText("Exit fullscreen")).toBeNull();
|
|
3489
|
+
});
|
|
3490
|
+
});
|
|
3491
|
+
|
|
3492
|
+
describe("CameraFeed - video source on camera switch", () => {
|
|
3493
|
+
const TWO_CAMERAS = [
|
|
3494
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
3495
|
+
makeCamera({ flightId: 43, cameraName: "Nose Cam" }),
|
|
3496
|
+
];
|
|
3497
|
+
|
|
3498
|
+
it("clears the previous frame when the new camera has no stream yet", async () => {
|
|
3499
|
+
const { client } = await buildConnectedSource(TWO_CAMERAS);
|
|
3500
|
+
const sentinel = {} as MediaStream;
|
|
3501
|
+
const useCustom: CameraStreamHook = (id) => (id === 42 ? sentinel : null);
|
|
3502
|
+
const view = (flightId: number) => (
|
|
3503
|
+
<KerbcastProvider client={client}>
|
|
3504
|
+
<CameraFeed flightId={flightId} useStream={useCustom} />
|
|
3505
|
+
</KerbcastProvider>
|
|
3506
|
+
);
|
|
3507
|
+
const { rerender } = render(view(42));
|
|
3508
|
+
const video = document.querySelector("video") as HTMLVideoElement;
|
|
3509
|
+
expect(video.srcObject).toBe(sentinel);
|
|
3510
|
+
|
|
3511
|
+
rerender(view(43));
|
|
3512
|
+
expect(document.querySelector("video")).toBe(video);
|
|
3513
|
+
expect(video.srcObject).toBeNull();
|
|
3514
|
+
});
|
|
3515
|
+
|
|
3516
|
+
it("keeps the element bound when a rebind wraps the same track in a new stream", async () => {
|
|
3517
|
+
const { client } = await buildConnectedSource(TWO_CAMERAS);
|
|
3518
|
+
const track = { id: "track-1", kind: "video" } as MediaStreamTrack;
|
|
3519
|
+
const wrap = () =>
|
|
3520
|
+
({ getVideoTracks: () => [track] }) as unknown as MediaStream;
|
|
3521
|
+
const first = wrap();
|
|
3522
|
+
const second = wrap();
|
|
3523
|
+
const view = (stream: MediaStream) => {
|
|
3524
|
+
const useFixed: CameraStreamHook = () => stream;
|
|
3525
|
+
return (
|
|
3526
|
+
<KerbcastProvider client={client}>
|
|
3527
|
+
<CameraFeed flightId={42} useStream={useFixed} />
|
|
3528
|
+
</KerbcastProvider>
|
|
3529
|
+
);
|
|
3530
|
+
};
|
|
3531
|
+
const { rerender } = render(view(first));
|
|
3532
|
+
const video = document.querySelector("video") as HTMLVideoElement;
|
|
3533
|
+
expect(video.srcObject).toBe(first);
|
|
3534
|
+
|
|
3535
|
+
rerender(view(second));
|
|
3536
|
+
expect(video.srcObject).toBe(first);
|
|
3537
|
+
|
|
3538
|
+
const other = { getVideoTracks: () => [{ id: "track-2" }] } as unknown as MediaStream;
|
|
3539
|
+
rerender(view(other));
|
|
3540
|
+
expect(video.srcObject).toBe(other);
|
|
3541
|
+
});
|
|
3542
|
+
|
|
3543
|
+
it("never reassigns a live stream on a re-render", async () => {
|
|
3544
|
+
const { client } = await buildConnectedSource(TWO_CAMERAS);
|
|
3545
|
+
const sentinel = {} as MediaStream;
|
|
3546
|
+
const useCustom: CameraStreamHook = () => sentinel;
|
|
3547
|
+
const view = (debug: boolean) => (
|
|
3548
|
+
<KerbcastProvider client={client}>
|
|
3549
|
+
<CameraFeed flightId={42} useStream={useCustom} showDebugInfo={debug} />
|
|
3550
|
+
</KerbcastProvider>
|
|
3551
|
+
);
|
|
3552
|
+
const { rerender } = render(view(false));
|
|
3553
|
+
const video = document.querySelector("video") as HTMLVideoElement;
|
|
3554
|
+
expect(video.srcObject).toBe(sentinel);
|
|
3555
|
+
|
|
3556
|
+
let current: MediaProvider | null = sentinel;
|
|
3557
|
+
const sets: (MediaProvider | null)[] = [];
|
|
3558
|
+
Object.defineProperty(video, "srcObject", {
|
|
3559
|
+
get: () => current,
|
|
3560
|
+
set: (v: MediaProvider | null) => {
|
|
3561
|
+
sets.push(v);
|
|
3562
|
+
current = v;
|
|
3563
|
+
},
|
|
3564
|
+
configurable: true,
|
|
3565
|
+
});
|
|
3566
|
+
rerender(view(true));
|
|
3567
|
+
rerender(view(false));
|
|
3568
|
+
expect(sets).toEqual([]);
|
|
3569
|
+
expect(video.srcObject).toBe(sentinel);
|
|
3570
|
+
});
|
|
3571
|
+
});
|
|
3572
|
+
|
|
3573
|
+
describe("CameraFeed - menus in fullscreen", () => {
|
|
3574
|
+
type FsDoc = Document & {
|
|
3575
|
+
fullscreenEnabled?: boolean;
|
|
3576
|
+
fullscreenElement?: Element | null;
|
|
3577
|
+
pictureInPictureEnabled?: boolean;
|
|
3578
|
+
};
|
|
3579
|
+
const fsDoc = document as FsDoc;
|
|
3580
|
+
let fsElement: Element | null = null;
|
|
3581
|
+
|
|
3582
|
+
beforeEach(() => {
|
|
3583
|
+
fsElement = null;
|
|
3584
|
+
Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
|
|
3585
|
+
Object.defineProperty(fsDoc, "pictureInPictureEnabled", { value: true, configurable: true });
|
|
3586
|
+
Object.defineProperty(fsDoc, "fullscreenElement", {
|
|
3587
|
+
get: () => fsElement,
|
|
3588
|
+
configurable: true,
|
|
3589
|
+
});
|
|
3590
|
+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
|
|
3591
|
+
value: async function (this: HTMLElement) {
|
|
3592
|
+
fsElement = this;
|
|
3593
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
3594
|
+
},
|
|
3595
|
+
configurable: true,
|
|
3596
|
+
});
|
|
3597
|
+
});
|
|
3598
|
+
|
|
3599
|
+
afterEach(() => {
|
|
3600
|
+
delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
|
|
3601
|
+
delete (fsDoc as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
|
|
3602
|
+
delete (fsDoc as { fullscreenElement?: Element | null }).fullscreenElement;
|
|
3603
|
+
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
|
|
3604
|
+
});
|
|
3605
|
+
|
|
3606
|
+
it("opens the more actions menu inside the fullscreen feed on the first tap", async () => {
|
|
3607
|
+
const { client } = await buildConnectedSource();
|
|
3608
|
+
const { getByLabelText, getByRole } = renderFeed(client, {
|
|
3609
|
+
flightId: 42,
|
|
3610
|
+
enableFullscreen: true,
|
|
3611
|
+
enablePictureInPicture: true,
|
|
3612
|
+
actions: [
|
|
3613
|
+
{ id: "a", label: "Spotlight", icon: null, onClick: () => {} },
|
|
3614
|
+
{ id: "b", label: "Snapshot", icon: null, onClick: () => {} },
|
|
3615
|
+
],
|
|
3616
|
+
trailingActions: [{ id: "x", label: "Close", icon: null, onClick: () => {} }],
|
|
3617
|
+
});
|
|
3618
|
+
/* Crowded enough that ⋮ stays even once fullscreen pins its exit inline. */
|
|
3619
|
+
fireEvent.click(getByLabelText("More actions"));
|
|
3620
|
+
await act(async () => {
|
|
3621
|
+
fireEvent.click(getByLabelText("Enter fullscreen"));
|
|
3622
|
+
});
|
|
3623
|
+
expect(fsElement).not.toBeNull();
|
|
3624
|
+
|
|
3625
|
+
const trigger = getByLabelText("More actions");
|
|
3626
|
+
fireEvent.pointerDown(trigger);
|
|
3627
|
+
fireEvent.click(trigger);
|
|
3628
|
+
const menu = getByRole("menu", { name: "More actions" });
|
|
3629
|
+
expect(fsElement?.contains(menu)).toBe(true);
|
|
3630
|
+
|
|
3631
|
+
fireEvent.pointerDown(menu);
|
|
3632
|
+
expect(getByRole("menu", { name: "More actions" })).toBe(menu);
|
|
3633
|
+
});
|
|
3634
|
+
|
|
3635
|
+
async function openMenuInFullscreen() {
|
|
3636
|
+
const { client } = await buildConnectedSource();
|
|
3637
|
+
const utils = renderFeed(client, {
|
|
3638
|
+
flightId: 42,
|
|
3639
|
+
enableFullscreen: true,
|
|
3640
|
+
enablePictureInPicture: true,
|
|
3641
|
+
actions: [
|
|
3642
|
+
{ id: "a", label: "Spotlight", icon: null, onClick: () => {} },
|
|
3643
|
+
{ id: "b", label: "Snapshot", icon: null, onClick: () => {} },
|
|
3644
|
+
],
|
|
3645
|
+
});
|
|
3646
|
+
fireEvent.click(utils.getByLabelText("More actions"));
|
|
3647
|
+
await act(async () => {
|
|
3648
|
+
fireEvent.click(utils.getByLabelText("Enter fullscreen"));
|
|
3649
|
+
});
|
|
3650
|
+
fireEvent.click(utils.getByLabelText("More actions"));
|
|
3651
|
+
expect(utils.getByRole("menu", { name: "More actions" })).toBeTruthy();
|
|
3652
|
+
return utils;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
it("Escape with a menu open closes the menu and stays fullscreen", async () => {
|
|
3656
|
+
const exitSpy = vi.fn();
|
|
3657
|
+
Object.defineProperty(fsDoc, "exitFullscreen", { value: exitSpy, configurable: true });
|
|
3658
|
+
try {
|
|
3659
|
+
const { queryByRole, getByLabelText, getByRole } = await openMenuInFullscreen();
|
|
3660
|
+
await act(async () => {
|
|
3661
|
+
fireEvent.keyDown(getByRole("button", { name: "More actions" }), { key: "Escape" });
|
|
3662
|
+
});
|
|
3663
|
+
expect(queryByRole("menu", { name: "More actions" })).toBeNull();
|
|
3664
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
3665
|
+
expect(document.activeElement).toBe(getByLabelText("More actions"));
|
|
3666
|
+
} finally {
|
|
3667
|
+
delete (fsDoc as { exitFullscreen?: unknown }).exitFullscreen;
|
|
3668
|
+
}
|
|
3669
|
+
});
|
|
3670
|
+
|
|
3671
|
+
it("closes an open menu when fullscreen ends", async () => {
|
|
3672
|
+
const { queryByRole, getByLabelText } = await openMenuInFullscreen();
|
|
3673
|
+
await act(async () => {
|
|
3674
|
+
fsElement = null;
|
|
3675
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
3676
|
+
});
|
|
3677
|
+
expect(queryByRole("menu", { name: "More actions" })).toBeNull();
|
|
3678
|
+
expect(document.activeElement).toBe(getByLabelText("More actions"));
|
|
3679
|
+
});
|
|
3680
|
+
|
|
3681
|
+
it("still portals menus to the body when not fullscreen", async () => {
|
|
3682
|
+
const { client } = await buildConnectedSource();
|
|
3683
|
+
const { getByLabelText, getByRole } = renderFeed(client, {
|
|
3684
|
+
flightId: 42,
|
|
3685
|
+
enableFullscreen: true,
|
|
3686
|
+
enablePictureInPicture: true,
|
|
3687
|
+
actions: [
|
|
3688
|
+
{ id: "a", label: "Spotlight", icon: null, onClick: () => {} },
|
|
3689
|
+
{ id: "b", label: "Snapshot", icon: null, onClick: () => {} },
|
|
3690
|
+
],
|
|
3691
|
+
});
|
|
3692
|
+
fireEvent.click(getByLabelText("More actions"));
|
|
3693
|
+
const menu = getByRole("menu", { name: "More actions" });
|
|
3694
|
+
expect(menu.parentElement).toBe(document.body);
|
|
3695
|
+
});
|
|
3696
|
+
});
|