@ksp-gonogo/kerbcast 1.4.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 +186 -0
- package/LICENSE +369 -0
- package/README.md +112 -0
- package/dist/__generated__/types.d.ts +529 -0
- package/dist/__generated__/types.js +50 -0
- package/dist/client.d.ts +433 -0
- package/dist/client.js +821 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/noise.d.ts +34 -0
- package/dist/noise.js +274 -0
- package/dist/panZoom.d.ts +190 -0
- package/dist/panZoom.js +302 -0
- package/dist/testing/index.d.ts +217 -0
- package/dist/testing/index.js +606 -0
- package/package.json +57 -0
- package/src/__generated__/types.ts +488 -0
- package/src/client.test.ts +1673 -0
- package/src/client.ts +1219 -0
- package/src/domStubs.test.ts +49 -0
- package/src/index.ts +29 -0
- package/src/noise.test.ts +340 -0
- package/src/noise.ts +317 -0
- package/src/panZoom.test.ts +404 -0
- package/src/panZoom.ts +405 -0
- package/src/testing/index.ts +689 -0
- package/src/testing.test.ts +156 -0
package/dist/panZoom.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless pan/zoom control state machine extracted from the CameraFeed
|
|
3
|
+
* component. Manages rate deduplication, analog deadzone, optimistic
|
|
4
|
+
* accumulators for discrete nudges, debounced FoV slider, and echo-sync
|
|
5
|
+
* while idle -- all without any DOM or React dependency.
|
|
6
|
+
*
|
|
7
|
+
* `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
|
|
8
|
+
* camera handle directly as the sink and the controller will drive it.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const cam = client.camera(flightId);
|
|
12
|
+
* const ctrl = new PanZoomController(cam);
|
|
13
|
+
*
|
|
14
|
+
* // Analog stick input
|
|
15
|
+
* ctrl.setPanRate(stickX, stickY);
|
|
16
|
+
*
|
|
17
|
+
* // Discrete nudge buttons
|
|
18
|
+
* ctrl.nudgePan(1, 0); // pan right one step
|
|
19
|
+
* ctrl.nudgeZoom(-1); // zoom in one step
|
|
20
|
+
*
|
|
21
|
+
* // FoV slider
|
|
22
|
+
* ctrl.setFovSliderDragging(true);
|
|
23
|
+
* ctrl.fovSliderInput(newFov);
|
|
24
|
+
* ctrl.setFovSliderDragging(false); // flushes immediately
|
|
25
|
+
*
|
|
26
|
+
* // Sync echoed state (call from camera "change" listener)
|
|
27
|
+
* ctrl.syncFromState({ fov, panYaw, panPitch, fovMin, fovMax,
|
|
28
|
+
* panYawMin, panYawMax, panPitchMin, panPitchMax });
|
|
29
|
+
*
|
|
30
|
+
* // Cleanup on unmount
|
|
31
|
+
* ctrl.stop();
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
function clamp(v, lo, hi) {
|
|
38
|
+
return Math.max(lo, Math.min(hi, v));
|
|
39
|
+
}
|
|
40
|
+
function applyDeadzone(v, deadzone) {
|
|
41
|
+
return Math.abs(v) < deadzone ? 0 : v;
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// PanZoomController
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Headless pan/zoom state machine. Manages rate deduplication, analog
|
|
48
|
+
* deadzone, optimistic accumulators, FoV slider debounce, and echo-sync
|
|
49
|
+
* idle rules. Mirrors the behaviour of the CameraFeed component in gonogo
|
|
50
|
+
* (CameraFeed.tsx lines 323-592) without any React or DOM coupling.
|
|
51
|
+
*/
|
|
52
|
+
export class PanZoomController {
|
|
53
|
+
constructor(sink, opts = {}) {
|
|
54
|
+
// Last-sent rates (dedupe against re-sending the same value).
|
|
55
|
+
this.sentPanRate = { yaw: 0, pitch: 0 };
|
|
56
|
+
this.sentZoomRate = 0;
|
|
57
|
+
// Optimistic accumulators for discrete nudges.
|
|
58
|
+
this.localPan = { yaw: 0, pitch: 0 };
|
|
59
|
+
this.localFov = 0;
|
|
60
|
+
// FoV slider state.
|
|
61
|
+
this._sliderFov = 0;
|
|
62
|
+
this.sliderDragging = false;
|
|
63
|
+
this.fovDebounceTimer = null;
|
|
64
|
+
this.pendingFov = null;
|
|
65
|
+
// Drag-ball flag (used only for the idle rule; pixel math lives in the UI).
|
|
66
|
+
this.ballDragging = false;
|
|
67
|
+
// Last-known bounds for clamping.
|
|
68
|
+
this.bounds = {
|
|
69
|
+
fovMin: 0,
|
|
70
|
+
fovMax: 180,
|
|
71
|
+
panYawMin: -180,
|
|
72
|
+
panYawMax: 180,
|
|
73
|
+
panPitchMin: -90,
|
|
74
|
+
panPitchMax: 90,
|
|
75
|
+
};
|
|
76
|
+
// sliderFov change subscribers.
|
|
77
|
+
this.sliderFovListeners = new Set();
|
|
78
|
+
this.sink = sink;
|
|
79
|
+
this.panNudgeDeg = opts.panNudgeDeg ?? 5;
|
|
80
|
+
this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
|
|
81
|
+
this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
|
|
82
|
+
this.analogDeadzone = opts.analogDeadzone ?? 0.05;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Public getters
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
/**
|
|
88
|
+
* Optimistic FoV value tracked by the slider. Follows the pointer while
|
|
89
|
+
* dragging, then the camera echo when idle. Reactive via
|
|
90
|
+
* {@link onSliderFov}.
|
|
91
|
+
*/
|
|
92
|
+
get sliderFov() {
|
|
93
|
+
return this._sliderFov;
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Echo-sync
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
/**
|
|
99
|
+
* Sync from the camera's echoed state (call from the camera's `"change"`
|
|
100
|
+
* event listener). Applies the echoed pan to the pan accumulator only when
|
|
101
|
+
* the controller is pan-idle (no active pan rate, no active ball drag).
|
|
102
|
+
* Applies the echoed FoV to the accumulator and slider only when the
|
|
103
|
+
* controller is zoom-idle (zoom rate 0, slider not dragging, no pending
|
|
104
|
+
* debounced send). Mirrors CameraFeed.tsx:370-387.
|
|
105
|
+
*/
|
|
106
|
+
syncFromState(state) {
|
|
107
|
+
this.bounds = {
|
|
108
|
+
fovMin: state.fovMin,
|
|
109
|
+
fovMax: state.fovMax,
|
|
110
|
+
panYawMin: state.panYawMin,
|
|
111
|
+
panYawMax: state.panYawMax,
|
|
112
|
+
panPitchMin: state.panPitchMin,
|
|
113
|
+
panPitchMax: state.panPitchMax,
|
|
114
|
+
};
|
|
115
|
+
const panIdle = !this.ballDragging &&
|
|
116
|
+
this.sentPanRate.yaw === 0 &&
|
|
117
|
+
this.sentPanRate.pitch === 0;
|
|
118
|
+
if (panIdle) {
|
|
119
|
+
this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
|
|
120
|
+
}
|
|
121
|
+
const zoomIdle = this.sentZoomRate === 0 &&
|
|
122
|
+
!this.sliderDragging &&
|
|
123
|
+
this.pendingFov === null;
|
|
124
|
+
if (zoomIdle) {
|
|
125
|
+
this.localFov = state.fov;
|
|
126
|
+
this.setSliderFovInternal(state.fov);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Pan rate
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
/**
|
|
133
|
+
* Set a persistent normalised pan velocity (-1..1 per axis). Clamps,
|
|
134
|
+
* applies the analog deadzone, and deduplicates against the last-sent
|
|
135
|
+
* value before forwarding to the sink. Mirrors sendPanRate.
|
|
136
|
+
*/
|
|
137
|
+
setPanRate(yaw, pitch) {
|
|
138
|
+
const y = applyDeadzone(clamp(yaw, -1, 1), this.analogDeadzone);
|
|
139
|
+
const p = applyDeadzone(clamp(pitch, -1, 1), this.analogDeadzone);
|
|
140
|
+
if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch)
|
|
141
|
+
return;
|
|
142
|
+
this.sentPanRate = { yaw: y, pitch: p };
|
|
143
|
+
void this.sink.setPanRate(y, p);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Update one pan axis, preserving the other. Allows two independent inputs
|
|
147
|
+
* (e.g. a serial stick axis and the ball) to compose rather than clobber
|
|
148
|
+
* each other. Mirrors setPanAxis.
|
|
149
|
+
*/
|
|
150
|
+
setPanAxis(axis, value) {
|
|
151
|
+
const cur = this.sentPanRate;
|
|
152
|
+
if (axis === "yaw")
|
|
153
|
+
this.setPanRate(value, cur.pitch);
|
|
154
|
+
else
|
|
155
|
+
this.setPanRate(cur.yaw, value);
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Zoom rate
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* Set a persistent normalised zoom velocity (-1..1; +1 = zoom in, FoV
|
|
162
|
+
* decreasing). Clamps, applies deadzone, deduplicates. Mirrors sendZoomRate.
|
|
163
|
+
*/
|
|
164
|
+
setZoomRate(rate) {
|
|
165
|
+
const r = applyDeadzone(clamp(rate, -1, 1), this.analogDeadzone);
|
|
166
|
+
if (r === this.sentZoomRate)
|
|
167
|
+
return;
|
|
168
|
+
this.sentZoomRate = r;
|
|
169
|
+
void this.sink.setZoomRate(r);
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Discrete nudges
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Discrete pan step (+1/-1 per axis sign). Moves the pan accumulator by
|
|
176
|
+
* `panNudgeDeg`, clamped to bounds, then sends an absolute `setPan`.
|
|
177
|
+
* Mirrors nudgePan.
|
|
178
|
+
*/
|
|
179
|
+
nudgePan(yawSign, pitchSign) {
|
|
180
|
+
const b = this.bounds;
|
|
181
|
+
this.localPan.yaw = clamp(this.localPan.yaw + yawSign * this.panNudgeDeg, b.panYawMin, b.panYawMax);
|
|
182
|
+
this.localPan.pitch = clamp(this.localPan.pitch + pitchSign * this.panNudgeDeg, b.panPitchMin, b.panPitchMax);
|
|
183
|
+
void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Discrete FoV step. `deltaSign: -1` = zoom in (FoV decreases by
|
|
187
|
+
* `fovNudgeDeg`), `+1` = zoom out (FoV increases). Clamped to fov bounds.
|
|
188
|
+
* Mirrors nudgeZoom (which calls onFovChange internally).
|
|
189
|
+
*/
|
|
190
|
+
nudgeZoom(deltaSign) {
|
|
191
|
+
const b = this.bounds;
|
|
192
|
+
const next = clamp(this.localFov + deltaSign * this.fovNudgeDeg, b.fovMin, b.fovMax);
|
|
193
|
+
this.localFov = next;
|
|
194
|
+
void this.sink.setFov(next);
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// FoV slider
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
/**
|
|
200
|
+
* Update the optimistic slider value and schedule a debounced `setFov`.
|
|
201
|
+
* The settled (paused) value is what gets sent; rapid drag input does not
|
|
202
|
+
* stream a command per pixel. Mirrors scheduleFovSlider.
|
|
203
|
+
*/
|
|
204
|
+
fovSliderInput(fov) {
|
|
205
|
+
this.pendingFov = fov;
|
|
206
|
+
this.setSliderFovInternal(fov);
|
|
207
|
+
if (this.fovDebounceTimer !== null)
|
|
208
|
+
clearTimeout(this.fovDebounceTimer);
|
|
209
|
+
this.fovDebounceTimer = setTimeout(() => {
|
|
210
|
+
this.fovDebounceTimer = null;
|
|
211
|
+
this.flushFovSlider();
|
|
212
|
+
}, this.fovSliderDebounceMs);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Declare slider drag start/end. While dragging, echo-sync does not
|
|
216
|
+
* override the slider (the user is in control). Setting `false` flushes
|
|
217
|
+
* any pending debounced FoV immediately. Mirrors the pointer-up
|
|
218
|
+
* flushFovSlider call.
|
|
219
|
+
*/
|
|
220
|
+
setFovSliderDragging(dragging) {
|
|
221
|
+
this.sliderDragging = dragging;
|
|
222
|
+
if (!dragging)
|
|
223
|
+
this.flushFovSlider();
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Subscribe to slider FoV changes. The callback fires on `fovSliderInput`
|
|
227
|
+
* and on idle echo-sync (when the camera echo updates the slider). Returns
|
|
228
|
+
* an unsubscribe function.
|
|
229
|
+
*/
|
|
230
|
+
onSliderFov(cb) {
|
|
231
|
+
this.sliderFovListeners.add(cb);
|
|
232
|
+
return () => {
|
|
233
|
+
this.sliderFovListeners.delete(cb);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Ball drag
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* Declare ball-drag start/end. While dragging, echo-sync does not apply
|
|
241
|
+
* the echoed pan to the local accumulator (the user is in control).
|
|
242
|
+
* The pixel-deflection-to-rate conversion happens in the UI layer; the
|
|
243
|
+
* UI calls `setPanRate` with the normalised result.
|
|
244
|
+
*/
|
|
245
|
+
setBallDragging(dragging) {
|
|
246
|
+
this.ballDragging = dragging;
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Lifecycle
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
/**
|
|
252
|
+
* Send zero pan and zoom rates if (and only if) a non-zero rate is currently
|
|
253
|
+
* active, and clear drag flags and any pending FoV timer without sending it.
|
|
254
|
+
* Use on component unmount or camera hide -- mirrors the cleanup effects at
|
|
255
|
+
* CameraFeed.tsx:477-508.
|
|
256
|
+
*/
|
|
257
|
+
stop() {
|
|
258
|
+
if (this.sentPanRate.yaw !== 0 || this.sentPanRate.pitch !== 0) {
|
|
259
|
+
this.sentPanRate = { yaw: 0, pitch: 0 };
|
|
260
|
+
void this.sink.setPanRate(0, 0);
|
|
261
|
+
}
|
|
262
|
+
if (this.sentZoomRate !== 0) {
|
|
263
|
+
this.sentZoomRate = 0;
|
|
264
|
+
void this.sink.setZoomRate(0);
|
|
265
|
+
}
|
|
266
|
+
this.ballDragging = false;
|
|
267
|
+
this.sliderDragging = false;
|
|
268
|
+
if (this.fovDebounceTimer !== null) {
|
|
269
|
+
clearTimeout(this.fovDebounceTimer);
|
|
270
|
+
this.fovDebounceTimer = null;
|
|
271
|
+
}
|
|
272
|
+
this.pendingFov = null;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Stop the controller and drop all slider-change subscribers. Call when
|
|
276
|
+
* the owning component is permanently destroyed.
|
|
277
|
+
*/
|
|
278
|
+
dispose() {
|
|
279
|
+
this.stop();
|
|
280
|
+
this.sliderFovListeners.clear();
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Private helpers
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
flushFovSlider() {
|
|
286
|
+
if (this.fovDebounceTimer !== null) {
|
|
287
|
+
clearTimeout(this.fovDebounceTimer);
|
|
288
|
+
this.fovDebounceTimer = null;
|
|
289
|
+
}
|
|
290
|
+
if (this.pendingFov !== null) {
|
|
291
|
+
const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
|
|
292
|
+
this.localFov = fov;
|
|
293
|
+
this.pendingFov = null;
|
|
294
|
+
void this.sink.setFov(fov);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
setSliderFovInternal(fov) {
|
|
298
|
+
this._sliderFov = fov;
|
|
299
|
+
for (const cb of this.sliderFovListeners)
|
|
300
|
+
cb(fov);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, SettingsStatePayload } from "../__generated__/types";
|
|
2
|
+
import { CameraLifecycle, Layer, QualityPreset } from "../__generated__/types";
|
|
3
|
+
import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
|
|
4
|
+
export interface MockCameraInit {
|
|
5
|
+
flightId: number;
|
|
6
|
+
lifecycle?: CameraLifecycle;
|
|
7
|
+
partName?: string;
|
|
8
|
+
partTitle?: string;
|
|
9
|
+
cameraName?: string;
|
|
10
|
+
vesselName?: string;
|
|
11
|
+
layers?: Layer[];
|
|
12
|
+
operatorLayers?: Layer[];
|
|
13
|
+
renderWidth?: number;
|
|
14
|
+
renderHeight?: number;
|
|
15
|
+
operatorWidth?: number;
|
|
16
|
+
operatorHeight?: number;
|
|
17
|
+
supportsZoom?: boolean;
|
|
18
|
+
fov?: number;
|
|
19
|
+
fovMin?: number;
|
|
20
|
+
fovMax?: number;
|
|
21
|
+
supportsPan?: boolean;
|
|
22
|
+
panYaw?: number;
|
|
23
|
+
panPitch?: number;
|
|
24
|
+
panYawMin?: number;
|
|
25
|
+
panYawMax?: number;
|
|
26
|
+
panPitchMin?: number;
|
|
27
|
+
panPitchMax?: number;
|
|
28
|
+
encoderBitrateBps?: number;
|
|
29
|
+
targetBitrateBps?: number;
|
|
30
|
+
degradeLevel?: number;
|
|
31
|
+
viewerQuality?: QualityPreset;
|
|
32
|
+
qualityLimitedBy?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* In-process protocol-level fake for the kerbcast sidecar.
|
|
36
|
+
*
|
|
37
|
+
* Owns a camera registry and speaks the full kerbcast wire protocol.
|
|
38
|
+
* Use it in tests to exercise `KerbcastClient` behaviour without a real
|
|
39
|
+
* sidecar or WebRTC stack.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const sidecar = new MockSidecar();
|
|
43
|
+
* sidecar.addCamera({ flightId: 42 });
|
|
44
|
+
*
|
|
45
|
+
* vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
46
|
+
* Promise.resolve(MockSidecar.makeOfferResponse([42]))
|
|
47
|
+
* );
|
|
48
|
+
*
|
|
49
|
+
* const client = new KerbcastClient({ host: "localhost", port: 8088 }, sidecar.createTransport());
|
|
50
|
+
* await client.connect([42]);
|
|
51
|
+
* sidecar.open(); // fires hello + camera-snapshot
|
|
52
|
+
*
|
|
53
|
+
* expect(client.cameras[0].flightId).toBe(42);
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare class MockSidecar {
|
|
57
|
+
private readonly _cameras;
|
|
58
|
+
private readonly _commands;
|
|
59
|
+
private _throttleMainScreen;
|
|
60
|
+
private _openHandler;
|
|
61
|
+
private _clientMsgHandler;
|
|
62
|
+
private _stateHandler;
|
|
63
|
+
private _onTrackHandler;
|
|
64
|
+
private _trackIdx;
|
|
65
|
+
/** Slot mids available for the dynamic-subscription model. Override with
|
|
66
|
+
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
67
|
+
private _slotMids;
|
|
68
|
+
/** mid -> camera currently bound to that slot. */
|
|
69
|
+
private readonly _slotBindings;
|
|
70
|
+
/**
|
|
71
|
+
* Per-flight inbound stats to return from the fake getStats. Set via
|
|
72
|
+
* {@link setInboundStats}. Keyed by flightId.
|
|
73
|
+
*/
|
|
74
|
+
private readonly _inboundStats;
|
|
75
|
+
/**
|
|
76
|
+
* Tracks delivered by {@link deliverTrack}, keyed by mid/idx string so
|
|
77
|
+
* getStats can synthesize a trackIdentifier matching what the client saw.
|
|
78
|
+
*/
|
|
79
|
+
private readonly _deliveredTracks;
|
|
80
|
+
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
81
|
+
addCamera(init: MockCameraInit): void;
|
|
82
|
+
/**
|
|
83
|
+
* Returns a `KerbcastTransport` backed by this mock. Pass it as the
|
|
84
|
+
* second argument to `KerbcastClient`.
|
|
85
|
+
*/
|
|
86
|
+
createTransport(): KerbcastTransport;
|
|
87
|
+
/**
|
|
88
|
+
* Simulate the sidecar completing the WebRTC handshake. Fires the
|
|
89
|
+
* channel `onOpen` handler (which triggers the client's `hello`), then
|
|
90
|
+
* responds with `hello` + `camera-snapshot`.
|
|
91
|
+
*/
|
|
92
|
+
open(): void;
|
|
93
|
+
/** Drive the underlying peer's connection-state handler. */
|
|
94
|
+
setConnectionState(state: KerbcastConnectionState): void;
|
|
95
|
+
/**
|
|
96
|
+
* Mark a camera as destroyed and push a `camera-state-changed` message
|
|
97
|
+
* to the client. The camera stays in the internal registry with
|
|
98
|
+
* `lifecycle: Destroyed`.
|
|
99
|
+
*/
|
|
100
|
+
destroyCamera(flightId: number): void;
|
|
101
|
+
/**
|
|
102
|
+
* Apply a partial update to an existing camera and push a
|
|
103
|
+
* `camera-state-changed` message to the client.
|
|
104
|
+
*/
|
|
105
|
+
updateCamera(flightId: number, partial: Partial<CameraState>): void;
|
|
106
|
+
/**
|
|
107
|
+
* Replace the entire camera registry and push a fresh `camera-snapshot` to
|
|
108
|
+
* the client. Models a vessel change / scene switch where the set of
|
|
109
|
+
* available cameras changes (cameras appear or disappear) — distinct from
|
|
110
|
+
* {@link destroyCamera}, which keeps the camera present but `Destroyed`.
|
|
111
|
+
*/
|
|
112
|
+
setCameras(inits: MockCameraInit[]): void;
|
|
113
|
+
/** Send a `ping` from the sidecar; the client should respond with `pong`. */
|
|
114
|
+
firePing(): void;
|
|
115
|
+
/** Push an `adaptive-shed` event to the client. */
|
|
116
|
+
fireAdaptiveShed(payload: AdaptiveShedPayload): void;
|
|
117
|
+
/** Push an `error` event to the client (simulates a sidecar error reply). */
|
|
118
|
+
fireError(payload: ErrorPayload): void;
|
|
119
|
+
/** Current mock-sidecar throttle state (reflects `set-throttle-main-screen` commands). */
|
|
120
|
+
get throttleMainScreen(): boolean;
|
|
121
|
+
/** Push a `settings-state` event to the client (simulates a plugin-status-change broadcast). */
|
|
122
|
+
fireSettingsState(payload: SettingsStatePayload): void;
|
|
123
|
+
/** Configure the slot-pool mids before connecting (dynamic mode). */
|
|
124
|
+
withSlots(mids: string[]): this;
|
|
125
|
+
/**
|
|
126
|
+
* Deliver a track onto a slot (by mid), simulating the slot's media
|
|
127
|
+
* arriving over WebRTC. The client routes it to whichever camera is bound
|
|
128
|
+
* to that mid. (jsdom can't make real tracks; pass a stub in unit tests or
|
|
129
|
+
* a `canvas.captureStream()` track in a real-browser harness.)
|
|
130
|
+
*
|
|
131
|
+
* The track is remembered so the fake `getStats()` can synthesize a
|
|
132
|
+
* `trackIdentifier` matching what the client received.
|
|
133
|
+
*/
|
|
134
|
+
deliverTrack(mid: string, track: MediaStreamTrack): void;
|
|
135
|
+
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
136
|
+
slotMidFor(flightId: number): string | undefined;
|
|
137
|
+
/** Every `ClientMessage` received from the client, in order. */
|
|
138
|
+
get commands(): ReadonlyArray<ClientMessage>;
|
|
139
|
+
/**
|
|
140
|
+
* Find the most recent client command of the given type. Pass `flightId`
|
|
141
|
+
* to further filter by camera (ignored for message types without a
|
|
142
|
+
* `content.flightId` field).
|
|
143
|
+
*/
|
|
144
|
+
lastCommand<T extends ClientMessage["type"]>(type: T, flightId?: number): Extract<ClientMessage, {
|
|
145
|
+
type: T;
|
|
146
|
+
}> | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* Build a `Response` that looks like the sidecar's `POST /offer` reply.
|
|
149
|
+
* Pass to `vi.spyOn(globalThis, "fetch").mockImplementation(...)` so the
|
|
150
|
+
* client's handshake can complete without a real HTTP server.
|
|
151
|
+
*
|
|
152
|
+
* ```ts
|
|
153
|
+
* vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
154
|
+
* Promise.resolve(MockSidecar.makeOfferResponse([42]))
|
|
155
|
+
* );
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
static makeOfferResponse(cameras: number[]): Response;
|
|
159
|
+
/**
|
|
160
|
+
* Signaling-seam analogue of {@link makeOfferResponse}: resolve an offer's
|
|
161
|
+
* answer without HTTP. Pass as the client's `negotiate` config to exercise
|
|
162
|
+
* the brokered-signaling path a station uses.
|
|
163
|
+
*/
|
|
164
|
+
negotiate(offer: {
|
|
165
|
+
sdp: string;
|
|
166
|
+
cameras: number[];
|
|
167
|
+
slots?: number;
|
|
168
|
+
}): Promise<{
|
|
169
|
+
sdp: string;
|
|
170
|
+
cameras: number[];
|
|
171
|
+
}>;
|
|
172
|
+
/**
|
|
173
|
+
* Configure the inbound video stats the fake `getStats()` will return for
|
|
174
|
+
* a given camera. Call before `client.inboundVideoStats()` in tests.
|
|
175
|
+
*
|
|
176
|
+
* The mock synthesizes a minimal `RTCStatsReport`-shaped object: one entry
|
|
177
|
+
* per flight that has stats set, with `type: "inbound-rtp"`, `kind: "video"`,
|
|
178
|
+
* and either a `trackIdentifier` matching the track delivered for that camera
|
|
179
|
+
* (legacy path) or a `mid` matching the slot binding (dynamic path), plus
|
|
180
|
+
* the stat fields from `partialStats`.
|
|
181
|
+
*
|
|
182
|
+
* ```ts
|
|
183
|
+
* sidecar.setInboundStats(42, { packetsReceived: 1000, framesDecoded: 300 });
|
|
184
|
+
* const stats = await client.inboundVideoStats();
|
|
185
|
+
* expect(stats.get(42)?.packetsReceived).toBe(1000);
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
setInboundStats(flightId: number, partialStats: Partial<InboundVideoStats>): void;
|
|
189
|
+
/** Build a minimal RTCStatsReport-compatible object for the current state. */
|
|
190
|
+
private _buildStatsReport;
|
|
191
|
+
private _sendToClient;
|
|
192
|
+
private _handleClientMessage;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Install jsdom shims needed by kerbcast component tests.
|
|
196
|
+
*
|
|
197
|
+
* jsdom omits several browser APIs that the SDK and React components call at
|
|
198
|
+
* construction or mount time. Stubbing here keeps individual tests clean.
|
|
199
|
+
* Each shim is idempotent so setup files can call `installDomStubs()`
|
|
200
|
+
* unconditionally.
|
|
201
|
+
*
|
|
202
|
+
* Stubs installed:
|
|
203
|
+
* ResizeObserver - jsdom does not implement it; CameraFeed uses it to
|
|
204
|
+
* drive auto render-size updates.
|
|
205
|
+
* captureStream - jsdom's HTMLCanvasElement lacks captureStream; the
|
|
206
|
+
* noise pipeline calls it to get its output MediaStream.
|
|
207
|
+
* Returns a stub MediaStream so callers receive a valid
|
|
208
|
+
* object rather than throwing.
|
|
209
|
+
* MediaStream - jsdom's MediaStream constructor is incomplete; tests
|
|
210
|
+
* need to construct instances for track delivery and
|
|
211
|
+
* stream assertions.
|
|
212
|
+
* play - jsdom prints "Not implemented" for HTMLMediaElement.play;
|
|
213
|
+
* the noise pipeline awaits it on the internal video element.
|
|
214
|
+
* matchMedia - jsdom does not implement window.matchMedia; theme
|
|
215
|
+
* detection reads prefers-color-scheme through it.
|
|
216
|
+
*/
|
|
217
|
+
export declare function installDomStubs(): void;
|