@ksp-gonogo/kerbcast-react 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/CameraFeed.d.ts +32 -16
- package/dist/CameraFeed.js +259 -169
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +12 -19
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/menuPositioning.d.ts +20 -0
- package/dist/menuPositioning.js +98 -0
- package/dist/timing.d.ts +4 -0
- package/dist/timing.js +19 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +545 -0
- package/src/CameraFeed.tsx +421 -309
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.tsx +13 -35
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
- package/src/hooks/useRecordings.test.tsx +217 -0
- package/src/hooks/useRecordings.ts +110 -0
- package/src/index.ts +15 -0
- package/src/menuPositioning.ts +126 -0
- package/src/timing.test.ts +34 -0
- package/src/timing.ts +21 -0
package/src/CameraFeed.test.tsx
CHANGED
|
@@ -40,9 +40,125 @@ import {
|
|
|
40
40
|
CameraFeed,
|
|
41
41
|
type CameraFeedHandle,
|
|
42
42
|
type CameraStreamHook,
|
|
43
|
+
type FeedAction,
|
|
43
44
|
} from "./CameraFeed";
|
|
44
45
|
import { KerbcastProvider } from "./context";
|
|
45
46
|
|
|
47
|
+
/*
|
|
48
|
+
* The REC control reads/drives `useRecordings()` (see CameraFeed - REC below).
|
|
49
|
+
* The real hook sits on top of a real `RecordingController`, which under
|
|
50
|
+
* jsdom needs a track-bearing MediaStream the noise-pipeline stub doesn't
|
|
51
|
+
* produce (`canvas.captureStream()` is stubbed empty). Stand in a fake store
|
|
52
|
+
* that mirrors the real hook's shape (its own per-component useState, so
|
|
53
|
+
* start/stop naturally re-render the owning CameraFeed exactly like the real
|
|
54
|
+
* hook does) so the REC tests exercise CameraFeed's wiring to the store's
|
|
55
|
+
* contract without depending on a real MediaRecorder track.
|
|
56
|
+
*/
|
|
57
|
+
const recordingsStoreSpies = vi.hoisted(() => ({
|
|
58
|
+
start: vi.fn(),
|
|
59
|
+
stop: vi.fn(),
|
|
60
|
+
/** When true, the fake store's stop() rejects instead of resolving -- lets
|
|
61
|
+
* a test drive CameraFeed's stop-failure path (see "CameraFeed - REC"). */
|
|
62
|
+
stopRejects: false,
|
|
63
|
+
/** Flips a still-arming fake recording out of arming, simulating the real
|
|
64
|
+
* controller's arm-and-wait resolving once the feed reaches full
|
|
65
|
+
* resolution. Given its real implementation by the mock factory below
|
|
66
|
+
* (it needs the factory's own closured resolver map, unavailable at
|
|
67
|
+
* hoist time). */
|
|
68
|
+
resolveArming: (_recordingId: string) => {},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
vi.mock("./hooks/useRecordings", async () => {
|
|
72
|
+
const { useCallback, useState: useReactState } = await import("react");
|
|
73
|
+
|
|
74
|
+
interface FakeActive {
|
|
75
|
+
recordingId: string;
|
|
76
|
+
flightId: number;
|
|
77
|
+
startedAt: number;
|
|
78
|
+
arming: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* recordingId -> the setState callback that flips that entry's arming off,
|
|
82
|
+
invoked by test code via recordingsStoreSpies.resolveArming. */
|
|
83
|
+
const armingResolvers = new Map<string, () => void>();
|
|
84
|
+
recordingsStoreSpies.resolveArming = (recordingId: string) => {
|
|
85
|
+
armingResolvers.get(recordingId)?.();
|
|
86
|
+
armingResolvers.delete(recordingId);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function useRecordings() {
|
|
90
|
+
const [state, setState] = useReactState<{
|
|
91
|
+
active: FakeActive[];
|
|
92
|
+
recordings: unknown[];
|
|
93
|
+
}>({ active: [], recordings: [] });
|
|
94
|
+
|
|
95
|
+
const start = useCallback(
|
|
96
|
+
(flightId: number, opts?: { forceFullResolution?: boolean }): string => {
|
|
97
|
+
recordingsStoreSpies.start(flightId, opts);
|
|
98
|
+
const recordingId = `rec-${flightId}`;
|
|
99
|
+
const forced = opts?.forceFullResolution === true;
|
|
100
|
+
setState((s) => ({
|
|
101
|
+
...s,
|
|
102
|
+
active: [
|
|
103
|
+
...s.active,
|
|
104
|
+
{ recordingId, flightId, startedAt: performance.now(), arming: forced },
|
|
105
|
+
],
|
|
106
|
+
}));
|
|
107
|
+
if (forced) {
|
|
108
|
+
armingResolvers.set(recordingId, () => {
|
|
109
|
+
setState((s) => ({
|
|
110
|
+
...s,
|
|
111
|
+
active: s.active.map((a) =>
|
|
112
|
+
a.recordingId === recordingId
|
|
113
|
+
? { ...a, arming: false, startedAt: performance.now() }
|
|
114
|
+
: a,
|
|
115
|
+
),
|
|
116
|
+
}));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return recordingId;
|
|
120
|
+
},
|
|
121
|
+
[],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const stop = useCallback(async (recordingId: string) => {
|
|
125
|
+
recordingsStoreSpies.stop(recordingId);
|
|
126
|
+
armingResolvers.delete(recordingId);
|
|
127
|
+
if (recordingsStoreSpies.stopRejects) {
|
|
128
|
+
throw new Error("stop failed");
|
|
129
|
+
}
|
|
130
|
+
const handle = {
|
|
131
|
+
recordingId,
|
|
132
|
+
blob: new Blob(),
|
|
133
|
+
mimeType: "video/webm",
|
|
134
|
+
utSamples: [],
|
|
135
|
+
byteSize: 0,
|
|
136
|
+
durationMs: 0,
|
|
137
|
+
};
|
|
138
|
+
setState((s) => ({
|
|
139
|
+
active: s.active.filter((a) => a.recordingId !== recordingId),
|
|
140
|
+
recordings: [...s.recordings, handle],
|
|
141
|
+
}));
|
|
142
|
+
return handle;
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
recordings: state.recordings,
|
|
147
|
+
groups: [],
|
|
148
|
+
active: state.active,
|
|
149
|
+
isRecording: (flightId: number) => state.active.some((a) => a.flightId === flightId),
|
|
150
|
+
start,
|
|
151
|
+
stop,
|
|
152
|
+
startGroup: vi.fn(),
|
|
153
|
+
stopGroup: vi.fn(),
|
|
154
|
+
discard: vi.fn(),
|
|
155
|
+
discardGroup: vi.fn(),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { useRecordings };
|
|
160
|
+
});
|
|
161
|
+
|
|
46
162
|
// ---------------------------------------------------------------------------
|
|
47
163
|
// Camera-state fixture factory
|
|
48
164
|
// ---------------------------------------------------------------------------
|
|
@@ -156,6 +272,12 @@ function renderFeed(
|
|
|
156
272
|
renderSize?: "auto" | "none";
|
|
157
273
|
enableQualityControl?: boolean;
|
|
158
274
|
enableTracking?: boolean;
|
|
275
|
+
enableRecording?: boolean;
|
|
276
|
+
recordFullResolution?: boolean;
|
|
277
|
+
enableFullscreen?: boolean;
|
|
278
|
+
enablePictureInPicture?: boolean;
|
|
279
|
+
actions?: FeedAction[];
|
|
280
|
+
trailingActions?: FeedAction[];
|
|
159
281
|
showStatic?: boolean;
|
|
160
282
|
showStandbyIcon?: boolean;
|
|
161
283
|
showActions?: boolean;
|
|
@@ -216,6 +338,9 @@ afterEach(() => {
|
|
|
216
338
|
}
|
|
217
339
|
createdClients.length = 0;
|
|
218
340
|
vi.restoreAllMocks();
|
|
341
|
+
recordingsStoreSpies.start.mockClear();
|
|
342
|
+
recordingsStoreSpies.stop.mockClear();
|
|
343
|
+
recordingsStoreSpies.stopRejects = false;
|
|
219
344
|
});
|
|
220
345
|
|
|
221
346
|
// ---------------------------------------------------------------------------
|
|
@@ -1412,6 +1537,50 @@ describe("CameraFeed - showActions", () => {
|
|
|
1412
1537
|
});
|
|
1413
1538
|
});
|
|
1414
1539
|
|
|
1540
|
+
// ---------------------------------------------------------------------------
|
|
1541
|
+
// Action-bar overflow threshold crossing -- never-remount regression
|
|
1542
|
+
// ---------------------------------------------------------------------------
|
|
1543
|
+
|
|
1544
|
+
describe("CameraFeed - action bar overflow threshold crossing", () => {
|
|
1545
|
+
it("never remounts the <video> element when the actions count crosses the overflow threshold", async () => {
|
|
1546
|
+
const { client } = await buildConnectedSource();
|
|
1547
|
+
|
|
1548
|
+
function Harness() {
|
|
1549
|
+
const [count, setCount] = useState(3);
|
|
1550
|
+
const actions = Array.from({ length: count }, (_, i) => ({
|
|
1551
|
+
id: `extra-${i}`,
|
|
1552
|
+
label: `Extra ${i}`,
|
|
1553
|
+
icon: <span>{i}</span>,
|
|
1554
|
+
onClick: () => {},
|
|
1555
|
+
}));
|
|
1556
|
+
return (
|
|
1557
|
+
<KerbcastProvider client={client}>
|
|
1558
|
+
<CameraFeed flightId={42} actions={actions} />
|
|
1559
|
+
<button type="button" onClick={() => setCount(4)}>
|
|
1560
|
+
grow
|
|
1561
|
+
</button>
|
|
1562
|
+
</KerbcastProvider>
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const { container } = render(<Harness />);
|
|
1567
|
+
const before = container.querySelector("video");
|
|
1568
|
+
expect(before).toBeTruthy();
|
|
1569
|
+
// Below the threshold (3 total): every action is inline, no ⋮ trigger.
|
|
1570
|
+
expect(screen.queryByRole("button", { name: /more actions/i })).toBeNull();
|
|
1571
|
+
|
|
1572
|
+
await act(async () => {
|
|
1573
|
+
fireEvent.click(screen.getByRole("button", { name: /^grow$/i }));
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
// Crossed the threshold (4 total, all non-stateful >= 2): the ⋮ trigger
|
|
1577
|
+
// now appears -- but the SAME <video> node is still there, never remounted.
|
|
1578
|
+
expect(screen.getByRole("button", { name: /more actions/i })).toBeTruthy();
|
|
1579
|
+
const after = container.querySelector("video");
|
|
1580
|
+
expect(after).toBe(before);
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1415
1584
|
// ---------------------------------------------------------------------------
|
|
1416
1585
|
// Pan reticle
|
|
1417
1586
|
// ---------------------------------------------------------------------------
|
|
@@ -1846,3 +2015,379 @@ describe("CameraFeed - tracking (crosshair)", () => {
|
|
|
1846
2015
|
expect((getByLabelText("Pan left") as HTMLButtonElement).disabled).toBe(false);
|
|
1847
2016
|
});
|
|
1848
2017
|
});
|
|
2018
|
+
|
|
2019
|
+
// ---------------------------------------------------------------------------
|
|
2020
|
+
// REC (per-feed recording)
|
|
2021
|
+
// ---------------------------------------------------------------------------
|
|
2022
|
+
|
|
2023
|
+
describe("CameraFeed - REC", () => {
|
|
2024
|
+
it("does not render REC without enableRecording", async () => {
|
|
2025
|
+
const { client } = await buildConnectedSource();
|
|
2026
|
+
const { queryByLabelText } = renderFeed(client, { flightId: 42 });
|
|
2027
|
+
expect(queryByLabelText("Start recording")).toBeNull();
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
it("renders an idle REC toggle when enableRecording", async () => {
|
|
2031
|
+
const { client } = await buildConnectedSource();
|
|
2032
|
+
const { getByLabelText, queryByLabelText } = renderFeed(client, {
|
|
2033
|
+
flightId: 42,
|
|
2034
|
+
enableRecording: true,
|
|
2035
|
+
});
|
|
2036
|
+
const rec = getByLabelText("Start recording");
|
|
2037
|
+
expect(rec.getAttribute("aria-pressed")).toBe("false");
|
|
2038
|
+
// No red-dot/timer badge while idle.
|
|
2039
|
+
expect(queryByLabelText("Recording")).toBeNull();
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
it("clicking idle REC starts a recording via the store", async () => {
|
|
2043
|
+
const { client } = await buildConnectedSource();
|
|
2044
|
+
const { getByLabelText } = renderFeed(client, {
|
|
2045
|
+
flightId: 42,
|
|
2046
|
+
enableRecording: true,
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2050
|
+
|
|
2051
|
+
expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, { forceFullResolution: false });
|
|
2052
|
+
const rec = getByLabelText("Stop recording");
|
|
2053
|
+
expect(rec.getAttribute("aria-pressed")).toBe("true");
|
|
2054
|
+
expect(getByLabelText("Recording")).toBeTruthy();
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
it("clicking active REC stops the recording via the store", async () => {
|
|
2058
|
+
const { client } = await buildConnectedSource();
|
|
2059
|
+
const { getByLabelText, queryByLabelText } = renderFeed(client, {
|
|
2060
|
+
flightId: 42,
|
|
2061
|
+
enableRecording: true,
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2065
|
+
await act(async () => {
|
|
2066
|
+
fireEvent.click(getByLabelText("Stop recording"));
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
expect(recordingsStoreSpies.stop).toHaveBeenCalledWith("rec-42");
|
|
2070
|
+
expect(getByLabelText("Start recording").getAttribute("aria-pressed")).toBe("false");
|
|
2071
|
+
expect(queryByLabelText("Recording")).toBeNull();
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
it("does not leave an unhandled rejection when the store's stop() fails", async () => {
|
|
2075
|
+
recordingsStoreSpies.stopRejects = true;
|
|
2076
|
+
const { client } = await buildConnectedSource();
|
|
2077
|
+
const { getByLabelText } = renderFeed(client, {
|
|
2078
|
+
flightId: 42,
|
|
2079
|
+
enableRecording: true,
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2083
|
+
await act(async () => {
|
|
2084
|
+
fireEvent.click(getByLabelText("Stop recording"));
|
|
2085
|
+
await Promise.resolve();
|
|
2086
|
+
await Promise.resolve();
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
/* Reaching here without vitest reporting an unhandled rejection is the
|
|
2090
|
+
assertion: toggleRecording's void recordings.stop(...) must catch. */
|
|
2091
|
+
expect(recordingsStoreSpies.stop).toHaveBeenCalledWith("rec-42");
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
it("reads recording state from the store, not local state", async () => {
|
|
2095
|
+
// Two feeds on independent flightIds: starting one's recording must not
|
|
2096
|
+
// flip the other's REC control -- isRecording(flightId) is per-feed.
|
|
2097
|
+
const { client } = await buildConnectedSource([
|
|
2098
|
+
makeCamera({ flightId: 42, cameraName: "Cam A" }),
|
|
2099
|
+
makeCamera({ flightId: 43, cameraName: "Cam B" }),
|
|
2100
|
+
]);
|
|
2101
|
+
renderFeed(client, { flightId: 42, enableRecording: true });
|
|
2102
|
+
renderFeed(client, { flightId: 43, enableRecording: true });
|
|
2103
|
+
|
|
2104
|
+
fireEvent.click(screen.getAllByLabelText("Start recording")[0]);
|
|
2105
|
+
|
|
2106
|
+
const stopButtons = screen.queryAllByLabelText("Stop recording");
|
|
2107
|
+
const idleButtons = screen.queryAllByLabelText("Start recording");
|
|
2108
|
+
expect(stopButtons).toHaveLength(1);
|
|
2109
|
+
expect(idleButtons).toHaveLength(1);
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("shows a live elapsed timer while recording", async () => {
|
|
2113
|
+
vi.useFakeTimers({
|
|
2114
|
+
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"],
|
|
2115
|
+
});
|
|
2116
|
+
try {
|
|
2117
|
+
const { client } = await buildConnectedSource();
|
|
2118
|
+
const { getByLabelText } = renderFeed(client, {
|
|
2119
|
+
flightId: 42,
|
|
2120
|
+
enableRecording: true,
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
act(() => {
|
|
2124
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2125
|
+
});
|
|
2126
|
+
expect(getByLabelText("Recording").textContent).toBe("REC 0:00");
|
|
2127
|
+
|
|
2128
|
+
await act(async () => {
|
|
2129
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
2130
|
+
});
|
|
2131
|
+
expect(getByLabelText("Recording").textContent).toBe("REC 0:03");
|
|
2132
|
+
} finally {
|
|
2133
|
+
vi.useRealTimers();
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
it("REC is a stateful primary that never enters the ⋮ overflow", async () => {
|
|
2138
|
+
const { client } = await buildConnectedSource();
|
|
2139
|
+
const extraActions: FeedAction[] = Array.from({ length: 3 }, (_, i) => ({
|
|
2140
|
+
id: `extra-${i}`,
|
|
2141
|
+
label: `Extra ${i}`,
|
|
2142
|
+
icon: <span>{i}</span>,
|
|
2143
|
+
onClick: () => {},
|
|
2144
|
+
}));
|
|
2145
|
+
const { getByLabelText, getByRole } = renderFeed(client, {
|
|
2146
|
+
flightId: 42,
|
|
2147
|
+
enableRecording: true,
|
|
2148
|
+
actions: extraActions,
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
// Crowded (>= 4 total, >= 2 non-stateful): the overflow trigger exists...
|
|
2152
|
+
expect(getByRole("button", { name: /more actions/i })).toBeTruthy();
|
|
2153
|
+
// ...but REC is directly queryable without opening it -- it never
|
|
2154
|
+
// collapsed into the menu.
|
|
2155
|
+
expect(getByLabelText("Start recording")).toBeTruthy();
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
it("never remounts the <video> element across a start/stop cycle", async () => {
|
|
2159
|
+
const { client } = await buildConnectedSource();
|
|
2160
|
+
const { container, getByLabelText } = renderFeed(client, {
|
|
2161
|
+
flightId: 42,
|
|
2162
|
+
enableRecording: true,
|
|
2163
|
+
});
|
|
2164
|
+
const before = container.querySelector("video");
|
|
2165
|
+
expect(before).toBeTruthy();
|
|
2166
|
+
|
|
2167
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2168
|
+
await act(async () => {
|
|
2169
|
+
fireEvent.click(getByLabelText("Stop recording"));
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
const after = container.querySelector("video");
|
|
2173
|
+
expect(after).toBe(before);
|
|
2174
|
+
});
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
describe("CameraFeed - recordFullResolution", () => {
|
|
2178
|
+
it("threads recordFullResolution: true into the store's start call", async () => {
|
|
2179
|
+
const { client } = await buildConnectedSource();
|
|
2180
|
+
const { getByLabelText } = renderFeed(client, {
|
|
2181
|
+
flightId: 42,
|
|
2182
|
+
enableRecording: true,
|
|
2183
|
+
recordFullResolution: true,
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2187
|
+
|
|
2188
|
+
expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, {
|
|
2189
|
+
forceFullResolution: true,
|
|
2190
|
+
});
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
it("sends forceFullResolution: false when the prop is absent (default)", async () => {
|
|
2194
|
+
const { client } = await buildConnectedSource();
|
|
2195
|
+
const { getByLabelText } = renderFeed(client, {
|
|
2196
|
+
flightId: 42,
|
|
2197
|
+
enableRecording: true,
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2201
|
+
|
|
2202
|
+
expect(recordingsStoreSpies.start).toHaveBeenCalledWith(42, {
|
|
2203
|
+
forceFullResolution: false,
|
|
2204
|
+
});
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("shows the ARMING pill instead of the REC badge while arming, then swaps to the REC badge", async () => {
|
|
2208
|
+
const { client } = await buildConnectedSource();
|
|
2209
|
+
const { getByLabelText, queryByLabelText } = renderFeed(client, {
|
|
2210
|
+
flightId: 42,
|
|
2211
|
+
enableRecording: true,
|
|
2212
|
+
recordFullResolution: true,
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2216
|
+
|
|
2217
|
+
expect(getByLabelText("Arming").textContent).toBe("ARMING");
|
|
2218
|
+
expect(queryByLabelText("Recording")).toBeNull();
|
|
2219
|
+
|
|
2220
|
+
await act(async () => {
|
|
2221
|
+
recordingsStoreSpies.resolveArming("rec-42");
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
expect(queryByLabelText("Arming")).toBeNull();
|
|
2225
|
+
expect(getByLabelText("Recording").textContent).toMatch(/^REC /);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
it("never remounts the <video> element across the arming -> recording transition", async () => {
|
|
2229
|
+
const { client } = await buildConnectedSource();
|
|
2230
|
+
const { container, getByLabelText } = renderFeed(client, {
|
|
2231
|
+
flightId: 42,
|
|
2232
|
+
enableRecording: true,
|
|
2233
|
+
recordFullResolution: true,
|
|
2234
|
+
});
|
|
2235
|
+
const before = container.querySelector("video");
|
|
2236
|
+
expect(before).toBeTruthy();
|
|
2237
|
+
|
|
2238
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2239
|
+
expect(getByLabelText("Arming")).toBeTruthy();
|
|
2240
|
+
|
|
2241
|
+
await act(async () => {
|
|
2242
|
+
recordingsStoreSpies.resolveArming("rec-42");
|
|
2243
|
+
});
|
|
2244
|
+
expect(getByLabelText("Recording")).toBeTruthy();
|
|
2245
|
+
|
|
2246
|
+
const after = container.querySelector("video");
|
|
2247
|
+
expect(after).toBe(before);
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
it("does not show the ARMING pill when recordFullResolution is false (default)", async () => {
|
|
2251
|
+
const { client } = await buildConnectedSource();
|
|
2252
|
+
const { getByLabelText, queryByLabelText } = renderFeed(client, {
|
|
2253
|
+
flightId: 42,
|
|
2254
|
+
enableRecording: true,
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2258
|
+
|
|
2259
|
+
expect(queryByLabelText("Arming")).toBeNull();
|
|
2260
|
+
expect(getByLabelText("Recording")).toBeTruthy();
|
|
2261
|
+
});
|
|
2262
|
+
});
|
|
2263
|
+
|
|
2264
|
+
describe("CameraFeed - recording locks source and close", () => {
|
|
2265
|
+
it("disables the camera source dropdown while recording, with a hint, and re-enables on stop", async () => {
|
|
2266
|
+
const { client } = await buildConnectedSource();
|
|
2267
|
+
const { getByLabelText, getByRole } = renderFeed(client, {
|
|
2268
|
+
flightId: 42,
|
|
2269
|
+
enableRecording: true,
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
const source = getByRole<HTMLButtonElement>("button", { name: /starboard cam/i });
|
|
2273
|
+
expect(source.disabled).toBe(false);
|
|
2274
|
+
|
|
2275
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2276
|
+
|
|
2277
|
+
expect(source.disabled).toBe(true);
|
|
2278
|
+
expect(source.title).toBe("Stop recording to change camera");
|
|
2279
|
+
|
|
2280
|
+
await act(async () => {
|
|
2281
|
+
fireEvent.click(getByLabelText("Stop recording"));
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
expect(source.disabled).toBe(false);
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
it("does not open the camera menu when the source dropdown is clicked while recording", async () => {
|
|
2288
|
+
const { client } = await buildConnectedSource([
|
|
2289
|
+
makeCamera({ flightId: 42, cameraName: "Starboard Cam" }),
|
|
2290
|
+
makeCamera({ flightId: 43, cameraName: "Nose Cam" }),
|
|
2291
|
+
]);
|
|
2292
|
+
const { getByLabelText, getByRole, queryAllByRole } = renderFeed(client, {
|
|
2293
|
+
flightId: 42,
|
|
2294
|
+
enableRecording: true,
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2298
|
+
fireEvent.click(getByRole("button", { name: /starboard cam/i }));
|
|
2299
|
+
|
|
2300
|
+
expect(queryAllByRole("menuitemradio")).toHaveLength(0);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
it("disables the pinned trailing close control while recording and re-enables on stop", async () => {
|
|
2304
|
+
const onClose = vi.fn();
|
|
2305
|
+
const { client } = await buildConnectedSource();
|
|
2306
|
+
const { getByLabelText } = renderFeed(client, {
|
|
2307
|
+
flightId: 42,
|
|
2308
|
+
enableRecording: true,
|
|
2309
|
+
trailingActions: [
|
|
2310
|
+
{ id: "close", label: "Close", icon: <span>x</span>, onClick: onClose },
|
|
2311
|
+
],
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
const close = getByLabelText("Close") as HTMLButtonElement;
|
|
2315
|
+
expect(close.disabled).toBe(false);
|
|
2316
|
+
|
|
2317
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2318
|
+
|
|
2319
|
+
expect(close.disabled).toBe(true);
|
|
2320
|
+
expect(close.title).toBe("Stop recording to close this feed");
|
|
2321
|
+
fireEvent.click(close);
|
|
2322
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
2323
|
+
|
|
2324
|
+
await act(async () => {
|
|
2325
|
+
fireEvent.click(getByLabelText("Stop recording"));
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
expect(close.disabled).toBe(false);
|
|
2329
|
+
fireEvent.click(close);
|
|
2330
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
it("keeps tracking, PiP, fullscreen, and the REC(stop) button enabled while recording", async () => {
|
|
2334
|
+
const fsDoc = document as Document & { fullscreenEnabled?: boolean };
|
|
2335
|
+
const pipDoc = document as Document & { pictureInPictureEnabled?: boolean };
|
|
2336
|
+
Object.defineProperty(fsDoc, "fullscreenEnabled", { value: true, configurable: true });
|
|
2337
|
+
Object.defineProperty(pipDoc, "pictureInPictureEnabled", { value: true, configurable: true });
|
|
2338
|
+
|
|
2339
|
+
try {
|
|
2340
|
+
const { client } = await buildConnectedSource([
|
|
2341
|
+
makeCamera({
|
|
2342
|
+
flightId: 42,
|
|
2343
|
+
cameraName: "Launchpad Cam",
|
|
2344
|
+
supportsPan: true,
|
|
2345
|
+
supportsZoom: true,
|
|
2346
|
+
panPitchMin: -90,
|
|
2347
|
+
panPitchMax: 90,
|
|
2348
|
+
}),
|
|
2349
|
+
]);
|
|
2350
|
+
const { getByLabelText, queryByLabelText } = renderFeed(client, {
|
|
2351
|
+
flightId: 42,
|
|
2352
|
+
enableRecording: true,
|
|
2353
|
+
enableTracking: true,
|
|
2354
|
+
enableFullscreen: true,
|
|
2355
|
+
enablePictureInPicture: true,
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
fireEvent.click(getByLabelText("Start recording"));
|
|
2359
|
+
|
|
2360
|
+
// Tracking + REC are stateful primaries, always inline; PiP + fullscreen
|
|
2361
|
+
// (non-stateful) collapse into the ⋮ overflow once the row is crowded
|
|
2362
|
+
// (REC + tracking + PiP + fullscreen == 4), so open it to reach them.
|
|
2363
|
+
const overflowTrigger = queryByLabelText("More actions");
|
|
2364
|
+
if (overflowTrigger) fireEvent.click(overflowTrigger);
|
|
2365
|
+
|
|
2366
|
+
expect((getByLabelText("Auto-track") as HTMLButtonElement).disabled).toBe(false);
|
|
2367
|
+
expect((getByLabelText("Picture in picture") as HTMLButtonElement).disabled).toBe(false);
|
|
2368
|
+
expect((getByLabelText("Enter fullscreen") as HTMLButtonElement).disabled).toBe(false);
|
|
2369
|
+
expect((getByLabelText("Stop recording") as HTMLButtonElement).disabled).toBe(false);
|
|
2370
|
+
} finally {
|
|
2371
|
+
delete (fsDoc as { fullscreenEnabled?: boolean }).fullscreenEnabled;
|
|
2372
|
+
delete (pipDoc as { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled;
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
it("leaves the source dropdown and close control enabled when not recording", async () => {
|
|
2377
|
+
const onClose = vi.fn();
|
|
2378
|
+
const { client } = await buildConnectedSource();
|
|
2379
|
+
const { getByLabelText, getByRole } = renderFeed(client, {
|
|
2380
|
+
flightId: 42,
|
|
2381
|
+
enableRecording: true,
|
|
2382
|
+
trailingActions: [
|
|
2383
|
+
{ id: "close", label: "Close", icon: <span>x</span>, onClick: onClose },
|
|
2384
|
+
],
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
expect(getByRole<HTMLButtonElement>("button", { name: /starboard cam/i }).disabled).toBe(
|
|
2388
|
+
false,
|
|
2389
|
+
);
|
|
2390
|
+
fireEvent.click(getByLabelText("Close"));
|
|
2391
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
2392
|
+
});
|
|
2393
|
+
});
|