@ksp-gonogo/kerbcast 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 +39 -0
- package/dist/__generated__/types.d.ts +23 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/recording.d.ts +426 -0
- package/dist/recording.js +898 -0
- package/dist/testing/index.d.ts +88 -1
- package/dist/testing/index.js +200 -3
- package/package.json +4 -1
- package/src/__generated__/types.ts +21 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +74 -0
- package/src/index.ts +22 -0
- package/src/recording.test.ts +1471 -0
- package/src/recording.ts +1233 -0
- package/src/testing/index.ts +239 -1
- package/src/testing.test.ts +147 -1
package/src/testing/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, ServerMessage, SettingsStatePayload } from "../__generated__/types";
|
|
2
2
|
import { CameraKind, CameraLifecycle, CrewLocation, ErrorSource, Layer, QualityPreset, TrackMode } from "../__generated__/types";
|
|
3
3
|
import type {
|
|
4
|
+
DiscoveredCamera,
|
|
4
5
|
InboundVideoStats,
|
|
5
6
|
KerbcastConnectionState,
|
|
6
7
|
KerbcastDataChannel,
|
|
@@ -46,6 +47,16 @@ export interface MockCameraInit {
|
|
|
46
47
|
qualityLimitedBy?: string;
|
|
47
48
|
/** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
|
|
48
49
|
trackMode?: TrackMode;
|
|
50
|
+
/**
|
|
51
|
+
* Physical/ceiling render size, served by the mock's `/cameras` discovery
|
|
52
|
+
* intercept (`DiscoveredCamera.maxWidth`/`maxHeight`). Defaults to
|
|
53
|
+
* `operatorWidth`/`operatorHeight` (i.e. already at ceiling) so a camera
|
|
54
|
+
* that never sets these behaves as it always has. Give a camera a ceiling
|
|
55
|
+
* above its initial `operatorWidth`/`operatorHeight` to demonstrate a
|
|
56
|
+
* visible bump when `set-force-full-resolution` lands.
|
|
57
|
+
*/
|
|
58
|
+
maxWidth?: number;
|
|
59
|
+
maxHeight?: number;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
function buildCamera(init: MockCameraInit): CameraState {
|
|
@@ -100,6 +111,18 @@ function scaleDim(operatorDim: number, scale: number): number {
|
|
|
100
111
|
return v < 2 ? 2 : v;
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
/** Options for {@link MockSidecar}'s constructor. */
|
|
115
|
+
export interface MockSidecarOptions {
|
|
116
|
+
/**
|
|
117
|
+
* Delay (ms) before a forced camera's simulated resolution bump lands,
|
|
118
|
+
* once `set-force-full-resolution` (`force: true`) is received, and
|
|
119
|
+
* before the render size reverts on `force: false`. Long enough that a
|
|
120
|
+
* screenshot can catch the resulting "ARMING" state; short enough that
|
|
121
|
+
* automated tests/waits don't feel slow. Defaults to 600ms.
|
|
122
|
+
*/
|
|
123
|
+
forceBumpDelayMs?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
103
126
|
/**
|
|
104
127
|
* In-process protocol-level fake for the kerbcast sidecar.
|
|
105
128
|
*
|
|
@@ -126,6 +149,17 @@ export class MockSidecar {
|
|
|
126
149
|
private readonly _cameras = new Map<number, CameraState>();
|
|
127
150
|
private readonly _commands: ClientMessage[] = [];
|
|
128
151
|
private _throttleMainScreen = false;
|
|
152
|
+
private readonly _forceBumpDelayMs: number;
|
|
153
|
+
/** Per-flight ceiling (ceiling render size), served by `discoveredCameras()`. */
|
|
154
|
+
private readonly _maxRenderSizes = new Map<number, { maxWidth: number; maxHeight: number }>();
|
|
155
|
+
/** Per-flight render size captured just before a force bump, restored on release. */
|
|
156
|
+
private readonly _preForceSize = new Map<number, { width: number; height: number }>();
|
|
157
|
+
/** Per-flight pending bump/revert timer, so a rapid force/unforce toggle cancels the stale one. */
|
|
158
|
+
private readonly _forceTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
159
|
+
|
|
160
|
+
constructor(opts: MockSidecarOptions = {}) {
|
|
161
|
+
this._forceBumpDelayMs = opts.forceBumpDelayMs ?? 600;
|
|
162
|
+
}
|
|
129
163
|
|
|
130
164
|
private _openHandler: (() => void) | undefined;
|
|
131
165
|
private _clientMsgHandler: ((raw: string) => void) | undefined;
|
|
@@ -155,7 +189,47 @@ export class MockSidecar {
|
|
|
155
189
|
|
|
156
190
|
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
157
191
|
addCamera(init: MockCameraInit): void {
|
|
158
|
-
|
|
192
|
+
const cam = buildCamera(init);
|
|
193
|
+
this._cameras.set(init.flightId, cam);
|
|
194
|
+
this._maxRenderSizes.set(init.flightId, {
|
|
195
|
+
maxWidth: init.maxWidth ?? cam.operatorWidth,
|
|
196
|
+
maxHeight: init.maxHeight ?? cam.operatorHeight,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The `DiscoveredCamera[]` the mock's `GET /cameras` intercept serves,
|
|
202
|
+
* mirroring the real sidecar's discovery endpoint. `maxWidth`/`maxHeight`
|
|
203
|
+
* come from each camera's `maxWidth`/`maxHeight` init (or its initial
|
|
204
|
+
* `operatorWidth`/`operatorHeight` when omitted). Used by
|
|
205
|
+
* `client.discover()` to populate `KerbcastCameraHandle.maxRenderSize`.
|
|
206
|
+
*/
|
|
207
|
+
discoveredCameras(): DiscoveredCamera[] {
|
|
208
|
+
return Array.from(this._cameras.values()).map((cam) => {
|
|
209
|
+
const max = this._maxRenderSizes.get(cam.flightId);
|
|
210
|
+
return {
|
|
211
|
+
flightId: cam.flightId,
|
|
212
|
+
lifecycle: cam.lifecycle,
|
|
213
|
+
partName: cam.partName,
|
|
214
|
+
partTitle: cam.partTitle,
|
|
215
|
+
cameraName: cam.cameraName,
|
|
216
|
+
vesselName: cam.vesselName,
|
|
217
|
+
maxWidth: max?.maxWidth ?? cam.operatorWidth,
|
|
218
|
+
maxHeight: max?.maxHeight ?? cam.operatorHeight,
|
|
219
|
+
supportsZoom: cam.supportsZoom,
|
|
220
|
+
fov: cam.fov,
|
|
221
|
+
fovMin: cam.fovMin,
|
|
222
|
+
fovMax: cam.fovMax,
|
|
223
|
+
supportsPan: cam.supportsPan,
|
|
224
|
+
panYawMin: cam.panYawMin,
|
|
225
|
+
panYawMax: cam.panYawMax,
|
|
226
|
+
panPitchMin: cam.panPitchMin,
|
|
227
|
+
panPitchMax: cam.panPitchMax,
|
|
228
|
+
encoderBitrateBps: cam.encoderBitrateBps,
|
|
229
|
+
targetBitrateBps: cam.targetBitrateBps,
|
|
230
|
+
degradeLevel: cam.degradeLevel,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
159
233
|
}
|
|
160
234
|
|
|
161
235
|
/**
|
|
@@ -250,9 +324,17 @@ export class MockSidecar {
|
|
|
250
324
|
* {@link destroyCamera}, which keeps the camera present but `Destroyed`.
|
|
251
325
|
*/
|
|
252
326
|
setCameras(inits: MockCameraInit[]): void {
|
|
327
|
+
for (const timer of this._forceTimers.values()) clearTimeout(timer);
|
|
328
|
+
this._forceTimers.clear();
|
|
329
|
+
this._preForceSize.clear();
|
|
253
330
|
this._cameras.clear();
|
|
331
|
+
this._maxRenderSizes.clear();
|
|
254
332
|
for (const init of inits) {
|
|
255
333
|
this._cameras.set(init.flightId, buildCamera(init));
|
|
334
|
+
this._maxRenderSizes.set(init.flightId, {
|
|
335
|
+
maxWidth: init.maxWidth ?? init.operatorWidth ?? 1280,
|
|
336
|
+
maxHeight: init.maxHeight ?? init.operatorHeight ?? 720,
|
|
337
|
+
});
|
|
256
338
|
}
|
|
257
339
|
this._sendToClient({
|
|
258
340
|
type: "camera-snapshot",
|
|
@@ -620,10 +702,70 @@ export class MockSidecar {
|
|
|
620
702
|
});
|
|
621
703
|
break;
|
|
622
704
|
}
|
|
705
|
+
case "set-force-full-resolution": {
|
|
706
|
+
this._setForced(msg.content.flightId, msg.content.force);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
623
709
|
case "disconnect":
|
|
624
710
|
break;
|
|
625
711
|
}
|
|
626
712
|
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Simulate the sidecar's `effective_render_size` force branch: after
|
|
716
|
+
* {@link _forceBumpDelayMs}, bump the camera's render/operator size to its
|
|
717
|
+
* ceiling (`force: true`) or restore whatever it was before the force
|
|
718
|
+
* (`force: false`), pushing a `camera-state-changed` either way. The delay
|
|
719
|
+
* models the real round trip (control-file write, plugin capture pickup,
|
|
720
|
+
* WebRTC renegotiation) and gives a UI something to show an "ARMING" state
|
|
721
|
+
* for. A rapid re-toggle cancels the previous pending bump/revert.
|
|
722
|
+
*/
|
|
723
|
+
private _setForced(flightId: number, force: boolean): void {
|
|
724
|
+
const pending = this._forceTimers.get(flightId);
|
|
725
|
+
if (pending !== undefined) {
|
|
726
|
+
clearTimeout(pending);
|
|
727
|
+
this._forceTimers.delete(flightId);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const cam = this._cameras.get(flightId);
|
|
731
|
+
if (!cam) return;
|
|
732
|
+
|
|
733
|
+
if (force) {
|
|
734
|
+
if (!this._preForceSize.has(flightId)) {
|
|
735
|
+
this._preForceSize.set(flightId, { width: cam.operatorWidth, height: cam.operatorHeight });
|
|
736
|
+
}
|
|
737
|
+
const max = this._maxRenderSizes.get(flightId);
|
|
738
|
+
const timer = setTimeout(() => {
|
|
739
|
+
this._forceTimers.delete(flightId);
|
|
740
|
+
this._applyRenderSize(flightId, max?.maxWidth, max?.maxHeight);
|
|
741
|
+
}, this._forceBumpDelayMs);
|
|
742
|
+
this._forceTimers.set(flightId, timer);
|
|
743
|
+
} else {
|
|
744
|
+
const prior = this._preForceSize.get(flightId);
|
|
745
|
+
this._preForceSize.delete(flightId);
|
|
746
|
+
if (!prior) return;
|
|
747
|
+
const timer = setTimeout(() => {
|
|
748
|
+
this._forceTimers.delete(flightId);
|
|
749
|
+
this._applyRenderSize(flightId, prior.width, prior.height);
|
|
750
|
+
}, this._forceBumpDelayMs);
|
|
751
|
+
this._forceTimers.set(flightId, timer);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Push a render/operator size onto a camera (both fields alike, i.e. no adaptive shed in effect) and broadcast it. */
|
|
756
|
+
private _applyRenderSize(flightId: number, width: number | undefined, height: number | undefined): void {
|
|
757
|
+
const cam = this._cameras.get(flightId);
|
|
758
|
+
if (!cam || width === undefined || height === undefined) return;
|
|
759
|
+
const updated: CameraState = {
|
|
760
|
+
...cam,
|
|
761
|
+
renderWidth: width,
|
|
762
|
+
renderHeight: height,
|
|
763
|
+
operatorWidth: width,
|
|
764
|
+
operatorHeight: height,
|
|
765
|
+
};
|
|
766
|
+
this._cameras.set(flightId, updated);
|
|
767
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
768
|
+
}
|
|
627
769
|
}
|
|
628
770
|
|
|
629
771
|
/**
|
|
@@ -676,6 +818,15 @@ export function installDomStubs(): void {
|
|
|
676
818
|
// tests (getTracks, addTrack, id).
|
|
677
819
|
installStubMediaStream();
|
|
678
820
|
|
|
821
|
+
// MediaRecorder: jsdom ships none, so the recording controller can't be
|
|
822
|
+
// exercised headlessly without it. The stub emits one canned Blob on stop,
|
|
823
|
+
// closing the start -> stop -> Blob path. Its isTypeSupported is controllable
|
|
824
|
+
// (StubMediaRecorder.supportedTypes) so tests can drive mime negotiation and
|
|
825
|
+
// its fallback.
|
|
826
|
+
if (typeof (globalThis as { MediaRecorder?: unknown }).MediaRecorder === "undefined") {
|
|
827
|
+
(globalThis as { MediaRecorder?: unknown }).MediaRecorder = StubMediaRecorder;
|
|
828
|
+
}
|
|
829
|
+
|
|
679
830
|
// play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
|
|
680
831
|
// The noise pipeline awaits video.play() on its internal video element.
|
|
681
832
|
if (typeof HTMLMediaElement !== "undefined") {
|
|
@@ -704,6 +855,18 @@ class StubMediaStream {
|
|
|
704
855
|
readonly id: string = Math.random().toString(36).slice(2);
|
|
705
856
|
private readonly _tracks: MediaStreamTrack[] = [];
|
|
706
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Mirrors the real `MediaStream(tracks?)` constructor: the real SDK builds
|
|
860
|
+
* its per-camera stream as `new MediaStream([track])` on track arrival, so
|
|
861
|
+
* without this the tracks array is silently dropped and the stream always
|
|
862
|
+
* reports zero video tracks: the client and anything downstream (e.g. a
|
|
863
|
+
* real `RecordingController.startRecording`, which requires at least one
|
|
864
|
+
* live video track) would never see the delivered track.
|
|
865
|
+
*/
|
|
866
|
+
constructor(tracks: MediaStreamTrack[] = []) {
|
|
867
|
+
this._tracks.push(...tracks);
|
|
868
|
+
}
|
|
869
|
+
|
|
707
870
|
getTracks(): MediaStreamTrack[] {
|
|
708
871
|
return [...this._tracks];
|
|
709
872
|
}
|
|
@@ -738,3 +901,78 @@ function installStubMediaStream(): void {
|
|
|
738
901
|
}
|
|
739
902
|
(globalThis as unknown as { MediaStream: unknown }).MediaStream = StubMediaStream;
|
|
740
903
|
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Minimal MediaRecorder stand-in for jsdom, installed by {@link installDomStubs}.
|
|
907
|
+
*
|
|
908
|
+
* jsdom implements no MediaRecorder, so the recording controller can't run
|
|
909
|
+
* headlessly without this. On `stop()` it synchronously flushes one canned
|
|
910
|
+
* Blob (a fixed byte pattern tagged with the negotiated mimeType) then fires
|
|
911
|
+
* `stop`, closing the start -> stop -> Blob path the controller drives.
|
|
912
|
+
*
|
|
913
|
+
* `isTypeSupported` is controllable so tests exercise mime negotiation:
|
|
914
|
+
* - `supportedTypes = null` (default): support everything, so negotiation
|
|
915
|
+
* picks the mp4 preference.
|
|
916
|
+
* - `supportedTypes = ["video/webm"]`: force the webm fallback.
|
|
917
|
+
* Reset `supportedTypes` back to null between tests that change it.
|
|
918
|
+
*/
|
|
919
|
+
export class StubMediaRecorder {
|
|
920
|
+
/** Types isTypeSupported reports available; null = everything. */
|
|
921
|
+
static supportedTypes: string[] | null = null;
|
|
922
|
+
|
|
923
|
+
static isTypeSupported(type: string): boolean {
|
|
924
|
+
return StubMediaRecorder.supportedTypes === null
|
|
925
|
+
? true
|
|
926
|
+
: StubMediaRecorder.supportedTypes.includes(type);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
readonly stream: MediaStream;
|
|
930
|
+
readonly mimeType: string;
|
|
931
|
+
state: "inactive" | "recording" | "paused" = "inactive";
|
|
932
|
+
|
|
933
|
+
private readonly _listeners = new Map<string, Set<(e: { data?: Blob }) => void>>();
|
|
934
|
+
|
|
935
|
+
constructor(stream: MediaStream, options?: { mimeType?: string }) {
|
|
936
|
+
this.stream = stream;
|
|
937
|
+
this.mimeType = options?.mimeType ?? "";
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
addEventListener(type: string, cb: (e: { data?: Blob }) => void): void {
|
|
941
|
+
let set = this._listeners.get(type);
|
|
942
|
+
if (!set) {
|
|
943
|
+
set = new Set();
|
|
944
|
+
this._listeners.set(type, set);
|
|
945
|
+
}
|
|
946
|
+
set.add(cb);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
removeEventListener(type: string, cb: (e: { data?: Blob }) => void): void {
|
|
950
|
+
this._listeners.get(type)?.delete(cb);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private _emit(type: string, e: { data?: Blob }): void {
|
|
954
|
+
this._listeners.get(type)?.forEach((cb) => cb(e));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
start(_timeslice?: number): void {
|
|
958
|
+
this.state = "recording";
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Flush a canned data chunk, then fire `stop`, mirroring the real order. */
|
|
962
|
+
stop(): void {
|
|
963
|
+
if (this.state === "inactive") return;
|
|
964
|
+
this.state = "inactive";
|
|
965
|
+
const bytes = new Uint8Array(2048);
|
|
966
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = i & 0xff;
|
|
967
|
+
const data = new Blob([bytes], { type: this.mimeType || "video/webm" });
|
|
968
|
+
this._emit("dataavailable", { data });
|
|
969
|
+
this._emit("stop", {});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Emit a partial data chunk without stopping (matches the real API). */
|
|
973
|
+
requestData(): void {
|
|
974
|
+
if (this.state !== "recording") return;
|
|
975
|
+
const data = new Blob([new Uint8Array(512)], { type: this.mimeType || "video/webm" });
|
|
976
|
+
this._emit("dataavailable", { data });
|
|
977
|
+
}
|
|
978
|
+
}
|
package/src/testing.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { CameraLifecycle, Layer, TrackMode } from "./__generated__/types";
|
|
3
3
|
import { KerbcastClient } from "./index";
|
|
4
4
|
import { MockSidecar } from "./testing/index";
|
|
@@ -196,3 +196,149 @@ describe("MockSidecar", () => {
|
|
|
196
196
|
expect(states).toEqual(["connected", "failed"]);
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("MockSidecar force-full-resolution", () => {
|
|
201
|
+
afterEach(() => {
|
|
202
|
+
vi.useRealTimers();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("bumps render/operator size to the ceiling after a delay, then reverts on release", async () => {
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const sidecar = new MockSidecar({ forceBumpDelayMs: 100 });
|
|
208
|
+
sidecar.addCamera({
|
|
209
|
+
flightId: 42,
|
|
210
|
+
renderWidth: 640,
|
|
211
|
+
renderHeight: 360,
|
|
212
|
+
operatorWidth: 640,
|
|
213
|
+
operatorHeight: 360,
|
|
214
|
+
maxWidth: 1920,
|
|
215
|
+
maxHeight: 1080,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const client = new KerbcastClient(
|
|
219
|
+
{ host: "localhost", port: 8088 },
|
|
220
|
+
sidecar.createTransport(),
|
|
221
|
+
);
|
|
222
|
+
await client.connect([42]);
|
|
223
|
+
sidecar.open();
|
|
224
|
+
|
|
225
|
+
await client.camera(42).setForceFullResolution(true);
|
|
226
|
+
// Not yet bumped: the delay hasn't elapsed.
|
|
227
|
+
expect(client.camera(42).state?.operatorWidth).toBe(640);
|
|
228
|
+
|
|
229
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
230
|
+
expect(client.camera(42).state).toMatchObject({
|
|
231
|
+
renderWidth: 1920,
|
|
232
|
+
renderHeight: 1080,
|
|
233
|
+
operatorWidth: 1920,
|
|
234
|
+
operatorHeight: 1080,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await client.camera(42).setForceFullResolution(false);
|
|
238
|
+
expect(client.camera(42).state?.operatorWidth).toBe(1920); // not yet reverted
|
|
239
|
+
|
|
240
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
241
|
+
expect(client.camera(42).state).toMatchObject({
|
|
242
|
+
renderWidth: 640,
|
|
243
|
+
renderHeight: 360,
|
|
244
|
+
operatorWidth: 640,
|
|
245
|
+
operatorHeight: 360,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("defaults the ceiling to the camera's initial operator size when maxWidth/maxHeight are omitted", async () => {
|
|
250
|
+
vi.useFakeTimers();
|
|
251
|
+
const sidecar = new MockSidecar({ forceBumpDelayMs: 10 });
|
|
252
|
+
sidecar.addCamera({ flightId: 7, operatorWidth: 1280, operatorHeight: 720 });
|
|
253
|
+
|
|
254
|
+
const client = new KerbcastClient(
|
|
255
|
+
{ host: "localhost", port: 8088 },
|
|
256
|
+
sidecar.createTransport(),
|
|
257
|
+
);
|
|
258
|
+
await client.connect([7]);
|
|
259
|
+
sidecar.open();
|
|
260
|
+
|
|
261
|
+
await client.camera(7).setForceFullResolution(true);
|
|
262
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
263
|
+
|
|
264
|
+
expect(client.camera(7).state).toMatchObject({
|
|
265
|
+
renderWidth: 1280,
|
|
266
|
+
renderHeight: 720,
|
|
267
|
+
operatorWidth: 1280,
|
|
268
|
+
operatorHeight: 720,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("cancels a pending bump when force is released before the delay elapses", async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
const sidecar = new MockSidecar({ forceBumpDelayMs: 100 });
|
|
275
|
+
sidecar.addCamera({
|
|
276
|
+
flightId: 42,
|
|
277
|
+
operatorWidth: 640,
|
|
278
|
+
operatorHeight: 360,
|
|
279
|
+
maxWidth: 1920,
|
|
280
|
+
maxHeight: 1080,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const client = new KerbcastClient(
|
|
284
|
+
{ host: "localhost", port: 8088 },
|
|
285
|
+
sidecar.createTransport(),
|
|
286
|
+
);
|
|
287
|
+
await client.connect([42]);
|
|
288
|
+
sidecar.open();
|
|
289
|
+
|
|
290
|
+
await client.camera(42).setForceFullResolution(true);
|
|
291
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
292
|
+
await client.camera(42).setForceFullResolution(false);
|
|
293
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
294
|
+
|
|
295
|
+
// Never bumped, so nothing to revert: still the original size.
|
|
296
|
+
expect(client.camera(42).state).toMatchObject({
|
|
297
|
+
operatorWidth: 640,
|
|
298
|
+
operatorHeight: 360,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("MockSidecar.discoveredCameras / client.discover()", () => {
|
|
304
|
+
it("reports maxWidth/maxHeight from each camera's init", () => {
|
|
305
|
+
const sidecar = new MockSidecar();
|
|
306
|
+
sidecar.addCamera({
|
|
307
|
+
flightId: 1,
|
|
308
|
+
cameraName: "NavCam",
|
|
309
|
+
operatorWidth: 1280,
|
|
310
|
+
operatorHeight: 720,
|
|
311
|
+
maxWidth: 1920,
|
|
312
|
+
maxHeight: 1080,
|
|
313
|
+
});
|
|
314
|
+
sidecar.addCamera({ flightId: 2, operatorWidth: 640, operatorHeight: 360 });
|
|
315
|
+
|
|
316
|
+
const discovered = sidecar.discoveredCameras();
|
|
317
|
+
expect(discovered.find((c) => c.flightId === 1)).toMatchObject({
|
|
318
|
+
maxWidth: 1920,
|
|
319
|
+
maxHeight: 1080,
|
|
320
|
+
});
|
|
321
|
+
// No explicit ceiling: falls back to the camera's initial operator size.
|
|
322
|
+
expect(discovered.find((c) => c.flightId === 2)).toMatchObject({
|
|
323
|
+
maxWidth: 640,
|
|
324
|
+
maxHeight: 360,
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("populates client.camera(id).maxRenderSize via client.discover()", async () => {
|
|
329
|
+
const sidecar = new MockSidecar();
|
|
330
|
+
sidecar.addCamera({ flightId: 42, maxWidth: 1920, maxHeight: 1080 });
|
|
331
|
+
|
|
332
|
+
const client = new KerbcastClient(
|
|
333
|
+
{ host: "localhost", port: 8088 },
|
|
334
|
+
sidecar.createTransport(),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
338
|
+
new Response(JSON.stringify({ cameras: sidecar.discoveredCameras() }), { status: 200 }),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
await client.discover();
|
|
342
|
+
expect(client.camera(42).maxRenderSize).toEqual({ width: 1920, height: 1080 });
|
|
343
|
+
});
|
|
344
|
+
});
|