@ksp-gonogo/kerbcast 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.
@@ -1,6 +1,7 @@
1
1
  import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, ServerMessage, SettingsStatePayload } from "../__generated__/types";
2
- import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
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,
@@ -11,6 +12,13 @@ import type {
11
12
  export interface MockCameraInit {
12
13
  flightId: number;
13
14
  lifecycle?: CameraLifecycle;
15
+ /** Part vs kerbal face camera. Defaults to `part` when omitted, so existing
16
+ * part-cam callers are unchanged. */
17
+ kind?: CameraKind;
18
+ /** Only meaningful for `kind: Kerbal`: seated IVA portrait vs EVA view. */
19
+ crewLocation?: CrewLocation;
20
+ /** Only meaningful for `kind: Kerbal`: informational raw persistentID. */
21
+ kerbalPersistentId?: number;
14
22
  partName?: string;
15
23
  partTitle?: string;
16
24
  cameraName?: string;
@@ -37,12 +45,27 @@ export interface MockCameraInit {
37
45
  degradeLevel?: number;
38
46
  viewerQuality?: QualityPreset;
39
47
  qualityLimitedBy?: string;
48
+ /** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
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;
40
60
  }
41
61
 
42
62
  function buildCamera(init: MockCameraInit): CameraState {
43
63
  return {
44
64
  flightId: init.flightId,
45
65
  lifecycle: init.lifecycle ?? CameraLifecycle.Active,
66
+ kind: init.kind ?? CameraKind.Part,
67
+ crewLocation: init.crewLocation,
68
+ kerbalPersistentId: init.kerbalPersistentId,
46
69
  partName: init.partName ?? `part-${init.flightId}`,
47
70
  partTitle: init.partTitle ?? `Part ${init.flightId}`,
48
71
  cameraName: init.cameraName ?? `camera-${init.flightId}`,
@@ -69,6 +92,7 @@ function buildCamera(init: MockCameraInit): CameraState {
69
92
  degradeLevel: init.degradeLevel ?? 0,
70
93
  viewerQuality: init.viewerQuality,
71
94
  qualityLimitedBy: init.qualityLimitedBy,
95
+ trackMode: init.trackMode ?? TrackMode.None,
72
96
  };
73
97
  }
74
98
 
@@ -87,6 +111,18 @@ function scaleDim(operatorDim: number, scale: number): number {
87
111
  return v < 2 ? 2 : v;
88
112
  }
89
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
+
90
126
  /**
91
127
  * In-process protocol-level fake for the kerbcast sidecar.
92
128
  *
@@ -113,6 +149,17 @@ export class MockSidecar {
113
149
  private readonly _cameras = new Map<number, CameraState>();
114
150
  private readonly _commands: ClientMessage[] = [];
115
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
+ }
116
163
 
117
164
  private _openHandler: (() => void) | undefined;
118
165
  private _clientMsgHandler: ((raw: string) => void) | undefined;
@@ -120,6 +167,9 @@ export class MockSidecar {
120
167
  private _onTrackHandler:
121
168
  | ((track: MediaStreamTrack, idx: number, mid: string) => void)
122
169
  | undefined;
170
+ private _subscribeHandler:
171
+ | ((flightId: number, mid: string) => void)
172
+ | undefined;
123
173
  private _trackIdx = 0;
124
174
  /** Slot mids available for the dynamic-subscription model. Override with
125
175
  * {@link withSlots} before connecting if a test needs a specific pool. */
@@ -139,7 +189,47 @@ export class MockSidecar {
139
189
 
140
190
  /** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
141
191
  addCamera(init: MockCameraInit): void {
142
- this._cameras.set(init.flightId, buildCamera(init));
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
+ });
143
233
  }
144
234
 
145
235
  /**
@@ -234,9 +324,17 @@ export class MockSidecar {
234
324
  * {@link destroyCamera}, which keeps the camera present but `Destroyed`.
235
325
  */
236
326
  setCameras(inits: MockCameraInit[]): void {
327
+ for (const timer of this._forceTimers.values()) clearTimeout(timer);
328
+ this._forceTimers.clear();
329
+ this._preForceSize.clear();
237
330
  this._cameras.clear();
331
+ this._maxRenderSizes.clear();
238
332
  for (const init of inits) {
239
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
+ });
240
338
  }
241
339
  this._sendToClient({
242
340
  type: "camera-snapshot",
@@ -295,6 +393,16 @@ export class MockSidecar {
295
393
  this._onTrackHandler?.(track, this._trackIdx++, mid);
296
394
  }
297
395
 
396
+ /**
397
+ * Register a handler fired each time a `subscribe` binds a camera to a slot,
398
+ * with `(flightId, mid)`. A browser harness uses it to deliver that camera's
399
+ * track to the mid it was actually bound to — so tracks follow the real
400
+ * subscription order, not the registration/array order. Set before connecting.
401
+ */
402
+ onSubscribe(handler: (flightId: number, mid: string) => void): void {
403
+ this._subscribeHandler = handler;
404
+ }
405
+
298
406
  /** The slot mid currently carrying `flightId`, or undefined. */
299
407
  slotMidFor(flightId: number): string | undefined {
300
408
  for (const [mid, fid] of this._slotBindings) {
@@ -520,6 +628,18 @@ export class MockSidecar {
520
628
  this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
521
629
  break;
522
630
  }
631
+ case "set-track-target": {
632
+ // Server-authoritative: hold the chosen mode and broadcast it back as
633
+ // camera-state-changed, mirroring the sidecar so every browser reflects
634
+ // the same trackMode (never optimistic-local).
635
+ const cam = this._cameras.get(msg.content.flightId);
636
+ if (cam) {
637
+ const updated: CameraState = { ...cam, trackMode: msg.content.mode };
638
+ this._cameras.set(msg.content.flightId, updated);
639
+ this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
640
+ }
641
+ break;
642
+ }
523
643
  case "subscribe": {
524
644
  const flightId = msg.content.flightId;
525
645
  const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
@@ -534,6 +654,9 @@ export class MockSidecar {
534
654
  type: "slot-map",
535
655
  content: { mid: freeMid, flightId },
536
656
  });
657
+ // Let a harness deliver this camera's track to the slot it was just
658
+ // bound to (so tracks follow the actual subscription, not array order).
659
+ this._subscribeHandler?.(flightId, freeMid);
537
660
  }
538
661
  break;
539
662
  }
@@ -559,6 +682,11 @@ export class MockSidecar {
559
682
  // the mock has no frame clock, so it doesn't model their *effect* on
560
683
  // panYaw/fov. They're still recorded in `_commands`, so consumer tests
561
684
  // can assert the command was sent via `lastCommand("set-pan-rate")`.
685
+ // Advisory per-consumer display-size input. The real sidecar aggregates
686
+ // it MAX-across-consumers to drive auto-resolution; the mock has no
687
+ // aggregator, so it just records the command (via `_commands`) and does
688
+ // NOT mutate camera render dims (unlike the operator `set-render-size`).
689
+ case "report-display-size":
562
690
  case "set-pan-rate":
563
691
  case "set-zoom-rate":
564
692
  case "hello":
@@ -574,10 +702,70 @@ export class MockSidecar {
574
702
  });
575
703
  break;
576
704
  }
705
+ case "set-force-full-resolution": {
706
+ this._setForced(msg.content.flightId, msg.content.force);
707
+ break;
708
+ }
577
709
  case "disconnect":
578
710
  break;
579
711
  }
580
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
+ }
581
769
  }
582
770
 
583
771
  /**
@@ -630,6 +818,15 @@ export function installDomStubs(): void {
630
818
  // tests (getTracks, addTrack, id).
631
819
  installStubMediaStream();
632
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
+
633
830
  // play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
634
831
  // The noise pipeline awaits video.play() on its internal video element.
635
832
  if (typeof HTMLMediaElement !== "undefined") {
@@ -658,6 +855,18 @@ class StubMediaStream {
658
855
  readonly id: string = Math.random().toString(36).slice(2);
659
856
  private readonly _tracks: MediaStreamTrack[] = [];
660
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
+
661
870
  getTracks(): MediaStreamTrack[] {
662
871
  return [...this._tracks];
663
872
  }
@@ -692,3 +901,78 @@ function installStubMediaStream(): void {
692
901
  }
693
902
  (globalThis as unknown as { MediaStream: unknown }).MediaStream = StubMediaStream;
694
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
+ }
@@ -1,5 +1,5 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { CameraLifecycle, Layer } from "./__generated__/types";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { CameraLifecycle, Layer, TrackMode } from "./__generated__/types";
3
3
  import { KerbcastClient } from "./index";
4
4
  import { MockSidecar } from "./testing/index";
5
5
 
@@ -121,6 +121,48 @@ describe("MockSidecar", () => {
121
121
  expect(last?.content.fov).toBe(60);
122
122
  });
123
123
 
124
+ it("accepts and records report-display-size as an advisory command", async () => {
125
+ const sidecar = new MockSidecar();
126
+ sidecar.addCamera({ flightId: 42 });
127
+
128
+ const client = new KerbcastClient(
129
+ { host: "localhost", port: 8088 },
130
+ sidecar.createTransport(),
131
+ );
132
+ await client.connect([42]);
133
+ sidecar.open();
134
+
135
+ // Must not throw on the new message type, and must be retrievable.
136
+ await client.reportDisplaySize(42, 40, 40);
137
+
138
+ const cmd = sidecar.lastCommand("report-display-size", 42);
139
+ expect(cmd?.content).toEqual({ flightId: 42, width: 40, height: 40 });
140
+ // Advisory: reporting a display size must NOT be translated into an
141
+ // operator set-render-size command (that path is the manual cap only).
142
+ expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
143
+ });
144
+
145
+ it("set-track-target updates trackMode and echoes it to the client (server-authoritative)", async () => {
146
+ const sidecar = new MockSidecar();
147
+ sidecar.addCamera({ flightId: 7, supportsPan: true, supportsZoom: true });
148
+
149
+ const client = new KerbcastClient(
150
+ { host: "localhost", port: 8088 },
151
+ sidecar.createTransport(),
152
+ );
153
+ await client.connect([7]);
154
+ sidecar.open();
155
+
156
+ await client.setTrackTarget(7, TrackMode.Target);
157
+ expect(sidecar.lastCommand("set-track-target", 7)?.content.mode).toBe(TrackMode.Target);
158
+ // The mock echoes camera-state-changed so every client reflects the same
159
+ // server-held mode (never optimistic-local).
160
+ expect(client.cameras.find((c) => c.flightId === 7)?.trackMode).toBe(TrackMode.Target);
161
+
162
+ await client.setTrackTarget(7, TrackMode.None);
163
+ expect(client.cameras.find((c) => c.flightId === 7)?.trackMode).toBe(TrackMode.None);
164
+ });
165
+
124
166
  it("updateCamera pushes state-change to the client", async () => {
125
167
  const sidecar = new MockSidecar();
126
168
  sidecar.addCamera({ flightId: 42, layers: [Layer.Near] });
@@ -154,3 +196,149 @@ describe("MockSidecar", () => {
154
196
  expect(states).toEqual(["connected", "failed"]);
155
197
  });
156
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
+ });