@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.
@@ -0,0 +1,288 @@
1
+ /*
2
+ * RecordingsTray.
3
+ *
4
+ * `useRecordings()` is mocked with a store that mirrors the real hook's shape
5
+ * (its own per-mount useState, so `discard`/`discardGroup` naturally re-render
6
+ * the tray exactly like the real hook does) seeded per test via
7
+ * `fakeStoreSeed` (shared with the mock factory through `vi.hoisted`, the
8
+ * documented way to reach into a hoisted `vi.mock`). This keeps the tray's
9
+ * rendering/formatting/object-URL logic under test without a real
10
+ * MediaRecorder track (jsdom's noise-pipeline stub has none -- see the same
11
+ * note in CameraFeed.test.tsx).
12
+ *
13
+ * jsdom has no working `URL.createObjectURL`/`revokeObjectURL`, so both are
14
+ * stubbed per test (same pattern as web/src/mock/driver.test.ts).
15
+ */
16
+
17
+ import type { GroupedRecordingHandle, RecordingHandle } from "@ksp-gonogo/kerbcast";
18
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
+ import type { ActiveRecordingInfo } from "./hooks/useRecordings";
21
+ import { RecordingsTray } from "./RecordingsTray";
22
+
23
+ const fakeStoreSeed = vi.hoisted(() => ({
24
+ recordings: [] as unknown[],
25
+ groups: [] as unknown[],
26
+ active: [] as unknown[],
27
+ }));
28
+
29
+ const storeSpies = vi.hoisted(() => ({
30
+ discard: vi.fn(),
31
+ discardGroup: vi.fn(),
32
+ }));
33
+
34
+ vi.mock("./hooks/useRecordings", async () => {
35
+ const { useState } = await import("react");
36
+
37
+ function useRecordings() {
38
+ const [state, setState] = useState(() => ({
39
+ recordings: fakeStoreSeed.recordings,
40
+ groups: fakeStoreSeed.groups,
41
+ active: fakeStoreSeed.active,
42
+ }));
43
+
44
+ return {
45
+ recordings: state.recordings,
46
+ groups: state.groups,
47
+ active: state.active,
48
+ isRecording: () => false,
49
+ start: vi.fn(),
50
+ stop: vi.fn(),
51
+ startGroup: vi.fn(),
52
+ stopGroup: vi.fn(),
53
+ discard: (recordingId: string) => {
54
+ storeSpies.discard(recordingId);
55
+ setState((s) => ({
56
+ ...s,
57
+ recordings: (s.recordings as { recordingId: string }[]).filter(
58
+ (r) => r.recordingId !== recordingId,
59
+ ),
60
+ }));
61
+ },
62
+ discardGroup: (groupId: string) => {
63
+ storeSpies.discardGroup(groupId);
64
+ setState((s) => ({
65
+ ...s,
66
+ groups: (s.groups as { groupId: string }[]).filter((g) => g.groupId !== groupId),
67
+ }));
68
+ },
69
+ };
70
+ }
71
+
72
+ return { useRecordings };
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Fixtures
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function makeRecording(overrides: Partial<RecordingHandle> = {}): RecordingHandle {
80
+ return {
81
+ recordingId: "rec-1",
82
+ flightId: 42,
83
+ blob: new Blob(["clip"], { type: "video/webm" }),
84
+ mimeType: "video/webm",
85
+ utStart: 100,
86
+ utEnd: 160,
87
+ utSamples: [],
88
+ byteSize: 2048,
89
+ durationMs: 65_000,
90
+ ...overrides,
91
+ };
92
+ }
93
+
94
+ function makeGroup(overrides: Partial<GroupedRecordingHandle> = {}): GroupedRecordingHandle {
95
+ return {
96
+ groupId: "grp-1",
97
+ recordings: [
98
+ makeRecording({ recordingId: "rec-a" }),
99
+ makeRecording({ recordingId: "rec-b" }),
100
+ ],
101
+ commonUtWindow: [100, 150],
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ function activeInfo(overrides: Partial<ActiveRecordingInfo> = {}): ActiveRecordingInfo {
107
+ return { recordingId: "rec-1", flightId: 42, startedAt: 0, ...overrides };
108
+ }
109
+
110
+ let urlCounter = 0;
111
+
112
+ beforeEach(() => {
113
+ urlCounter = 0;
114
+ vi.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:mock-${urlCounter++}`);
115
+ vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
116
+ });
117
+
118
+ afterEach(() => {
119
+ cleanup();
120
+ vi.restoreAllMocks();
121
+ storeSpies.discard.mockClear();
122
+ storeSpies.discardGroup.mockClear();
123
+ fakeStoreSeed.recordings = [];
124
+ fakeStoreSeed.groups = [];
125
+ fakeStoreSeed.active = [];
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Tests
130
+ // ---------------------------------------------------------------------------
131
+
132
+ describe("RecordingsTray - empty state", () => {
133
+ it("shows a placeholder when there are no clips", () => {
134
+ render(<RecordingsTray />);
135
+ expect(screen.getByText(/no recordings yet/i)).toBeTruthy();
136
+ expect(screen.queryByText(/unsaved clips/i)).toBeNull();
137
+ });
138
+ });
139
+
140
+ describe("RecordingsTray - single clips", () => {
141
+ it("renders a clip's camera name, UT range, duration, and size", () => {
142
+ fakeStoreSeed.active = [activeInfo({ recordingId: "rec-1", flightId: 42 })];
143
+ fakeStoreSeed.recordings = [
144
+ makeRecording({ recordingId: "rec-1", utStart: 100, utEnd: 160, durationMs: 65_000, byteSize: 2048 }),
145
+ ];
146
+
147
+ render(<RecordingsTray cameraLabel={(flightId) => `Cam ${flightId}`} />);
148
+
149
+ expect(screen.getByText("Cam 42")).toBeTruthy();
150
+ expect(screen.getByText(/T\+100s.*T\+160s/)).toBeTruthy();
151
+ expect(screen.getByText(/1:05/)).toBeTruthy(); // 65s
152
+ expect(screen.getByText(/2\.0 KB/)).toBeTruthy();
153
+ expect(screen.getByText(/unsaved clips/i)).toBeTruthy();
154
+ });
155
+
156
+ it("falls back to 'Camera <flightId>' when no cameraLabel prop is given", () => {
157
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 7 })];
158
+ render(<RecordingsTray />);
159
+ expect(screen.getByText("Camera 7")).toBeTruthy();
160
+ });
161
+
162
+ it("labels a finished clip from its own flightId, even when the tray mounts after the recording finished (never in `active`)", () => {
163
+ fakeStoreSeed.active = []; // the tray mounted after the clip finished
164
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", flightId: 99 })];
165
+ render(<RecordingsTray cameraLabel={(flightId) => `Cam ${flightId}`} />);
166
+ expect(screen.getByText("Cam 99")).toBeTruthy();
167
+ });
168
+
169
+ it("shows 'no UT' when the clip has no UT range", () => {
170
+ fakeStoreSeed.recordings = [
171
+ makeRecording({ recordingId: "rec-1", utStart: undefined, utEnd: undefined }),
172
+ ];
173
+ render(<RecordingsTray />);
174
+ expect(screen.getByText(/no ut \(out of flight\)/i)).toBeTruthy();
175
+ });
176
+
177
+ it("download uses an object URL created from the clip's blob", () => {
178
+ const blob = new Blob(["specific"], { type: "video/webm" });
179
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1", blob })];
180
+ render(<RecordingsTray />);
181
+
182
+ expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
183
+ const link = screen.getByText("Download").closest("a");
184
+ expect(link?.getAttribute("href")).toBe("blob:mock-0");
185
+ expect(link?.hasAttribute("download")).toBe(true);
186
+ });
187
+
188
+ it("mounts a <video> preview sourced from the clip's object URL", () => {
189
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1" })];
190
+ const { container } = render(<RecordingsTray />);
191
+ const video = container.querySelector("video");
192
+ expect(video).toBeTruthy();
193
+ expect(video?.getAttribute("src")).toBe("blob:mock-0");
194
+ });
195
+
196
+ it("discard calls the store and revokes the clip's object URL", () => {
197
+ fakeStoreSeed.recordings = [makeRecording({ recordingId: "rec-1" })];
198
+ render(<RecordingsTray />);
199
+
200
+ fireEvent.click(screen.getByText("Discard"));
201
+
202
+ expect(storeSpies.discard).toHaveBeenCalledWith("rec-1");
203
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-0");
204
+ expect(screen.queryByText("Discard")).toBeNull();
205
+ });
206
+ });
207
+
208
+ describe("RecordingsTray - grouped sets", () => {
209
+ it("renders a group's clips together with a download-all", () => {
210
+ fakeStoreSeed.groups = [
211
+ makeGroup({
212
+ groupId: "grp-1",
213
+ recordings: [
214
+ makeRecording({ recordingId: "rec-a" }),
215
+ makeRecording({ recordingId: "rec-b" }),
216
+ ],
217
+ }),
218
+ ];
219
+ render(<RecordingsTray />);
220
+
221
+ expect(screen.getByText(/grouped recording/i)).toBeTruthy();
222
+ expect(screen.getByText(/2 feeds/i)).toBeTruthy();
223
+ expect(screen.getAllByText("Download")).toHaveLength(2);
224
+ expect(screen.getByText("Download all")).toBeTruthy();
225
+ });
226
+
227
+ it("download-all clicks one <a> per member", () => {
228
+ fakeStoreSeed.groups = [
229
+ makeGroup({
230
+ groupId: "grp-1",
231
+ recordings: [
232
+ makeRecording({ recordingId: "rec-a" }),
233
+ makeRecording({ recordingId: "rec-b" }),
234
+ ],
235
+ }),
236
+ ];
237
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
238
+ render(<RecordingsTray />);
239
+
240
+ fireEvent.click(screen.getByText("Download all"));
241
+
242
+ expect(clickSpy).toHaveBeenCalledTimes(2);
243
+ });
244
+
245
+ it("labels grouped clips from their own flightId, with no active info needed", () => {
246
+ fakeStoreSeed.active = [];
247
+ fakeStoreSeed.groups = [
248
+ makeGroup({
249
+ groupId: "grp-1",
250
+ recordings: [
251
+ makeRecording({ recordingId: "rec-a", flightId: 10 }),
252
+ makeRecording({ recordingId: "rec-b", flightId: 11 }),
253
+ ],
254
+ }),
255
+ ];
256
+ render(<RecordingsTray cameraLabel={(flightId) => `Cam ${flightId}`} />);
257
+
258
+ expect(screen.getByText("Cam 10")).toBeTruthy();
259
+ expect(screen.getByText("Cam 11")).toBeTruthy();
260
+ });
261
+
262
+ it("discard set calls the store's discardGroup, not a per-clip discard", () => {
263
+ fakeStoreSeed.groups = [makeGroup({ groupId: "grp-1" })];
264
+ render(<RecordingsTray />);
265
+
266
+ fireEvent.click(screen.getByText("Discard set"));
267
+
268
+ expect(storeSpies.discardGroup).toHaveBeenCalledWith("grp-1");
269
+ expect(storeSpies.discard).not.toHaveBeenCalled();
270
+ expect(screen.queryByText(/grouped recording/i)).toBeNull();
271
+ });
272
+ });
273
+
274
+ describe("RecordingsTray - object URL lifecycle", () => {
275
+ it("revokes every outstanding object URL on unmount", () => {
276
+ fakeStoreSeed.recordings = [
277
+ makeRecording({ recordingId: "rec-1" }),
278
+ makeRecording({ recordingId: "rec-2" }),
279
+ ];
280
+ const { unmount } = render(<RecordingsTray />);
281
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
282
+
283
+ unmount();
284
+
285
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-0");
286
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-1");
287
+ });
288
+ });