@ksp-gonogo/kerbcast-react 1.10.2 → 1.11.0-rc.10

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,156 @@
1
+ /**
2
+ * Display UT for command feeds and the displayed-frame UT hook: the presented
3
+ * frame's own capture stamp wins over the capture clock, every value says
4
+ * which it is, and a host override beats both.
5
+ */
6
+
7
+ import { KerbcastClient, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
8
+ import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
9
+ import { act, cleanup, render } from "@testing-library/react";
10
+ import { afterEach, describe, expect, it } from "vitest";
11
+ import { KerbcastProvider } from "../context";
12
+ import { crossesEvent, useCommandDisplayUt, type CommandDisplayUt } from "./useCommandDisplayUt";
13
+ import { useDisplayedFrameUt } from "./useDisplayedFrameUt";
14
+
15
+ async function connected() {
16
+ const sidecar = new MockSidecar();
17
+ const client = new KerbcastClient(
18
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
19
+ sidecar.createTransport(),
20
+ );
21
+ await act(async () => {
22
+ await client.connect([], { slots: 4 });
23
+ sidecar.open();
24
+ sidecar.setConnectionState("connected");
25
+ });
26
+ sidecar.fireSettingsState({ throttleMainScreen: false, captureUt: 1000, timeWarpRate: 1 });
27
+ return { client, sidecar };
28
+ }
29
+
30
+ /** A video element whose frames the test presents by hand. */
31
+ function fakeVideo() {
32
+ let pending: ((now: number, meta: object) => void) | null = null;
33
+ const video = {
34
+ requestVideoFrameCallback: (cb: (now: number, meta: object) => void) => {
35
+ pending = cb;
36
+ return 1;
37
+ },
38
+ cancelVideoFrameCallback: () => {
39
+ pending = null;
40
+ },
41
+ } as unknown as HTMLVideoElement;
42
+ const present = (rtpTimestamp?: number) =>
43
+ act(() => {
44
+ const cb = pending;
45
+ pending = null;
46
+ cb?.(0, rtpTimestamp === undefined ? {} : { rtpTimestamp });
47
+ });
48
+ return { video, present };
49
+ }
50
+
51
+ const event = (ut: number): CommandTrackEvent =>
52
+ ({ ut, occluded: true, sunlit: true, occluder: "", blank: "" }) as CommandTrackEvent;
53
+
54
+ let last: CommandDisplayUt | null | undefined;
55
+ function CommandProbe(props: {
56
+ override?: number;
57
+ events?: CommandTrackEvent[];
58
+ video: HTMLVideoElement | null;
59
+ }) {
60
+ last = useCommandDisplayUt(true, props.override, props.events, 7, props.video);
61
+ return null;
62
+ }
63
+
64
+ afterEach(() => {
65
+ cleanup();
66
+ last = undefined;
67
+ });
68
+
69
+ describe("useCommandDisplayUt", () => {
70
+ it("is estimated from the clock until an exactly stamped frame is shown", async () => {
71
+ const { client, sidecar } = await connected();
72
+ const { video, present } = fakeVideo();
73
+ render(
74
+ <KerbcastProvider client={client}>
75
+ <CommandProbe video={video} />
76
+ </KerbcastProvider>,
77
+ );
78
+ expect(last?.source).toBe("estimated");
79
+
80
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 4500, captureUt: 999.8, captureEpoch: 0 });
81
+ present(4500);
82
+ expect(last).toEqual({ ut: 999.8, source: "exact" });
83
+
84
+ /* An unstamped frame falls back to the clock, and says so. */
85
+ present(9000);
86
+ expect(last?.source).toBe("estimated");
87
+ });
88
+
89
+ it("re-renders when an exact frame steps across an event", async () => {
90
+ const { client, sidecar } = await connected();
91
+ const { video, present } = fakeVideo();
92
+ render(
93
+ <KerbcastProvider client={client}>
94
+ <CommandProbe video={video} events={[event(999.9)]} />
95
+ </KerbcastProvider>,
96
+ );
97
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0 });
98
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 2, captureUt: 999.85, captureEpoch: 0 });
99
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 3, captureUt: 999.95, captureEpoch: 0 });
100
+ present(1);
101
+ expect(last?.ut).toBe(999.8);
102
+ /* Same side of the event: no re-render needed, the value shown holds. */
103
+ present(2);
104
+ expect(last?.ut).toBe(999.8);
105
+ present(3);
106
+ expect(last?.ut).toBe(999.95);
107
+ });
108
+
109
+ it("prefers a host override over the displayed frame", async () => {
110
+ const { client, sidecar } = await connected();
111
+ const { video, present } = fakeVideo();
112
+ render(
113
+ <KerbcastProvider client={client}>
114
+ <CommandProbe video={video} override={990} />
115
+ </KerbcastProvider>,
116
+ );
117
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 1, captureUt: 999.8, captureEpoch: 0 });
118
+ present(1);
119
+ expect(last).toEqual({ ut: 990, source: "override" });
120
+ });
121
+ });
122
+
123
+ describe("crossesEvent", () => {
124
+ it("counts an event the display moves onto or past, in either direction", () => {
125
+ const events = [event(10)];
126
+ expect(crossesEvent(events, 9, 10)).toBe(true);
127
+ expect(crossesEvent(events, 11, 9)).toBe(true);
128
+ expect(crossesEvent(events, 10, 11)).toBe(false);
129
+ expect(crossesEvent(undefined, 0, 100)).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("useDisplayedFrameUt", () => {
134
+ let frame: ReturnType<typeof useDisplayedFrameUt> | undefined;
135
+ function FrameProbe({ video }: { video: HTMLVideoElement }) {
136
+ frame = useDisplayedFrameUt(7, video);
137
+ return null;
138
+ }
139
+
140
+ it("reports every presented frame with its source", async () => {
141
+ const { client, sidecar } = await connected();
142
+ const { video, present } = fakeVideo();
143
+ render(
144
+ <KerbcastProvider client={client}>
145
+ <FrameProbe video={video} />
146
+ </KerbcastProvider>,
147
+ );
148
+ expect(frame).toBeNull();
149
+ sidecar.fireFrameStamp({ flightId: 7, rtpTimestamp: 77, captureUt: 999.5, captureEpoch: 4 });
150
+ present(77);
151
+ expect(frame).toMatchObject({ ut: 999.5, epoch: 4, source: "exact", rtpTimestamp: 77 });
152
+ /* Unstamped: bounded by the last exactly identified frame. */
153
+ present(78);
154
+ expect(frame).toMatchObject({ ut: 999.5, source: "estimated", rtpTimestamp: 77 });
155
+ });
156
+ });
@@ -0,0 +1,135 @@
1
+ import { useEffect, useReducer, useRef } from "react";
2
+ import {
3
+ extrapolateCaptureUt,
4
+ type DisplayedFrameUt,
5
+ type FrameUtSource,
6
+ type CommandTrackEvent,
7
+ } from "@ksp-gonogo/kerbcast";
8
+ import { useKerbcastClient } from "../context";
9
+ import { nowMs } from "../timing";
10
+
11
+ export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
12
+
13
+ const TICK_MS = 500;
14
+
15
+ /** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
16
+ export function msUntilNextEvent(
17
+ events: readonly CommandTrackEvent[] | undefined,
18
+ displayUt: number,
19
+ warpRate: number,
20
+ ): number | null {
21
+ if (!events?.length || !(warpRate > 0)) return null;
22
+ let next = Infinity;
23
+ for (const e of events) if (e.ut > displayUt && e.ut < next) next = e.ut;
24
+ return next === Infinity ? null : ((next - displayUt) / warpRate) * 1000;
25
+ }
26
+
27
+ /** Whether an event's UT lies in `(from, to]`, i.e. moving the display from `from` to `to` changes the state in force. */
28
+ export function crossesEvent(
29
+ events: readonly CommandTrackEvent[] | undefined,
30
+ from: number,
31
+ to: number,
32
+ ): boolean {
33
+ if (!events?.length) return false;
34
+ const lo = Math.min(from, to);
35
+ const hi = Math.max(from, to);
36
+ return events.some((e) => e.ut > lo && e.ut <= hi);
37
+ }
38
+
39
+ /** The UT a command feed is showing and where it came from; `"override"` is a host's own value. */
40
+ export interface CommandDisplayUt {
41
+ ut: number;
42
+ source: FrameUtSource | "override";
43
+ }
44
+
45
+ /**
46
+ * The UT a command feed is showing. An explicit `override` (a host with its
47
+ * own delayed playout) wins. Otherwise, given the feed's `flightId` and the
48
+ * `video` element showing it, the presented frame's own capture stamp is
49
+ * used (`source: "exact"`); without one, the client's capture clock is
50
+ * interpolated to now (`"estimated"`). Re-evaluated twice a second while
51
+ * `enabled` so readout ages keep counting, and again the moment the
52
+ * displayed UT reaches the next of `events`, so a state change lands on its
53
+ * frame rather than on the next tick. `null` when nothing is known.
54
+ */
55
+ export function useCommandDisplayUt(
56
+ enabled: boolean,
57
+ override?: number,
58
+ events?: readonly CommandTrackEvent[],
59
+ flightId?: number | null,
60
+ video?: HTMLVideoElement | null,
61
+ ): CommandDisplayUt | null {
62
+ const client = useKerbcastClient();
63
+ const sample = useRef<{ captureUt: number | null; warpRate: number; at: number }>({
64
+ captureUt: client.clock.captureUt,
65
+ warpRate: client.clock.warpRate,
66
+ at: nowMs(),
67
+ });
68
+ const frame = useRef<DisplayedFrameUt | null>(null);
69
+ const shown = useRef<CommandDisplayUt | null>(null);
70
+ const eventsRef = useRef(events);
71
+ eventsRef.current = events;
72
+ const [, tick] = useReducer((c: number) => c + 1, 0);
73
+
74
+ useEffect(() => {
75
+ const take = () => {
76
+ const { captureUt, warpRate } = client.clock;
77
+ sample.current = { captureUt, warpRate, at: nowMs() };
78
+ };
79
+ take();
80
+ return client.on("settings-change", take);
81
+ }, [client]);
82
+
83
+ useEffect(() => {
84
+ if (!enabled) return;
85
+ const interval = setInterval(tick, TICK_MS);
86
+ return () => clearInterval(interval);
87
+ }, [enabled]);
88
+
89
+ /* Exact frames re-render only when they change what is drawn: a switch of
90
+ source, or a step across an event. Ages catch up on the tick. */
91
+ const overriding = override !== undefined;
92
+ useEffect(() => {
93
+ frame.current = null;
94
+ if (!enabled || overriding || flightId == null || !video) return;
95
+ return client.watchDisplayedFrames(flightId, video, (f) => {
96
+ frame.current = f.source === "exact" ? f : null;
97
+ const prev = shown.current;
98
+ if (!frame.current) {
99
+ if (prev?.source === "exact") tick();
100
+ return;
101
+ }
102
+ if (
103
+ prev === null ||
104
+ prev.source !== "exact" ||
105
+ crossesEvent(eventsRef.current, prev.ut, f.ut)
106
+ ) {
107
+ tick();
108
+ }
109
+ });
110
+ }, [client, enabled, overriding, flightId, video]);
111
+
112
+ const { captureUt, warpRate, at } = sample.current;
113
+ const exact = frame.current;
114
+ const displayUt: CommandDisplayUt | null = overriding
115
+ ? { ut: override, source: "override" }
116
+ : exact
117
+ ? { ut: exact.ut, source: "exact" }
118
+ : captureUt === null
119
+ ? null
120
+ : { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
121
+ shown.current = displayUt;
122
+ const untilEdge =
123
+ enabled && displayUt?.source === "estimated"
124
+ ? msUntilNextEvent(events, displayUt.ut, warpRate)
125
+ : null;
126
+
127
+ /* Edges further out than a tick are caught by a later render's timer. */
128
+ useEffect(() => {
129
+ if (untilEdge === null || untilEdge > TICK_MS) return;
130
+ const timer = setTimeout(tick, Math.max(0, untilEdge) + 1);
131
+ return () => clearTimeout(timer);
132
+ });
133
+
134
+ return displayUt;
135
+ }
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { DisplayedFrameUt } from "@ksp-gonogo/kerbcast";
3
+ import { useKerbcastClient } from "../context";
4
+
5
+ /**
6
+ * Mission time of the frame `video` is showing for camera `flightId`,
7
+ * updated on every presented frame (so the calling component re-renders at
8
+ * the video's frame rate). `source` says whether the UT is the frame's own
9
+ * capture stamp (`"exact"`) or the capture clock extrapolated
10
+ * (`"estimated"`). `null` until the first frame with a known UT, and while
11
+ * either argument is null. For per-frame work without re-rendering, use
12
+ * `client.watchDisplayedFrames` directly.
13
+ */
14
+ export function useDisplayedFrameUt(
15
+ flightId: number | null,
16
+ video: HTMLVideoElement | null,
17
+ ): DisplayedFrameUt | null {
18
+ const client = useKerbcastClient();
19
+ const [frame, setFrame] = useState<DisplayedFrameUt | null>(null);
20
+ useEffect(() => {
21
+ setFrame(null);
22
+ if (flightId === null || !video) return;
23
+ return client.watchDisplayedFrames(flightId, video, setFrame);
24
+ }, [client, flightId, video]);
25
+ return frame;
26
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The render-size request must never feed the stream back into itself. A host
3
+ * that sizes the measured box from the stream (a frame at the video's aspect,
4
+ * a `<video>` at its own size) closes a loop: stream -> box -> request ->
5
+ * stream. These tests run that loop against a stand-in sidecar that delivers
6
+ * a stream of whatever size it is asked for (or smaller, or late) and assert
7
+ * the request settles instead of spiralling.
8
+ *
9
+ * Layout is faked: a ResizeObserver stub reports the px size in the measured
10
+ * element's inline style (or the tile's when it has none), once per change,
11
+ * on each animation frame the test steps through.
12
+ */
13
+
14
+ import type { KerbcastClient } from "@ksp-gonogo/kerbcast";
15
+ import { act, cleanup, render } from "@testing-library/react";
16
+ import { useEffect, useRef, useState } from "react";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ import { KerbcastProvider, type KerbcastDisplaySizes } from "../context";
20
+ import { useReportDisplaySize, type Size } from "./useReportDisplaySize";
21
+
22
+ const FRAME_MS = 16;
23
+ const fakeClient = { on: () => () => {} } as unknown as KerbcastClient;
24
+
25
+ let tile: Size = { width: 170, height: 400 };
26
+ /* Re-renders the host after the tile changes, as a real layout change would. */
27
+ let relayout = () => {};
28
+
29
+ function resizeTile(size: Size) {
30
+ tile = size;
31
+ act(() => relayout());
32
+ }
33
+
34
+ interface Watched {
35
+ el: Element;
36
+ cb: ResizeObserverCallback;
37
+ last: string | null;
38
+ }
39
+ let watched: Watched[] = [];
40
+
41
+ function boxOf(el: Element): Size {
42
+ const style = (el as HTMLElement).style;
43
+ if (style?.width.endsWith("px") && style.height.endsWith("px")) {
44
+ return { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
45
+ }
46
+ return tile;
47
+ }
48
+
49
+ function layout() {
50
+ for (const w of watched) {
51
+ const box = boxOf(w.el);
52
+ const key = `${box.width}x${box.height}`;
53
+ if (key === w.last) continue;
54
+ w.last = key;
55
+ w.cb(
56
+ [{ target: w.el, contentRect: box as DOMRectReadOnly } as ResizeObserverEntry],
57
+ {} as ResizeObserver,
58
+ );
59
+ }
60
+ }
61
+
62
+ /** Step `ms` of time a frame at a time, laying out after each. */
63
+ function run(ms: number) {
64
+ for (let t = 0; t < ms; t += FRAME_MS) {
65
+ act(() => {
66
+ vi.advanceTimersByTime(FRAME_MS);
67
+ });
68
+ act(() => layout());
69
+ }
70
+ }
71
+
72
+ const FIRST_RENDER: Size = { width: 1280, height: 720 };
73
+
74
+ type Mode = "fit" | "intrinsic" | "late-intrinsic" | "tile";
75
+
76
+ /**
77
+ * The stand-in sidecar: after `latencyMs` the stream becomes `deliver(request)`,
78
+ * seen both as the video's own size and as the camera's render size.
79
+ */
80
+ interface Sidecar {
81
+ reports: Size[];
82
+ deliver: (req: Size) => Size;
83
+ latencyMs: number;
84
+ streamBecomes: (size: Size) => void;
85
+ }
86
+
87
+ function makeSidecar(): Sidecar {
88
+ return {
89
+ reports: [],
90
+ deliver: (req) => req,
91
+ latencyMs: 300,
92
+ streamBecomes: () => {},
93
+ };
94
+ }
95
+
96
+ function registry(sidecar: Sidecar): KerbcastDisplaySizes {
97
+ return {
98
+ report: (_flightId, _id, width, height) => {
99
+ sidecar.reports.push({ width, height });
100
+ setTimeout(() => sidecar.streamBecomes(sidecar.deliver({ width, height })), sidecar.latencyMs);
101
+ },
102
+ clear: () => {},
103
+ };
104
+ }
105
+
106
+ /**
107
+ * A host whose measured box comes from the stream in one of a few natural
108
+ * ways, or ("tile") from layout alone.
109
+ */
110
+ function Host({ mode, sidecar }: { mode: Mode; sidecar: Sidecar }) {
111
+ const frameRef = useRef<HTMLDivElement>(null);
112
+ const videoRef = useRef<HTMLVideoElement>(null);
113
+ const [render, setRender] = useState<Size>(FIRST_RENDER);
114
+ const [shown, setShown] = useState<Size | null>(null);
115
+ const [, bump] = useState(0);
116
+ relayout = () => bump((n) => n + 1);
117
+
118
+ sidecar.streamBecomes = (size) => {
119
+ const v = videoRef.current;
120
+ if (!v) return;
121
+ act(() => {
122
+ Object.defineProperty(v, "videoWidth", { value: size.width, configurable: true });
123
+ Object.defineProperty(v, "videoHeight", { value: size.height, configurable: true });
124
+ setRender(size);
125
+ v.dispatchEvent(new Event("resize"));
126
+ });
127
+ };
128
+
129
+ useEffect(() => {
130
+ const v = videoRef.current;
131
+ if (!v) return;
132
+ const onResize = () => {
133
+ bump((n) => n + 1);
134
+ const size = { width: v.videoWidth, height: v.videoHeight };
135
+ setTimeout(() => setShown(size), 1500);
136
+ };
137
+ v.addEventListener("resize", onResize);
138
+ return () => v.removeEventListener("resize", onResize);
139
+ }, []);
140
+
141
+ useReportDisplaySize(7, frameRef, { videoRef, streamSize: render });
142
+
143
+ const v = videoRef.current;
144
+ const stream = v && v.videoWidth > 0 ? { width: v.videoWidth, height: v.videoHeight } : render;
145
+ let style: React.CSSProperties | undefined;
146
+ if (mode === "fit") {
147
+ const aspect = stream.width / stream.height;
148
+ const w = Math.min(tile.width, tile.height * aspect);
149
+ style = { width: `${Math.floor(w)}px`, height: `${Math.floor(w / aspect)}px` };
150
+ } else if (mode === "intrinsic") {
151
+ style = { width: `${stream.width}px`, height: `${stream.height}px` };
152
+ } else if (mode === "late-intrinsic") {
153
+ const s = shown ?? FIRST_RENDER;
154
+ style = { width: `${s.width}px`, height: `${s.height}px` };
155
+ }
156
+ return (
157
+ <div ref={frameRef} style={style}>
158
+ <video ref={videoRef} />
159
+ </div>
160
+ );
161
+ }
162
+
163
+ function mount(mode: Mode, sidecar = makeSidecar()) {
164
+ render(
165
+ <KerbcastProvider client={fakeClient} displaySizes={registry(sidecar)}>
166
+ <Host mode={mode} sidecar={sidecar} />
167
+ </KerbcastProvider>,
168
+ );
169
+ run(FRAME_MS * 2);
170
+ return sidecar;
171
+ }
172
+
173
+ beforeEach(() => {
174
+ vi.useFakeTimers();
175
+ tile = { width: 170, height: 400 };
176
+ watched = [];
177
+ globalThis.ResizeObserver = class {
178
+ private readonly cb: ResizeObserverCallback;
179
+ constructor(cb: ResizeObserverCallback) {
180
+ this.cb = cb;
181
+ }
182
+ observe(el: Element) {
183
+ watched.push({ el, cb: this.cb, last: null });
184
+ }
185
+ unobserve() {}
186
+ disconnect() {
187
+ watched = watched.filter((w) => w.cb !== this.cb);
188
+ }
189
+ } as unknown as typeof ResizeObserver;
190
+ });
191
+
192
+ afterEach(() => {
193
+ cleanup();
194
+ vi.useRealTimers();
195
+ });
196
+
197
+ describe("useReportDisplaySize: a box sized from the stream", () => {
198
+ it("asks once for a tile sized by layout, at 16:9 whatever the tile's shape", () => {
199
+ const sidecar = mount("tile");
200
+ run(5000);
201
+ expect(sidecar.reports).toEqual([{ width: 176, height: 100 }]);
202
+ });
203
+
204
+ it("settles after the first request when the frame keeps the stream's aspect", () => {
205
+ const sidecar = mount("fit");
206
+ run(10_000);
207
+ expect(sidecar.reports).toEqual([{ width: 176, height: 100 }]);
208
+ });
209
+
210
+ it("does not ratchet down when a video at its own size arrives smaller than asked", () => {
211
+ const sidecar = makeSidecar();
212
+ sidecar.deliver = (req) => ({ width: req.width / 2, height: req.height / 2 });
213
+ mount("intrinsic", sidecar);
214
+ run(20_000);
215
+ expect(sidecar.reports).toEqual([{ width: 1280, height: 720 }]);
216
+ });
217
+
218
+ it("sends nothing when the stream changes size or shape on its own", () => {
219
+ const sidecar = mount("fit");
220
+ const before = sidecar.reports.length;
221
+ for (const size of [
222
+ { width: 96, height: 54 },
223
+ { width: 1920, height: 1080 },
224
+ { width: 160, height: 120 },
225
+ { width: 176, height: 400 },
226
+ ]) {
227
+ sidecar.streamBecomes(size);
228
+ run(3000);
229
+ }
230
+ expect(sidecar.reports.length - before).toBe(0);
231
+ });
232
+
233
+ it("sends nothing while the stream flips between two sizes", () => {
234
+ const sidecar = mount("fit");
235
+ const before = sidecar.reports.length;
236
+ for (let i = 0; i < 6; i++) {
237
+ sidecar.streamBecomes(i % 2 ? { width: 176, height: 128 } : { width: 176, height: 96 });
238
+ run(300);
239
+ }
240
+ run(5000);
241
+ expect(sidecar.reports.length - before).toBe(0);
242
+ });
243
+
244
+ it("lets at most one more request through when the box follows too late to be recognised", () => {
245
+ const sidecar = makeSidecar();
246
+ sidecar.deliver = (req) => ({ width: (req.width * 3) / 4, height: (req.height * 3) / 4 });
247
+ mount("late-intrinsic", sidecar);
248
+ run(30_000);
249
+ expect(sidecar.reports.length).toBeLessThanOrEqual(2);
250
+ for (const r of sidecar.reports) {
251
+ expect(r.width).toBeGreaterThanOrEqual(960);
252
+ expect(r.height).toBeGreaterThanOrEqual(540);
253
+ }
254
+ const settled = sidecar.reports.length;
255
+ run(30_000);
256
+ expect(sidecar.reports.length).toBe(settled);
257
+ });
258
+ });
259
+
260
+ describe("useReportDisplaySize: a genuine layout change", () => {
261
+ it("sends exactly one request, sized from the tile, for a resize dragged across several sizes", () => {
262
+ const sidecar = mount("tile");
263
+ const before = sidecar.reports.length;
264
+ for (const width of [240, 280, 320]) {
265
+ resizeTile({ width, height: 400 });
266
+ act(() => layout());
267
+ }
268
+ run(1000);
269
+ expect(sidecar.reports.slice(before)).toEqual([{ width: 320, height: 180 }]);
270
+ });
271
+
272
+ it("still reports once a stream-sized frame has settled", () => {
273
+ const sidecar = mount("fit");
274
+ run(10_000);
275
+ const before = sidecar.reports.length;
276
+ resizeTile({ width: 320, height: 400 });
277
+ run(1000);
278
+ expect(sidecar.reports.slice(before)).toEqual([{ width: 320, height: 180 }]);
279
+ });
280
+
281
+ it("still reports a resize right after a stream change that the stream can not explain", () => {
282
+ const sidecar = mount("fit");
283
+ run(10_000);
284
+ const before = sidecar.reports.length;
285
+ sidecar.streamBecomes({ width: 160, height: 90 });
286
+ resizeTile({ width: 640, height: 400 });
287
+ run(1000);
288
+ expect(sidecar.reports.slice(before)).toEqual([{ width: 640, height: 360 }]);
289
+ });
290
+ });
@@ -11,7 +11,7 @@ import { useRef } from "react";
11
11
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
12
 
13
13
  import { KerbcastProvider, type KerbcastDisplaySizes } from "../context";
14
- import { useReportDisplaySize } from "./useReportDisplaySize";
14
+ import { displayReportSize, useReportDisplaySize } from "./useReportDisplaySize";
15
15
 
16
16
  // A controllable ResizeObserver: captures the callback so a test can fire a
17
17
  // resize with a chosen contentRect.
@@ -89,9 +89,18 @@ describe("useReportDisplaySize", () => {
89
89
  <Harness flightId={7} />
90
90
  </KerbcastProvider>,
91
91
  );
92
- // 40x30 -> ceil to a 16-multiple = 48x32, reported without advancing timers.
92
+ // 40x30 -> width ceiled to a 16-multiple (48), height at 16:9 rounded to even (28), reported without advancing timers.
93
93
  fireResize(40, 30);
94
- expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 32 }]);
94
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
95
+ });
96
+
97
+ it("asks for the 16:9 picture that fits the box, never the box's shape", () => {
98
+ expect(displayReportSize(1024, 576)).toEqual({ width: 1024, height: 576 });
99
+ expect(displayReportSize(441, 246)).toEqual({ width: 448, height: 252 });
100
+ // A narrow, tall tile is limited by its width.
101
+ expect(displayReportSize(170, 400)).toEqual({ width: 176, height: 100 });
102
+ // A wide, short one by its height: the letterboxed picture is ~356x200.
103
+ expect(displayReportSize(800, 200)).toEqual({ width: 368, height: 208 });
95
104
  });
96
105
 
97
106
  it("reports a square (min edge) when square is set", () => {
@@ -113,12 +122,12 @@ describe("useReportDisplaySize", () => {
113
122
  <Harness flightId={7} />
114
123
  </KerbcastProvider>,
115
124
  );
116
- fireResize(48, 48); // prompt first -> 48x48
125
+ fireResize(48, 48); // prompt first -> 48x28
117
126
  fireResize(200, 200); // debounced, not yet applied
118
127
  expect(reports).toHaveLength(1);
119
128
  act(() => vi.advanceTimersByTime(200));
120
- // 200 -> 208; the debounced resize now lands.
121
- expect(reports.at(-1)).toMatchObject({ w: 208, h: 208 });
129
+ // 200 -> 208 wide at 16:9; the debounced resize now lands.
130
+ expect(reports.at(-1)).toMatchObject({ w: 208, h: 118 });
122
131
  });
123
132
 
124
133
  it("does not re-report a sub-bucket change", () => {
@@ -128,8 +137,8 @@ describe("useReportDisplaySize", () => {
128
137
  <Harness flightId={7} />
129
138
  </KerbcastProvider>,
130
139
  );
131
- fireResize(40, 40); // -> 48x48
132
- fireResize(44, 44); // still ceils to 48x48 -> no new report
140
+ fireResize(40, 40); // -> 48x28
141
+ fireResize(44, 44); // still ceils to 48x28 -> no new report
133
142
  act(() => vi.advanceTimersByTime(200));
134
143
  expect(reports).toHaveLength(1);
135
144
  });
@@ -181,6 +190,6 @@ describe("useReportDisplaySize", () => {
181
190
  expect(resizeCallback).toBeUndefined();
182
191
  rerender(view(true));
183
192
  fireResize(40, 30);
184
- expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 32 }]);
193
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
185
194
  });
186
195
  });