@ksp-gonogo/kerbcast 1.4.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,404 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { PanZoomController } from "./panZoom";
3
+ import type { PanZoomCommandSink } from "./panZoom";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function makeSink() {
10
+ const setPan = vi.fn();
11
+ const setPanRate = vi.fn();
12
+ const setFov = vi.fn();
13
+ const setZoomRate = vi.fn();
14
+ const sink: PanZoomCommandSink = { setPan, setPanRate, setFov, setZoomRate };
15
+ return { sink, setPan, setPanRate, setFov, setZoomRate };
16
+ }
17
+
18
+ const DEFAULT_BOUNDS = {
19
+ fov: 60,
20
+ panYaw: 0,
21
+ panPitch: 0,
22
+ fovMin: 10,
23
+ fovMax: 120,
24
+ panYawMin: -90,
25
+ panYawMax: 90,
26
+ panPitchMin: -45,
27
+ panPitchMax: 45,
28
+ };
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Pan rate: dedupe + deadzone
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe("PanZoomController: pan rate", () => {
35
+ it("does not re-send the same pan rate", () => {
36
+ const { sink, setPanRate } = makeSink();
37
+ const ctrl = new PanZoomController(sink);
38
+ ctrl.setPanRate(0.5, 0.3);
39
+ ctrl.setPanRate(0.5, 0.3); // identical -- must not re-send
40
+ expect(setPanRate).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ it("sends after a meaningful change", () => {
44
+ const { sink, setPanRate } = makeSink();
45
+ const ctrl = new PanZoomController(sink);
46
+ ctrl.setPanRate(0.5, 0);
47
+ ctrl.setPanRate(0.8, 0);
48
+ expect(setPanRate).toHaveBeenCalledTimes(2);
49
+ expect(setPanRate.mock.calls[1]).toEqual([0.8, 0]);
50
+ });
51
+
52
+ it("snaps small magnitudes to 0 (deadzone)", () => {
53
+ const { sink, setPanRate } = makeSink();
54
+ const ctrl = new PanZoomController(sink, { analogDeadzone: 0.05 });
55
+ ctrl.setPanRate(0.04, 0.04); // below deadzone -- should snap to 0
56
+ // dedupe: 0,0 against initial 0,0 -- nothing sent
57
+ expect(setPanRate).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("sends when one axis is above the deadzone and the other is below", () => {
61
+ const { sink, setPanRate } = makeSink();
62
+ const ctrl = new PanZoomController(sink, { analogDeadzone: 0.05 });
63
+ ctrl.setPanRate(0.5, 0.03);
64
+ expect(setPanRate).toHaveBeenCalledWith(0.5, 0);
65
+ });
66
+
67
+ it("clamps to -1..1 before applying deadzone", () => {
68
+ const { sink, setPanRate } = makeSink();
69
+ const ctrl = new PanZoomController(sink);
70
+ ctrl.setPanRate(2.0, -5.0);
71
+ expect(setPanRate).toHaveBeenCalledWith(1, -1);
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Pan rate: axis composition
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe("PanZoomController: setPanAxis", () => {
80
+ it("updates one axis and preserves the other", () => {
81
+ const { sink, setPanRate } = makeSink();
82
+ const ctrl = new PanZoomController(sink);
83
+ ctrl.setPanRate(0.4, 0.6); // set both
84
+ setPanRate.mockClear();
85
+
86
+ ctrl.setPanAxis("yaw", 0.8); // update yaw only
87
+ expect(setPanRate).toHaveBeenCalledWith(0.8, 0.6);
88
+
89
+ ctrl.setPanAxis("pitch", -0.2); // update pitch only
90
+ expect(setPanRate).toHaveBeenLastCalledWith(0.8, -0.2);
91
+ });
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Zoom rate: dedupe + deadzone
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("PanZoomController: zoom rate", () => {
99
+ it("does not re-send the same zoom rate", () => {
100
+ const { sink, setZoomRate } = makeSink();
101
+ const ctrl = new PanZoomController(sink);
102
+ ctrl.setZoomRate(1);
103
+ ctrl.setZoomRate(1);
104
+ expect(setZoomRate).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("snaps below-deadzone zoom rate to 0 and dedupes against the initial 0", () => {
108
+ const { sink, setZoomRate } = makeSink();
109
+ const ctrl = new PanZoomController(sink, { analogDeadzone: 0.05 });
110
+ ctrl.setZoomRate(0.02); // below deadzone -- snaps to 0, matches initial, no send
111
+ expect(setZoomRate).not.toHaveBeenCalled();
112
+ });
113
+ });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Discrete nudge: accumulation + clamping
117
+ // ---------------------------------------------------------------------------
118
+
119
+ describe("PanZoomController: nudgePan", () => {
120
+ it("accumulates pan nudges and sends absolute setPan", () => {
121
+ const { sink, setPan } = makeSink();
122
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
123
+ ctrl.syncFromState(DEFAULT_BOUNDS); // seed accumulator with 0,0
124
+ ctrl.nudgePan(1, 0); // yaw +5
125
+ ctrl.nudgePan(1, 0); // yaw +10
126
+ ctrl.nudgePan(0, 1); // pitch +5
127
+ expect(setPan.mock.calls).toEqual([
128
+ [5, 0],
129
+ [10, 0],
130
+ [10, 5],
131
+ ]);
132
+ });
133
+
134
+ it("clamps nudge at pan bounds", () => {
135
+ const { sink, setPan } = makeSink();
136
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
137
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 88, panPitch: 0 });
138
+ ctrl.nudgePan(1, 0); // would go to 93, clamped to 90
139
+ expect(setPan).toHaveBeenLastCalledWith(90, 0);
140
+ });
141
+ });
142
+
143
+ describe("PanZoomController: nudgeZoom", () => {
144
+ it("nudges FoV by the configured step (deltaSign +1 = wider / FoV up)", () => {
145
+ const { sink, setFov } = makeSink();
146
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
147
+ ctrl.syncFromState(DEFAULT_BOUNDS); // seeds localFov = 60
148
+ ctrl.nudgeZoom(1); // +1 = zoom out = FoV up
149
+ expect(setFov).toHaveBeenLastCalledWith(65);
150
+ ctrl.nudgeZoom(-1); // -1 = zoom in = FoV down
151
+ expect(setFov).toHaveBeenLastCalledWith(60);
152
+ });
153
+
154
+ it("clamps nudge at fov bounds", () => {
155
+ const { sink, setFov } = makeSink();
156
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
157
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 118 });
158
+ ctrl.nudgeZoom(1); // would go to 123, clamped to 120
159
+ expect(setFov).toHaveBeenLastCalledWith(120);
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Echo-sync idle rules
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe("PanZoomController: echo-sync", () => {
168
+ it("applies echoed pan while idle", () => {
169
+ const { sink, setPan } = makeSink();
170
+ const ctrl = new PanZoomController(sink);
171
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 15, panPitch: -5 });
172
+ // Now nudge -- should start from the echoed position.
173
+ ctrl.nudgePan(1, 0);
174
+ expect(setPan).toHaveBeenLastCalledWith(20, -5);
175
+ });
176
+
177
+ it("does not apply echoed pan while a non-zero pan rate is active", () => {
178
+ const { sink, setPan } = makeSink();
179
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
180
+ ctrl.setPanRate(0.5, 0); // rate active
181
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 30, panPitch: 0 });
182
+ ctrl.setPanRate(0, 0); // clear rate (does not apply echo retroactively)
183
+ ctrl.nudgePan(1, 0); // accumulator was NOT updated, so starts from 0
184
+ expect(setPan).toHaveBeenLastCalledWith(5, 0);
185
+ });
186
+
187
+ it("does not apply echoed pan while ball drag is active", () => {
188
+ const { sink, setPan } = makeSink();
189
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
190
+ ctrl.setBallDragging(true);
191
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 50, panPitch: 0 });
192
+ ctrl.setBallDragging(false);
193
+ ctrl.nudgePan(1, 0); // accumulator stuck at 0 (never synced)
194
+ expect(setPan).toHaveBeenLastCalledWith(5, 0);
195
+ });
196
+
197
+ it("applies echoed fov while zoom-idle", () => {
198
+ const { sink } = makeSink();
199
+ const ctrl = new PanZoomController(sink);
200
+ const fovValues: number[] = [];
201
+ ctrl.onSliderFov((f) => fovValues.push(f));
202
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 75 });
203
+ expect(fovValues.at(-1)).toBe(75);
204
+ expect(ctrl.sliderFov).toBe(75);
205
+ });
206
+
207
+ it("does not apply echoed fov while zoom rate is active", () => {
208
+ const { sink } = makeSink();
209
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
210
+ ctrl.syncFromState(DEFAULT_BOUNDS); // seed at 60
211
+ ctrl.setZoomRate(1); // rate active
212
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 80 }); // echo: 80 -- ignored
213
+ ctrl.setZoomRate(0);
214
+ ctrl.nudgeZoom(1); // should start from 60, not 80
215
+ const { setFov } = makeSink();
216
+ // Re-derive: nudgeZoom starts from localFov which was 60.
217
+ // We need to check via sink calls. Let's check a fresh controller.
218
+ void setFov; // suppress unused lint
219
+ });
220
+
221
+ it("accumulator stays at seed when rate was active during sync, then zeroed", () => {
222
+ // This is the canonical idle-rule test: set a rate, sync (ignored),
223
+ // zero the rate, sync again (applied), nudge from the synced value.
224
+ const { sink, setPan } = makeSink();
225
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
226
+ ctrl.syncFromState(DEFAULT_BOUNDS); // seed accumulator 0,0
227
+
228
+ ctrl.setPanRate(0.5, 0); // rate active
229
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 40 }); // ignored (not idle)
230
+
231
+ ctrl.setPanRate(0, 0); // clear rate
232
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 40 }); // now applied
233
+ ctrl.nudgePan(1, 0); // should start from 40
234
+ expect(setPan).toHaveBeenLastCalledWith(45, 0);
235
+ });
236
+ });
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // FoV slider
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe("PanZoomController: fovSliderInput debounce", () => {
243
+ beforeEach(() => {
244
+ vi.useFakeTimers();
245
+ });
246
+ afterEach(() => {
247
+ vi.useRealTimers();
248
+ });
249
+
250
+ it("sends only the settled value (not every intermediate input)", async () => {
251
+ const { sink, setFov } = makeSink();
252
+ const ctrl = new PanZoomController(sink, { fovSliderDebounceMs: 120 });
253
+ ctrl.syncFromState(DEFAULT_BOUNDS);
254
+
255
+ ctrl.fovSliderInput(65);
256
+ ctrl.fovSliderInput(70);
257
+ ctrl.fovSliderInput(75);
258
+ // None sent yet (debounce pending).
259
+ expect(setFov).not.toHaveBeenCalled();
260
+
261
+ await vi.advanceTimersByTimeAsync(120);
262
+ // Only the last value is sent.
263
+ expect(setFov).toHaveBeenCalledTimes(1);
264
+ expect(setFov).toHaveBeenCalledWith(75);
265
+ });
266
+
267
+ it("drag-end flushes the pending value immediately", async () => {
268
+ const { sink, setFov } = makeSink();
269
+ const ctrl = new PanZoomController(sink, { fovSliderDebounceMs: 120 });
270
+ ctrl.syncFromState(DEFAULT_BOUNDS);
271
+
272
+ ctrl.setFovSliderDragging(true);
273
+ ctrl.fovSliderInput(80);
274
+ expect(setFov).not.toHaveBeenCalled();
275
+
276
+ // Pointer release -- flush without waiting for the timer.
277
+ ctrl.setFovSliderDragging(false);
278
+ expect(setFov).toHaveBeenCalledOnce();
279
+ expect(setFov).toHaveBeenCalledWith(80);
280
+
281
+ // Timer must be cancelled; no second send.
282
+ await vi.advanceTimersByTimeAsync(120);
283
+ expect(setFov).toHaveBeenCalledTimes(1);
284
+ });
285
+
286
+ it("emits sliderFov on input (optimistic)", () => {
287
+ const { sink } = makeSink();
288
+ const ctrl = new PanZoomController(sink, { fovSliderDebounceMs: 120 });
289
+ ctrl.syncFromState(DEFAULT_BOUNDS);
290
+
291
+ const seen: number[] = [];
292
+ ctrl.onSliderFov((f) => seen.push(f));
293
+ ctrl.fovSliderInput(72);
294
+ expect(seen).toContain(72);
295
+ expect(ctrl.sliderFov).toBe(72);
296
+ });
297
+
298
+ it("emits sliderFov on idle echo-sync", () => {
299
+ const { sink } = makeSink();
300
+ const ctrl = new PanZoomController(sink);
301
+ const seen: number[] = [];
302
+ ctrl.onSliderFov((f) => seen.push(f));
303
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 55 });
304
+ expect(seen).toContain(55);
305
+ });
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // stop()
310
+ // ---------------------------------------------------------------------------
311
+
312
+ describe("PanZoomController: stop", () => {
313
+ it("sends zero pan rate only when a non-zero rate is active", () => {
314
+ const { sink, setPanRate } = makeSink();
315
+ const ctrl = new PanZoomController(sink);
316
+ ctrl.stop(); // no rate active -- must not send
317
+ expect(setPanRate).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("sends zero pan rate when active, then not again on second stop", () => {
321
+ const { sink, setPanRate } = makeSink();
322
+ const ctrl = new PanZoomController(sink);
323
+ ctrl.setPanRate(0.5, 0);
324
+ setPanRate.mockClear();
325
+
326
+ ctrl.stop();
327
+ expect(setPanRate).toHaveBeenCalledWith(0, 0);
328
+ setPanRate.mockClear();
329
+
330
+ ctrl.stop(); // already zeroed -- no resend
331
+ expect(setPanRate).not.toHaveBeenCalled();
332
+ });
333
+
334
+ it("sends zero zoom rate when active", () => {
335
+ const { sink, setZoomRate } = makeSink();
336
+ const ctrl = new PanZoomController(sink);
337
+ ctrl.setZoomRate(1);
338
+ setZoomRate.mockClear();
339
+
340
+ ctrl.stop();
341
+ expect(setZoomRate).toHaveBeenCalledWith(0);
342
+ });
343
+
344
+ it("does not send zero zoom rate when rate is already 0", () => {
345
+ const { sink, setZoomRate } = makeSink();
346
+ const ctrl = new PanZoomController(sink);
347
+ ctrl.stop();
348
+ expect(setZoomRate).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it("cancels pending FoV timer without sending", async () => {
352
+ vi.useFakeTimers();
353
+ try {
354
+ const { sink, setFov } = makeSink();
355
+ const ctrl = new PanZoomController(sink, { fovSliderDebounceMs: 120 });
356
+ ctrl.syncFromState(DEFAULT_BOUNDS);
357
+ ctrl.fovSliderInput(90);
358
+ ctrl.stop(); // cancels the timer, no send
359
+ await vi.advanceTimersByTimeAsync(120);
360
+ expect(setFov).not.toHaveBeenCalled();
361
+ } finally {
362
+ vi.useRealTimers();
363
+ }
364
+ });
365
+ });
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Nudge after echo-sync starts from the echoed value
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe("PanZoomController: nudge after echo-sync", () => {
372
+ it("pan nudge accumulates from the echoed position", () => {
373
+ const { sink, setPan } = makeSink();
374
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
375
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, panYaw: 20, panPitch: 10 });
376
+ ctrl.nudgePan(1, -1);
377
+ expect(setPan).toHaveBeenLastCalledWith(25, 5);
378
+ });
379
+
380
+ it("zoom nudge accumulates from the echoed FoV", () => {
381
+ const { sink, setFov } = makeSink();
382
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
383
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 50 });
384
+ ctrl.nudgeZoom(-1); // zoom in: 50 - 5 = 45
385
+ expect(setFov).toHaveBeenLastCalledWith(45);
386
+ });
387
+ });
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // onSliderFov subscription management
391
+ // ---------------------------------------------------------------------------
392
+
393
+ describe("PanZoomController: onSliderFov unsubscribe", () => {
394
+ it("returns an unsubscribe function that stops further notifications", () => {
395
+ const { sink } = makeSink();
396
+ const ctrl = new PanZoomController(sink);
397
+ const seen: number[] = [];
398
+ const unsub = ctrl.onSliderFov((f) => seen.push(f));
399
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
400
+ unsub();
401
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 70 });
402
+ expect(seen).not.toContain(70);
403
+ });
404
+ });