@ksp-gonogo/kerbcast-react 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
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 { FeedAction, CameraStreamHook } from "./CameraFeed";
24
+ import { useKerbcastStream } from "./hooks/useKerbcastStream";
25
+ import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
26
+ import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
27
+ import { isCameraDestroyed } from "./lifecycle";
28
+ import { StandbyIcon } from "./StandbyIcon";
29
+
30
+ /**
31
+ * Coarse presentation state a wrapper reacts to for its own fallback/badge:
32
+ * - `live`: a stream is bound and playing.
33
+ * - `no-signal`: no stream yet (connecting, out of flight, or not visible).
34
+ * - `destroyed`: the camera's part/kerbal is gone (the wrapper renders SIGNAL
35
+ * LOST; the last frame stays on-screen because the `<video>` is not cleared).
36
+ */
37
+ export type KerbalFeedState = "live" | "no-signal" | "destroyed";
38
+
39
+ export interface KerbalFaceFeedProps {
40
+ /** Wire-id (`CameraState.flightId`) of the kerbal face camera to display. */
41
+ flightId: number;
42
+ /**
43
+ * Explicit square side in px, for LAYOUT only. When omitted the feed fills
44
+ * its parent's width at a 1:1 aspect ratio. This does NOT drive resolution:
45
+ * the primitive always self-measures its rendered box and reports that to the
46
+ * sidecar (auto-resolution). Pass it to fix the element's size, not to
47
+ * request a stream resolution.
48
+ */
49
+ size?: number;
50
+ /**
51
+ * Report the self-measured display size to the sidecar to drive
52
+ * auto-resolution. Default true. Set false for a fixed-resolution feed that
53
+ * should not participate in auto-res. Independent of {@link showActions}
54
+ * (a resolution concern, not a UI one).
55
+ */
56
+ reportSize?: boolean;
57
+ /**
58
+ * Consumer action buttons, rendered as a minimal top-right bar (the shared
59
+ * {@link FeedAction} shape). Omit for no bar — the common case, since a crew
60
+ * bar usually composes its controls in `children` instead.
61
+ */
62
+ actions?: FeedAction[];
63
+ /**
64
+ * Render the feed's action UI (the top-right action bar). Default true. Set
65
+ * false to suppress the bar entirely (e.g. a tiny avatar where hover controls
66
+ * add nothing). Does not affect display-size reporting.
67
+ */
68
+ showActions?: boolean;
69
+ /**
70
+ * Overlay content layered OVER the square face: name label, IVA/EVA badge,
71
+ * click target, a custom destroyed treatment. Absolutely positioned to fill
72
+ * the frame; the primitive keeps its own chrome out of the way.
73
+ */
74
+ children?: React.ReactNode;
75
+ /**
76
+ * Fired whenever the derived feed state changes, so the wrapper can swap its
77
+ * fallback/badge without polling. Fires once on mount with the initial state.
78
+ */
79
+ onFeedStateChange?: (state: KerbalFeedState) => void;
80
+ /**
81
+ * Draw the built-in standby glyph while not `live`. Default true. Set false
82
+ * when the wrapper renders its own out-of-signal overlay (so it isn't drawn
83
+ * twice); the frame then just stays dark / holds the last frame.
84
+ */
85
+ showStandby?: boolean;
86
+ /**
87
+ * Override how the stream is sourced (delayed playout, alternate transport).
88
+ * Must be a stable reference passed consistently across renders — it is
89
+ * called as a hook. Omit to use the built-in {@link useKerbcastStream}.
90
+ */
91
+ useStream?: CameraStreamHook;
92
+ }
93
+
94
+ const Frame = styled.div<{ $size?: number }>`
95
+ position: relative;
96
+ ${(p) => (p.$size != null ? `width: ${p.$size}px; height: ${p.$size}px;` : "width: 100%;")}
97
+ aspect-ratio: 1 / 1;
98
+ overflow: hidden;
99
+ background: #000;
100
+ border-radius: 4px;
101
+ `;
102
+
103
+ const Face = styled.video`
104
+ width: 100%;
105
+ height: 100%;
106
+ /* Cover the square: the portrait fills the frame, cropping the excess,
107
+ rather than letterboxing — a face tile reads best edge-to-edge. */
108
+ object-fit: cover;
109
+ display: block;
110
+ `;
111
+
112
+ const StandbyLayer = styled.div`
113
+ position: absolute;
114
+ inset: 0;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ color: rgba(255, 255, 255, 0.35);
119
+ pointer-events: none;
120
+ `;
121
+
122
+ const DestroyedScrim = styled.div`
123
+ position: absolute;
124
+ inset: 0;
125
+ background: rgba(0, 0, 0, 0.45);
126
+ pointer-events: none;
127
+ `;
128
+
129
+ const OverlayLayer = styled.div`
130
+ position: absolute;
131
+ inset: 0;
132
+ `;
133
+
134
+ const ActionBar = styled.div`
135
+ position: absolute;
136
+ top: 4px;
137
+ right: 4px;
138
+ display: flex;
139
+ gap: 4px;
140
+ z-index: 2;
141
+ `;
142
+
143
+ const ActionButton = styled.button<{ $active?: boolean }>`
144
+ display: inline-flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ width: 22px;
148
+ height: 22px;
149
+ padding: 0;
150
+ border: none;
151
+ border-radius: 3px;
152
+ cursor: pointer;
153
+ color: #fff;
154
+ background: ${(p) => (p.$active ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.45)")};
155
+ `;
156
+
157
+ export function KerbalFaceFeed({
158
+ flightId,
159
+ size,
160
+ reportSize = true,
161
+ actions,
162
+ showActions = true,
163
+ children,
164
+ onFeedStateChange,
165
+ showStandby = true,
166
+ useStream,
167
+ }: KerbalFaceFeedProps) {
168
+ // Match CameraFeed: the built-in hook (which acquires/releases the
169
+ // subscription slot) runs only when no override is supplied.
170
+ const resolveStream = useStream ?? useKerbcastStream;
171
+ const stream = resolveStream(flightId);
172
+
173
+ // The camera's lifecycle comes from the registry snapshot; destroyed cameras
174
+ // linger in the list so the wrapper can show SIGNAL LOST.
175
+ const cameras = useKerbcastCameras();
176
+ const destroyed = cameras.some((c) => c.flightId === flightId && isCameraDestroyed(c));
177
+
178
+ const state: KerbalFeedState = destroyed
179
+ ? "destroyed"
180
+ : stream
181
+ ? "live"
182
+ : "no-signal";
183
+
184
+ // Self-measure the square frame and report it to the sidecar (auto-res).
185
+ // Reporting follows the mount lifecycle; the registry collapses multiple
186
+ // feeds of the same kerbal to one MAX report.
187
+ const frameRef = useRef<HTMLDivElement>(null);
188
+ useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
189
+
190
+ // Bind the stream to the persistent <video> without remounting it — the
191
+ // element stays mounted across every re-layout / prop change, only its
192
+ // srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
193
+ const videoRef = useRef<HTMLVideoElement>(null);
194
+ useEffect(() => {
195
+ if (videoRef.current && stream) {
196
+ videoRef.current.srcObject = stream;
197
+ }
198
+ }, [stream]);
199
+
200
+ // Notify the wrapper on every state transition (and once on mount).
201
+ const notify = useRef(onFeedStateChange);
202
+ notify.current = onFeedStateChange;
203
+ useEffect(() => {
204
+ notify.current?.(state);
205
+ }, [state]);
206
+
207
+ return (
208
+ <Frame ref={frameRef} $size={size} data-testid="kerbal-face-feed" data-feed-state={state}>
209
+ <Face ref={videoRef} autoPlay playsInline muted controls={false} />
210
+ {state === "destroyed" && <DestroyedScrim />}
211
+ {showStandby && state !== "live" && (
212
+ <StandbyLayer>
213
+ <StandbyIcon size={Math.round((size ?? 96) * 0.32)} />
214
+ </StandbyLayer>
215
+ )}
216
+ {showActions && actions && actions.length > 0 && (
217
+ <ActionBar>
218
+ {actions.map((a) => (
219
+ <ActionButton
220
+ key={a.id}
221
+ type="button"
222
+ aria-label={a.label}
223
+ title={a.label}
224
+ aria-pressed={a.active}
225
+ $active={a.active}
226
+ onClick={a.onClick}
227
+ >
228
+ {a.icon}
229
+ </ActionButton>
230
+ ))}
231
+ </ActionBar>
232
+ )}
233
+ {children && <OverlayLayer>{children}</OverlayLayer>}
234
+ </Frame>
235
+ );
236
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
3
+ import { createClientDisplaySizes } from "./context";
4
+
5
+ /**
6
+ * A client stub that only records reportDisplaySize calls. The registry is the
7
+ * per-consumer MAX collapse across every mounted reporter for one flightId, so
8
+ * a spotlight and a tiny avatar of the same camera resolve to one report = max,
9
+ * and a departing big reporter relaxes the stream back down (the v1.6.3
10
+ * "don't pin large" discipline at the client edge).
11
+ */
12
+ function fakeClient() {
13
+ const reports: Array<{ flightId: number; width: number; height: number }> = [];
14
+ const client = {
15
+ reportDisplaySize: vi.fn(async (flightId: number, width: number, height: number) => {
16
+ reports.push({ flightId, width, height });
17
+ }),
18
+ } as unknown as KerbcastClient;
19
+ return { client, reports };
20
+ }
21
+
22
+ describe("createClientDisplaySizes", () => {
23
+ it("reports the measured size for a single reporter", () => {
24
+ const { client, reports } = fakeClient();
25
+ const reg = createClientDisplaySizes(client);
26
+ reg.report(1, "a", 40, 40);
27
+ expect(reports).toEqual([{ flightId: 1, width: 40, height: 40 }]);
28
+ });
29
+
30
+ it("collapses two reporters of one camera to a single MAX report", () => {
31
+ const { client, reports } = fakeClient();
32
+ const reg = createClientDisplaySizes(client);
33
+ reg.report(1, "small", 40, 40);
34
+ reg.report(1, "big", 512, 512);
35
+ // 40 first, then bumped to the max 512 when the big one mounts.
36
+ expect(reports).toEqual([
37
+ { flightId: 1, width: 40, height: 40 },
38
+ { flightId: 1, width: 512, height: 512 },
39
+ ]);
40
+ });
41
+
42
+ it("maxes width and height independently across reporters", () => {
43
+ const { client, reports } = fakeClient();
44
+ const reg = createClientDisplaySizes(client);
45
+ reg.report(1, "a", 640, 360);
46
+ reg.report(1, "b", 320, 400);
47
+ expect(reports.at(-1)).toEqual({ flightId: 1, width: 640, height: 400 });
48
+ });
49
+
50
+ it("does not re-report when the max is unchanged", () => {
51
+ const { client, reports } = fakeClient();
52
+ const reg = createClientDisplaySizes(client);
53
+ reg.report(1, "big", 512, 512);
54
+ reg.report(1, "small", 40, 40); // below current max -> no new report
55
+ reg.report(1, "small", 44, 44); // still below -> no new report
56
+ expect(reports).toEqual([{ flightId: 1, width: 512, height: 512 }]);
57
+ });
58
+
59
+ it("relaxes to the next-largest reporter when the big one departs", () => {
60
+ const { client, reports } = fakeClient();
61
+ const reg = createClientDisplaySizes(client);
62
+ reg.report(1, "small", 40, 40);
63
+ reg.report(1, "big", 512, 512);
64
+ reg.clear(1, "big");
65
+ expect(reports.at(-1)).toEqual({ flightId: 1, width: 40, height: 40 });
66
+ });
67
+
68
+ it("sends nothing more once the last reporter departs (backend forget clears it)", () => {
69
+ const { client, reports } = fakeClient();
70
+ const reg = createClientDisplaySizes(client);
71
+ reg.report(1, "a", 40, 40);
72
+ const countBefore = reports.length;
73
+ reg.clear(1, "a");
74
+ expect(reports.length).toBe(countBefore); // no extra report on empty
75
+ });
76
+
77
+ it("keeps separate maxes per flightId", () => {
78
+ const { client, reports } = fakeClient();
79
+ const reg = createClientDisplaySizes(client);
80
+ reg.report(1, "a", 40, 40);
81
+ reg.report(2, "b", 512, 512);
82
+ expect(reports).toEqual([
83
+ { flightId: 1, width: 40, height: 40 },
84
+ { flightId: 2, width: 512, height: 512 },
85
+ ]);
86
+ });
87
+
88
+ it("clearing an unknown reporter is a no-op", () => {
89
+ const { client, reports } = fakeClient();
90
+ const reg = createClientDisplaySizes(client);
91
+ reg.clear(99, "ghost");
92
+ expect(reports).toEqual([]);
93
+ });
94
+ });
package/src/context.tsx CHANGED
@@ -46,6 +46,78 @@ export function createClientSubscriptions(
46
46
  };
47
47
  }
48
48
 
49
+ // ---------------------------------------------------------------------------
50
+ // Display-size reporting seam
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Collects each mounted feed's self-measured display size and drives the
55
+ * client's per-consumer `reportDisplaySize`. Multiple feeds can show the SAME
56
+ * camera at once (e.g. a tiny avatar and a spotlight of the same kerbal); the
57
+ * sidecar keys the auto-resolution max per CONSUMER, so those would clobber
58
+ * each other last-writer-wins. This collapses them at the client edge: it
59
+ * keeps the MAX display size across every mounted reporter for a flightId and
60
+ * sends one report per max-change. Co-located with the subscription refcount
61
+ * (same per-flightId lifetime) so one object owns everything this client is
62
+ * doing with camera N.
63
+ */
64
+ export interface KerbcastDisplaySizes {
65
+ /** Register/update a reporter's measured size for a camera. */
66
+ report(flightId: number, instanceId: string, width: number, height: number): void;
67
+ /** Deregister a reporter (on unmount / flightId change). */
68
+ clear(flightId: number, instanceId: string): void;
69
+ }
70
+
71
+ /** Build the default per-flightId MAX display-size registry for a client. */
72
+ export function createClientDisplaySizes(
73
+ client: KerbcastClient,
74
+ ): KerbcastDisplaySizes {
75
+ // flightId -> (instanceId -> measured size)
76
+ const perCamera = new Map<number, Map<string, { w: number; h: number }>>();
77
+ // Last size reported to the sidecar per flightId, so we send only on change.
78
+ const lastSent = new Map<number, { w: number; h: number }>();
79
+
80
+ function recompute(flightId: number): void {
81
+ const instances = perCamera.get(flightId);
82
+ if (!instances || instances.size === 0) {
83
+ // No reporters left. Drop bookkeeping and send nothing: the subscription
84
+ // release (unsubscribe -> sidecar remove_track -> forget_display_size)
85
+ // clears the sidecar side. Sending a spurious size here would fight it.
86
+ perCamera.delete(flightId);
87
+ lastSent.delete(flightId);
88
+ return;
89
+ }
90
+ let maxW = 0;
91
+ let maxH = 0;
92
+ for (const { w, h } of instances.values()) {
93
+ if (w > maxW) maxW = w;
94
+ if (h > maxH) maxH = h;
95
+ }
96
+ const prev = lastSent.get(flightId);
97
+ if (prev && prev.w === maxW && prev.h === maxH) return;
98
+ lastSent.set(flightId, { w: maxW, h: maxH });
99
+ client.reportDisplaySize(flightId, maxW, maxH).catch(() => {});
100
+ }
101
+
102
+ return {
103
+ report(flightId: number, instanceId: string, width: number, height: number): void {
104
+ let instances = perCamera.get(flightId);
105
+ if (!instances) {
106
+ instances = new Map();
107
+ perCamera.set(flightId, instances);
108
+ }
109
+ instances.set(instanceId, { w: width, h: height });
110
+ recompute(flightId);
111
+ },
112
+ clear(flightId: number, instanceId: string): void {
113
+ const instances = perCamera.get(flightId);
114
+ if (!instances) return;
115
+ instances.delete(instanceId);
116
+ recompute(flightId);
117
+ },
118
+ };
119
+ }
120
+
49
121
  // ---------------------------------------------------------------------------
50
122
  // Context shape
51
123
  // ---------------------------------------------------------------------------
@@ -53,6 +125,7 @@ export function createClientSubscriptions(
53
125
  interface KerbcastContextValue {
54
126
  client: KerbcastClient;
55
127
  subscriptions: KerbcastSubscriptions;
128
+ displaySizes: KerbcastDisplaySizes;
56
129
  }
57
130
 
58
131
  const KerbcastContext = createContext<KerbcastContextValue | null>(null);
@@ -65,6 +138,8 @@ export interface KerbcastProviderProps {
65
138
  client: KerbcastClient;
66
139
  /** Override the subscription manager. Defaults to a refcounted per-client impl. */
67
140
  subscriptions?: KerbcastSubscriptions;
141
+ /** Override the display-size registry. Defaults to a per-flightId MAX impl. */
142
+ displaySizes?: KerbcastDisplaySizes;
68
143
  children: ReactNode;
69
144
  }
70
145
 
@@ -75,6 +150,7 @@ export interface KerbcastProviderProps {
75
150
  export function KerbcastProvider({
76
151
  client,
77
152
  subscriptions: subscriptionsProp,
153
+ displaySizes: displaySizesProp,
78
154
  children,
79
155
  }: KerbcastProviderProps): React.JSX.Element {
80
156
  // Create default subscriptions once per client. A ref so the object is
@@ -84,13 +160,20 @@ export function KerbcastProvider({
84
160
  defaultSubsRef.current = createClientSubscriptions(client);
85
161
  }
86
162
 
163
+ // Same for the display-size registry: one per client, stable across renders.
164
+ const defaultSizesRef = useRef<KerbcastDisplaySizes | null>(null);
165
+ if (defaultSizesRef.current === null) {
166
+ defaultSizesRef.current = createClientDisplaySizes(client);
167
+ }
168
+
87
169
  const value = useMemo(
88
170
  () => ({
89
171
  client,
90
172
  subscriptions: subscriptionsProp ?? defaultSubsRef.current!,
173
+ displaySizes: displaySizesProp ?? defaultSizesRef.current!,
91
174
  }),
92
175
  // eslint-disable-next-line react-hooks/exhaustive-deps
93
- [client, subscriptionsProp],
176
+ [client, subscriptionsProp, displaySizesProp],
94
177
  );
95
178
 
96
179
  return (
@@ -127,3 +210,17 @@ export function useKerbcastSubscriptions(): KerbcastSubscriptions {
127
210
  }
128
211
  return ctx.subscriptions;
129
212
  }
213
+
214
+ /**
215
+ * Return the `KerbcastDisplaySizes` registry from the nearest
216
+ * `KerbcastProvider`. Throws if called outside a provider.
217
+ */
218
+ export function useKerbcastDisplaySizes(): KerbcastDisplaySizes {
219
+ const ctx = useContext(KerbcastContext);
220
+ if (!ctx) {
221
+ throw new Error(
222
+ "useKerbcastDisplaySizes must be used inside a KerbcastProvider",
223
+ );
224
+ }
225
+ return ctx.displaySizes;
226
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for useReportDisplaySize: the self-measure -> report path. Drives a
3
+ * controllable ResizeObserver and a recording displaySizes registry override
4
+ * so the debounce / bucket / prompt-first / square-vs-wh / clear-on-unmount
5
+ * behaviour is asserted directly, without a real layout engine.
6
+ */
7
+
8
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
9
+ import { act, cleanup, render } from "@testing-library/react";
10
+ import { useRef } from "react";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { KerbcastProvider, type KerbcastDisplaySizes } from "../context";
14
+ import { useReportDisplaySize } from "./useReportDisplaySize";
15
+
16
+ // A controllable ResizeObserver: captures the callback so a test can fire a
17
+ // resize with a chosen contentRect.
18
+ let resizeCallback:
19
+ | ((entries: ResizeObserverEntry[], observer: ResizeObserver) => void)
20
+ | undefined;
21
+ const originalResizeObserver = globalThis.ResizeObserver;
22
+
23
+ // A recording registry override, injected via the provider.
24
+ function recordingRegistry() {
25
+ const reports: Array<{ flightId: number; id: string; w: number; h: number }> = [];
26
+ const clears: Array<{ flightId: number; id: string }> = [];
27
+ const displaySizes: KerbcastDisplaySizes = {
28
+ report: (flightId, id, w, h) => reports.push({ flightId, id, w, h }),
29
+ clear: (flightId, id) => clears.push({ flightId, id }),
30
+ };
31
+ return { displaySizes, reports, clears };
32
+ }
33
+
34
+ const fakeClient = {} as unknown as KerbcastClient;
35
+
36
+ function fireResize(width: number, height: number) {
37
+ act(() => {
38
+ resizeCallback?.(
39
+ [{ contentRect: { width, height } }] as ResizeObserverEntry[],
40
+ {} as ResizeObserver,
41
+ );
42
+ });
43
+ }
44
+
45
+ function Harness({
46
+ flightId,
47
+ square,
48
+ enabled,
49
+ }: {
50
+ flightId: number | null;
51
+ square?: boolean;
52
+ enabled?: boolean;
53
+ }) {
54
+ const ref = useRef<HTMLDivElement>(null);
55
+ useReportDisplaySize(flightId, ref, { square, enabled });
56
+ return <div ref={ref} data-testid="feed" />;
57
+ }
58
+
59
+ beforeEach(() => {
60
+ vi.useFakeTimers();
61
+ globalThis.ResizeObserver = class {
62
+ constructor(cb: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void) {
63
+ resizeCallback = cb;
64
+ }
65
+ observe() {}
66
+ unobserve() {}
67
+ disconnect() {}
68
+ } as unknown as typeof ResizeObserver;
69
+ });
70
+
71
+ afterEach(() => {
72
+ cleanup();
73
+ globalThis.ResizeObserver = originalResizeObserver;
74
+ resizeCallback = undefined;
75
+ vi.useRealTimers();
76
+ });
77
+
78
+ describe("useReportDisplaySize", () => {
79
+ it("reports the first measurement promptly (no debounce wait)", () => {
80
+ const { displaySizes, reports } = recordingRegistry();
81
+ render(
82
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
83
+ <Harness flightId={7} />
84
+ </KerbcastProvider>,
85
+ );
86
+ // 40x30 -> ceil to a 16-multiple = 48x32, reported without advancing timers.
87
+ fireResize(40, 30);
88
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 32 }]);
89
+ });
90
+
91
+ it("reports a square (min edge) when square is set", () => {
92
+ const { displaySizes, reports } = recordingRegistry();
93
+ render(
94
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
95
+ <Harness flightId={7} square />
96
+ </KerbcastProvider>,
97
+ );
98
+ // min(50,40)=40 -> 48x48 square.
99
+ fireResize(50, 40);
100
+ expect(reports.at(-1)).toMatchObject({ w: 48, h: 48 });
101
+ });
102
+
103
+ it("debounces churn after the first report", () => {
104
+ const { displaySizes, reports } = recordingRegistry();
105
+ render(
106
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
107
+ <Harness flightId={7} />
108
+ </KerbcastProvider>,
109
+ );
110
+ fireResize(48, 48); // prompt first -> 48x48
111
+ fireResize(200, 200); // debounced, not yet applied
112
+ expect(reports).toHaveLength(1);
113
+ act(() => vi.advanceTimersByTime(200));
114
+ // 200 -> 208; the debounced resize now lands.
115
+ expect(reports.at(-1)).toMatchObject({ w: 208, h: 208 });
116
+ });
117
+
118
+ it("does not re-report a sub-bucket change", () => {
119
+ const { displaySizes, reports } = recordingRegistry();
120
+ render(
121
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
122
+ <Harness flightId={7} />
123
+ </KerbcastProvider>,
124
+ );
125
+ fireResize(40, 40); // -> 48x48
126
+ fireResize(44, 44); // still ceils to 48x48 -> no new report
127
+ act(() => vi.advanceTimersByTime(200));
128
+ expect(reports).toHaveLength(1);
129
+ });
130
+
131
+ it("clears its registration on unmount", () => {
132
+ const { displaySizes, reports, clears } = recordingRegistry();
133
+ const { unmount } = render(
134
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
135
+ <Harness flightId={7} />
136
+ </KerbcastProvider>,
137
+ );
138
+ fireResize(40, 40);
139
+ const id = reports[0].id;
140
+ act(() => unmount());
141
+ expect(clears).toEqual([{ flightId: 7, id }]);
142
+ });
143
+
144
+ it("does not observe or report when disabled", () => {
145
+ const { displaySizes, reports } = recordingRegistry();
146
+ render(
147
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
148
+ <Harness flightId={7} enabled={false} />
149
+ </KerbcastProvider>,
150
+ );
151
+ // The observer is never constructed when disabled, so no callback is armed.
152
+ expect(resizeCallback).toBeUndefined();
153
+ expect(reports).toEqual([]);
154
+ });
155
+
156
+ it("does nothing for a null flightId", () => {
157
+ const { displaySizes, reports } = recordingRegistry();
158
+ render(
159
+ <KerbcastProvider client={fakeClient} displaySizes={displaySizes}>
160
+ <Harness flightId={null} />
161
+ </KerbcastProvider>,
162
+ );
163
+ expect(resizeCallback).toBeUndefined();
164
+ expect(reports).toEqual([]);
165
+ });
166
+ });