@ksp-gonogo/kerbcast-react 1.11.0-rc.4 → 1.11.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.
@@ -177,12 +177,12 @@ export function KerbalFaceFeed({
177
177
  // Reporting follows the mount lifecycle; the registry collapses multiple
178
178
  // feeds of the same kerbal to one MAX report.
179
179
  const frameRef = useRef<HTMLDivElement>(null);
180
- useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
180
+ const videoRef = useRef<HTMLVideoElement>(null);
181
+ useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize, videoRef });
181
182
 
182
183
  // Bind the stream to the persistent <video> without remounting it — the
183
184
  // element stays mounted across every re-layout / prop change, only its
184
185
  // srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
185
- const videoRef = useRef<HTMLVideoElement>(null);
186
186
  useEffect(() => {
187
187
  if (videoRef.current && stream) {
188
188
  videoRef.current.srcObject = stream;
@@ -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
+ });
@@ -89,15 +89,18 @@ describe("useReportDisplaySize", () => {
89
89
  <Harness flightId={7} />
90
90
  </KerbcastProvider>,
91
91
  );
92
- // 40x30 -> width ceiled to a 16-multiple (48), height at the box's 4:3 (36), 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: 36 }]);
94
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
95
95
  });
96
96
 
97
- it("keeps the box's aspect so the frame fills it", () => {
98
- expect(displayReportSize(441, 246)).toEqual({ width: 448, height: 250 });
99
- expect(displayReportSize(372, 208)).toEqual({ width: 384, height: 214 });
97
+ it("asks for the 16:9 picture that fits the box, never the box's shape", () => {
100
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 });
101
104
  });
102
105
 
103
106
  it("reports a square (min edge) when square is set", () => {
@@ -119,12 +122,12 @@ describe("useReportDisplaySize", () => {
119
122
  <Harness flightId={7} />
120
123
  </KerbcastProvider>,
121
124
  );
122
- fireResize(48, 48); // prompt first -> 48x48
125
+ fireResize(48, 48); // prompt first -> 48x28
123
126
  fireResize(200, 200); // debounced, not yet applied
124
127
  expect(reports).toHaveLength(1);
125
128
  act(() => vi.advanceTimersByTime(200));
126
- // 200 -> 208; the debounced resize now lands.
127
- 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 });
128
131
  });
129
132
 
130
133
  it("does not re-report a sub-bucket change", () => {
@@ -134,8 +137,8 @@ describe("useReportDisplaySize", () => {
134
137
  <Harness flightId={7} />
135
138
  </KerbcastProvider>,
136
139
  );
137
- fireResize(40, 40); // -> 48x48
138
- 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
139
142
  act(() => vi.advanceTimersByTime(200));
140
143
  expect(reports).toHaveLength(1);
141
144
  });
@@ -187,6 +190,6 @@ describe("useReportDisplaySize", () => {
187
190
  expect(resizeCallback).toBeUndefined();
188
191
  rerender(view(true));
189
192
  fireResize(40, 30);
190
- expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 36 }]);
193
+ expect(reports).toEqual([{ flightId: 7, id: expect.any(String), w: 48, h: 28 }]);
191
194
  });
192
195
  });