@ksp-gonogo/kerbcast-react 1.6.3 → 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 +54 -0
- package/README.md +19 -0
- package/dist/CameraFeed.d.ts +63 -19
- package/dist/CameraFeed.js +329 -202
- package/dist/FeedActionBar.d.ts +133 -0
- package/dist/FeedActionBar.js +225 -0
- package/dist/KerbalFaceFeed.d.ts +82 -0
- package/dist/KerbalFaceFeed.js +111 -0
- package/dist/RecordingsTray.d.ts +37 -0
- package/dist/RecordingsTray.js +279 -0
- package/dist/context.d.ts +27 -1
- package/dist/context.js +68 -2
- package/dist/hooks/useRecordings.d.ts +35 -0
- package/dist/hooks/useRecordings.js +33 -0
- package/dist/hooks/useReportDisplaySize.d.ts +23 -0
- package/dist/hooks/useReportDisplaySize.js +71 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +13 -1
- 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 +719 -10
- package/src/CameraFeed.tsx +586 -334
- package/src/FeedActionBar.test.ts +196 -0
- package/src/FeedActionBar.test.tsx +215 -0
- package/src/FeedActionBar.tsx +391 -0
- package/src/KerbalFaceFeed.test.tsx +203 -0
- package/src/KerbalFaceFeed.tsx +214 -0
- package/src/RecordingsTray.test.tsx +288 -0
- package/src/RecordingsTray.tsx +475 -0
- package/src/context.test.tsx +94 -0
- package/src/context.tsx +98 -1
- 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/hooks/useReportDisplaySize.test.tsx +166 -0
- package/src/hooks/useReportDisplaySize.ts +91 -0
- package/src/index.ts +27 -1
- package/src/menuPositioning.ts +126 -0
- package/src/timing.test.ts +34 -0
- package/src/timing.ts +21 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KerbalFaceFeed — the shared single-kerbal-face primitive.
|
|
3
|
+
*
|
|
4
|
+
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
5
|
+
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
6
|
+
* surface (gonogo) composes: identity-keyed by `flightId`, it binds the live
|
|
7
|
+
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
8
|
+
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
9
|
+
* technique `CameraFeed` uses).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately minimal chrome: just the square live face plus a standby glyph
|
|
12
|
+
* when there is no live signal. Everything else — name label, IVA/EVA badge,
|
|
13
|
+
* click behaviour, a "SIGNAL LOST" treatment — is layered OVER it by the
|
|
14
|
+
* wrapper via `children`, driven by the `onFeedStateChange` signal. A thin
|
|
15
|
+
* `actions` bar (the shared `FeedAction` shape) is rendered top-right when a
|
|
16
|
+
* wrapper supplies one, so declarative controls compose the same way they do
|
|
17
|
+
* on `CameraFeed`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
21
|
+
import styled from "styled-components";
|
|
22
|
+
|
|
23
|
+
import { CameraStreamHook, FeedAction } from "./CameraFeed";
|
|
24
|
+
import { FeedActionBar, feedActionToEntry } from "./FeedActionBar";
|
|
25
|
+
import { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
26
|
+
import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
27
|
+
import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
28
|
+
import { isCameraDestroyed } from "./lifecycle";
|
|
29
|
+
import { StandbyIcon } from "./StandbyIcon";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Coarse presentation state a wrapper reacts to for its own fallback/badge:
|
|
33
|
+
* - `live`: a stream is bound and playing.
|
|
34
|
+
* - `no-signal`: no stream yet (connecting, out of flight, or not visible).
|
|
35
|
+
* - `destroyed`: the camera's part/kerbal is gone (the wrapper renders SIGNAL
|
|
36
|
+
* LOST; the last frame stays on-screen because the `<video>` is not cleared).
|
|
37
|
+
*/
|
|
38
|
+
export type KerbalFeedState = "live" | "no-signal" | "destroyed";
|
|
39
|
+
|
|
40
|
+
export interface KerbalFaceFeedProps {
|
|
41
|
+
/** Wire-id (`CameraState.flightId`) of the kerbal face camera to display. */
|
|
42
|
+
flightId: number;
|
|
43
|
+
/**
|
|
44
|
+
* Explicit square side in px, for LAYOUT only. When omitted the feed fills
|
|
45
|
+
* its parent's width at a 1:1 aspect ratio. This does NOT drive resolution:
|
|
46
|
+
* the primitive always self-measures its rendered box and reports that to the
|
|
47
|
+
* sidecar (auto-resolution). Pass it to fix the element's size, not to
|
|
48
|
+
* request a stream resolution.
|
|
49
|
+
*/
|
|
50
|
+
size?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Report the self-measured display size to the sidecar to drive
|
|
53
|
+
* auto-resolution. Default true. Set false for a fixed-resolution feed that
|
|
54
|
+
* should not participate in auto-res. Independent of {@link showActions}
|
|
55
|
+
* (a resolution concern, not a UI one).
|
|
56
|
+
*/
|
|
57
|
+
reportSize?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Consumer action buttons, rendered as a minimal top-right bar (the shared
|
|
60
|
+
* {@link FeedAction} shape). Omit for no bar — the common case, since a crew
|
|
61
|
+
* bar usually composes its controls in `children` instead.
|
|
62
|
+
*/
|
|
63
|
+
actions?: FeedAction[];
|
|
64
|
+
/**
|
|
65
|
+
* Render the feed's action UI (the top-right action bar). Default true. Set
|
|
66
|
+
* false to suppress the bar entirely (e.g. a tiny avatar where hover controls
|
|
67
|
+
* add nothing). Does not affect display-size reporting.
|
|
68
|
+
*/
|
|
69
|
+
showActions?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Overlay content layered OVER the square face: name label, IVA/EVA badge,
|
|
72
|
+
* click target, a custom destroyed treatment. Absolutely positioned to fill
|
|
73
|
+
* the frame; the primitive keeps its own chrome out of the way.
|
|
74
|
+
*/
|
|
75
|
+
children?: React.ReactNode;
|
|
76
|
+
/**
|
|
77
|
+
* Fired whenever the derived feed state changes, so the wrapper can swap its
|
|
78
|
+
* fallback/badge without polling. Fires once on mount with the initial state.
|
|
79
|
+
*/
|
|
80
|
+
onFeedStateChange?: (state: KerbalFeedState) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Draw the built-in standby glyph while not `live`. Default true. Set false
|
|
83
|
+
* when the wrapper renders its own out-of-signal overlay (so it isn't drawn
|
|
84
|
+
* twice); the frame then just stays dark / holds the last frame.
|
|
85
|
+
*/
|
|
86
|
+
showStandby?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Override how the stream is sourced (delayed playout, alternate transport).
|
|
89
|
+
* Must be a stable reference passed consistently across renders — it is
|
|
90
|
+
* called as a hook. Omit to use the built-in {@link useKerbcastStream}.
|
|
91
|
+
*/
|
|
92
|
+
useStream?: CameraStreamHook;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const Frame = styled.div<{ $size?: number }>`
|
|
96
|
+
position: relative;
|
|
97
|
+
${(p) => (p.$size != null ? `width: ${p.$size}px; height: ${p.$size}px;` : "width: 100%;")}
|
|
98
|
+
aspect-ratio: 1 / 1;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
background: #000;
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
const Face = styled.video`
|
|
105
|
+
width: 100%;
|
|
106
|
+
height: 100%;
|
|
107
|
+
/* Cover the square: the portrait fills the frame, cropping the excess,
|
|
108
|
+
rather than letterboxing — a face tile reads best edge-to-edge. */
|
|
109
|
+
object-fit: cover;
|
|
110
|
+
display: block;
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
const StandbyLayer = styled.div`
|
|
114
|
+
position: absolute;
|
|
115
|
+
inset: 0;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
color: rgba(255, 255, 255, 0.35);
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const DestroyedScrim = styled.div`
|
|
124
|
+
position: absolute;
|
|
125
|
+
inset: 0;
|
|
126
|
+
background: rgba(0, 0, 0, 0.45);
|
|
127
|
+
pointer-events: none;
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const OverlayLayer = styled.div`
|
|
131
|
+
position: absolute;
|
|
132
|
+
inset: 0;
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
/*
|
|
136
|
+
* The shared FeedActionBar is hover-revealed by default (CameraFeed's larger
|
|
137
|
+
* frame fades it in on hover/focus). A tiny face tile has no such affordance,
|
|
138
|
+
* so this override keeps the original always-visible, corner-hugging look;
|
|
139
|
+
* layout/button styling (flex row, gap, the shared OverlayIconButton) still
|
|
140
|
+
* comes from FeedActionBar itself.
|
|
141
|
+
*/
|
|
142
|
+
const KerbalActionBarRow = styled(FeedActionBar)`
|
|
143
|
+
top: 4px;
|
|
144
|
+
right: 4px;
|
|
145
|
+
opacity: 1;
|
|
146
|
+
pointer-events: auto;
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
export function KerbalFaceFeed({
|
|
150
|
+
flightId,
|
|
151
|
+
size,
|
|
152
|
+
reportSize = true,
|
|
153
|
+
actions,
|
|
154
|
+
showActions = true,
|
|
155
|
+
children,
|
|
156
|
+
onFeedStateChange,
|
|
157
|
+
showStandby = true,
|
|
158
|
+
useStream,
|
|
159
|
+
}: KerbalFaceFeedProps) {
|
|
160
|
+
// Match CameraFeed: the built-in hook (which acquires/releases the
|
|
161
|
+
// subscription slot) runs only when no override is supplied.
|
|
162
|
+
const resolveStream = useStream ?? useKerbcastStream;
|
|
163
|
+
const stream = resolveStream(flightId);
|
|
164
|
+
|
|
165
|
+
// The camera's lifecycle comes from the registry snapshot; destroyed cameras
|
|
166
|
+
// linger in the list so the wrapper can show SIGNAL LOST.
|
|
167
|
+
const cameras = useKerbcastCameras();
|
|
168
|
+
const destroyed = cameras.some((c) => c.flightId === flightId && isCameraDestroyed(c));
|
|
169
|
+
|
|
170
|
+
const state: KerbalFeedState = destroyed
|
|
171
|
+
? "destroyed"
|
|
172
|
+
: stream
|
|
173
|
+
? "live"
|
|
174
|
+
: "no-signal";
|
|
175
|
+
|
|
176
|
+
// Self-measure the square frame and report it to the sidecar (auto-res).
|
|
177
|
+
// Reporting follows the mount lifecycle; the registry collapses multiple
|
|
178
|
+
// feeds of the same kerbal to one MAX report.
|
|
179
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
180
|
+
useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
|
|
181
|
+
|
|
182
|
+
// Bind the stream to the persistent <video> without remounting it — the
|
|
183
|
+
// element stays mounted across every re-layout / prop change, only its
|
|
184
|
+
// srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
|
|
185
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (videoRef.current && stream) {
|
|
188
|
+
videoRef.current.srcObject = stream;
|
|
189
|
+
}
|
|
190
|
+
}, [stream]);
|
|
191
|
+
|
|
192
|
+
// Notify the wrapper on every state transition (and once on mount).
|
|
193
|
+
const notify = useRef(onFeedStateChange);
|
|
194
|
+
notify.current = onFeedStateChange;
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
notify.current?.(state);
|
|
197
|
+
}, [state]);
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Frame ref={frameRef} $size={size} data-testid="kerbal-face-feed" data-feed-state={state}>
|
|
201
|
+
<Face ref={videoRef} autoPlay playsInline muted controls={false} />
|
|
202
|
+
{state === "destroyed" && <DestroyedScrim />}
|
|
203
|
+
{showStandby && state !== "live" && (
|
|
204
|
+
<StandbyLayer>
|
|
205
|
+
<StandbyIcon size={Math.round((size ?? 96) * 0.32)} />
|
|
206
|
+
</StandbyLayer>
|
|
207
|
+
)}
|
|
208
|
+
{showActions && actions && actions.length > 0 && (
|
|
209
|
+
<KerbalActionBarRow entries={actions.map(feedActionToEntry)} />
|
|
210
|
+
)}
|
|
211
|
+
{children && <OverlayLayer>{children}</OverlayLayer>}
|
|
212
|
+
</Frame>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -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
|
+
});
|