@flighthq/application 0.1.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.
- package/dist/application.d.ts +24 -0
- package/dist/application.d.ts.map +1 -0
- package/dist/application.js +371 -0
- package/dist/application.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/window.d.ts +63 -0
- package/dist/window.d.ts.map +1 -0
- package/dist/window.js +661 -0
- package/dist/window.js.map +1 -0
- package/package.json +38 -0
- package/src/application.test.ts +743 -0
- package/src/window.test.ts +1185 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { connectSignal, emitSignal } from '@flighthq/signals';
|
|
2
|
+
import type { LoopBackend } from '@flighthq/types';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
attachApplicationExit,
|
|
6
|
+
attachApplicationLifecycle,
|
|
7
|
+
createApplication,
|
|
8
|
+
createWebLoopBackend,
|
|
9
|
+
detachApplicationExit,
|
|
10
|
+
disposeApplication,
|
|
11
|
+
enableApplicationLifecycleSignals,
|
|
12
|
+
forEachApplicationWindow,
|
|
13
|
+
getApplicationFrameRate,
|
|
14
|
+
getApplicationMainWindow,
|
|
15
|
+
getApplicationWindows,
|
|
16
|
+
getLoopBackend,
|
|
17
|
+
isApplicationRunning,
|
|
18
|
+
pauseApplicationLoop,
|
|
19
|
+
registerApplicationWindow,
|
|
20
|
+
resumeApplicationLoop,
|
|
21
|
+
setApplicationMainWindow,
|
|
22
|
+
setLoopBackend,
|
|
23
|
+
startApplicationLoop,
|
|
24
|
+
stepApplicationLoop,
|
|
25
|
+
stopApplicationLoop,
|
|
26
|
+
unregisterApplicationWindow,
|
|
27
|
+
} from './application';
|
|
28
|
+
import { createApplicationWindow } from './window';
|
|
29
|
+
|
|
30
|
+
function makeManualLoopBackend(): LoopBackend & { tick: (time: number) => void; cancelCount: number } {
|
|
31
|
+
let callback: ((time: number) => void) | null = null;
|
|
32
|
+
let cancelCount = 0;
|
|
33
|
+
return {
|
|
34
|
+
cancelCount,
|
|
35
|
+
requestFrame(cb: (time: number) => void): unknown {
|
|
36
|
+
callback = cb;
|
|
37
|
+
return 1;
|
|
38
|
+
},
|
|
39
|
+
cancelFrame(): void {
|
|
40
|
+
cancelCount++;
|
|
41
|
+
this.cancelCount = cancelCount;
|
|
42
|
+
callback = null;
|
|
43
|
+
},
|
|
44
|
+
now(): number {
|
|
45
|
+
return performance.now();
|
|
46
|
+
},
|
|
47
|
+
tick(time: number): void {
|
|
48
|
+
callback?.(time);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
afterEach(() => setLoopBackend(null));
|
|
54
|
+
|
|
55
|
+
describe('attachApplicationExit', () => {
|
|
56
|
+
it('emits onExit on beforeunload', () => {
|
|
57
|
+
const app = createApplication();
|
|
58
|
+
let called = false;
|
|
59
|
+
connectSignal(app.onExit, () => {
|
|
60
|
+
called = true;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
attachApplicationExit(app);
|
|
64
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
65
|
+
|
|
66
|
+
expect(called).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replaces a previous exit listener when called again', () => {
|
|
70
|
+
const app = createApplication();
|
|
71
|
+
let count = 0;
|
|
72
|
+
connectSignal(app.onExit, () => count++);
|
|
73
|
+
|
|
74
|
+
attachApplicationExit(app);
|
|
75
|
+
attachApplicationExit(app);
|
|
76
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
77
|
+
|
|
78
|
+
expect(count).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('attachApplicationLifecycle', () => {
|
|
83
|
+
it('pauses the loop when the window deactivates', () => {
|
|
84
|
+
const backend = makeManualLoopBackend();
|
|
85
|
+
setLoopBackend(backend);
|
|
86
|
+
const app = createApplication();
|
|
87
|
+
const win = createApplicationWindow();
|
|
88
|
+
startApplicationLoop(app);
|
|
89
|
+
attachApplicationLifecycle(app, win);
|
|
90
|
+
|
|
91
|
+
// Simulate window deactivation by emitting its signal directly.
|
|
92
|
+
emitSignal(win.onDeactivate);
|
|
93
|
+
expect(app.isRunning).toBe(false);
|
|
94
|
+
stopApplicationLoop(app);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('resumes the loop when the window activates', () => {
|
|
98
|
+
const backend = makeManualLoopBackend();
|
|
99
|
+
setLoopBackend(backend);
|
|
100
|
+
const app = createApplication();
|
|
101
|
+
const win = createApplicationWindow();
|
|
102
|
+
startApplicationLoop(app);
|
|
103
|
+
attachApplicationLifecycle(app, win);
|
|
104
|
+
|
|
105
|
+
emitSignal(win.onDeactivate);
|
|
106
|
+
expect(app.isRunning).toBe(false);
|
|
107
|
+
emitSignal(win.onActivate);
|
|
108
|
+
expect(app.isRunning).toBe(true);
|
|
109
|
+
stopApplicationLoop(app);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('createApplication', () => {
|
|
114
|
+
it('returns signals with no side effects', () => {
|
|
115
|
+
const app = createApplication();
|
|
116
|
+
expect(app.onUpdate).toBeDefined();
|
|
117
|
+
expect(app.onRender).toBeDefined();
|
|
118
|
+
expect(app.onExit).toBeDefined();
|
|
119
|
+
|
|
120
|
+
let exitCalled = false;
|
|
121
|
+
connectSignal(app.onExit, () => {
|
|
122
|
+
exitCalled = true;
|
|
123
|
+
});
|
|
124
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
125
|
+
expect(exitCalled).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('initializes frame metrics to zero', () => {
|
|
129
|
+
const app = createApplication();
|
|
130
|
+
expect(app.elapsedTime).toBe(0);
|
|
131
|
+
expect(app.frameCount).toBe(0);
|
|
132
|
+
expect(app.deltaTime).toBe(0);
|
|
133
|
+
expect(app.isRunning).toBe(false);
|
|
134
|
+
expect(app.interpolationAlpha).toBe(1);
|
|
135
|
+
expect(app.windows).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('initializes lifecycle signals to null', () => {
|
|
139
|
+
const app = createApplication();
|
|
140
|
+
expect(app.onActivate).toBeNull();
|
|
141
|
+
expect(app.onDeactivate).toBeNull();
|
|
142
|
+
expect(app.onError).toBeNull();
|
|
143
|
+
expect(app.onFixedUpdate).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('createWebLoopBackend', () => {
|
|
148
|
+
it('wraps requestAnimationFrame', () => {
|
|
149
|
+
const raf = vi.fn().mockReturnValue(42);
|
|
150
|
+
vi.stubGlobal('requestAnimationFrame', raf);
|
|
151
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
152
|
+
const backend = createWebLoopBackend();
|
|
153
|
+
const handle = backend.requestFrame(() => {});
|
|
154
|
+
expect(raf).toHaveBeenCalled();
|
|
155
|
+
expect(handle).toBe(42);
|
|
156
|
+
vi.unstubAllGlobals();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('wraps cancelAnimationFrame', () => {
|
|
160
|
+
const caf = vi.fn();
|
|
161
|
+
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1));
|
|
162
|
+
vi.stubGlobal('cancelAnimationFrame', caf);
|
|
163
|
+
const backend = createWebLoopBackend();
|
|
164
|
+
const handle = backend.requestFrame(() => {});
|
|
165
|
+
backend.cancelFrame(handle);
|
|
166
|
+
expect(caf).toHaveBeenCalledWith(1);
|
|
167
|
+
vi.unstubAllGlobals();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('detachApplicationExit', () => {
|
|
172
|
+
it('removes the listener', () => {
|
|
173
|
+
const app = createApplication();
|
|
174
|
+
let called = false;
|
|
175
|
+
connectSignal(app.onExit, () => {
|
|
176
|
+
called = true;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
attachApplicationExit(app);
|
|
180
|
+
detachApplicationExit(app);
|
|
181
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
182
|
+
|
|
183
|
+
expect(called).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('disposeApplication', () => {
|
|
188
|
+
it('stops loop, removes exit listener, and sets isRunning false', () => {
|
|
189
|
+
const backend = makeManualLoopBackend();
|
|
190
|
+
setLoopBackend(backend);
|
|
191
|
+
|
|
192
|
+
const app = createApplication();
|
|
193
|
+
let exitCalled = false;
|
|
194
|
+
connectSignal(app.onExit, () => {
|
|
195
|
+
exitCalled = true;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
startApplicationLoop(app);
|
|
199
|
+
attachApplicationExit(app);
|
|
200
|
+
expect(app.isRunning).toBe(true);
|
|
201
|
+
disposeApplication(app);
|
|
202
|
+
|
|
203
|
+
expect(app.isRunning).toBe(false);
|
|
204
|
+
window.dispatchEvent(new Event('beforeunload'));
|
|
205
|
+
expect(exitCalled).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('enableApplicationLifecycleSignals', () => {
|
|
210
|
+
it('allocates the opt-in signals on the application', () => {
|
|
211
|
+
const app = createApplication();
|
|
212
|
+
expect(app.onActivate).toBeNull();
|
|
213
|
+
enableApplicationLifecycleSignals(app);
|
|
214
|
+
expect(app.onActivate).not.toBeNull();
|
|
215
|
+
expect(app.onDeactivate).not.toBeNull();
|
|
216
|
+
expect(app.onError).not.toBeNull();
|
|
217
|
+
expect(app.onFixedUpdate).not.toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('is idempotent', () => {
|
|
221
|
+
const app = createApplication();
|
|
222
|
+
enableApplicationLifecycleSignals(app);
|
|
223
|
+
const signal = app.onActivate;
|
|
224
|
+
enableApplicationLifecycleSignals(app);
|
|
225
|
+
expect(app.onActivate).toBe(signal); // same object reference
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('forEachApplicationWindow', () => {
|
|
230
|
+
it('iterates all registered windows', () => {
|
|
231
|
+
const app = createApplication();
|
|
232
|
+
const win1 = createApplicationWindow();
|
|
233
|
+
const win2 = createApplicationWindow();
|
|
234
|
+
registerApplicationWindow(app, win1);
|
|
235
|
+
registerApplicationWindow(app, win2);
|
|
236
|
+
const seen: unknown[] = [];
|
|
237
|
+
forEachApplicationWindow(app, (w) => seen.push(w));
|
|
238
|
+
expect(seen).toEqual([win1, win2]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('getApplicationFrameRate', () => {
|
|
243
|
+
it('returns 0 before any ticks', () => {
|
|
244
|
+
const app = createApplication();
|
|
245
|
+
expect(getApplicationFrameRate(app)).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns approximate FPS after several ticks', () => {
|
|
249
|
+
const backend = makeManualLoopBackend();
|
|
250
|
+
setLoopBackend(backend);
|
|
251
|
+
const app = createApplication();
|
|
252
|
+
startApplicationLoop(app);
|
|
253
|
+
// Simulate 60fps ticks (~16.67ms each).
|
|
254
|
+
for (let i = 0; i <= 60; i++) {
|
|
255
|
+
backend.tick(i * 16.67);
|
|
256
|
+
}
|
|
257
|
+
const fps = getApplicationFrameRate(app);
|
|
258
|
+
expect(fps).toBeGreaterThan(55);
|
|
259
|
+
expect(fps).toBeLessThan(65);
|
|
260
|
+
stopApplicationLoop(app);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('getApplicationMainWindow', () => {
|
|
265
|
+
it('returns null with no windows', () => {
|
|
266
|
+
const app = createApplication();
|
|
267
|
+
expect(getApplicationMainWindow(app)).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('returns the first registered window by default', () => {
|
|
271
|
+
const app = createApplication();
|
|
272
|
+
const win1 = createApplicationWindow();
|
|
273
|
+
const win2 = createApplicationWindow();
|
|
274
|
+
registerApplicationWindow(app, win1);
|
|
275
|
+
registerApplicationWindow(app, win2);
|
|
276
|
+
expect(getApplicationMainWindow(app)).toBe(win1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('returns the explicitly set main window', () => {
|
|
280
|
+
const app = createApplication();
|
|
281
|
+
const win1 = createApplicationWindow();
|
|
282
|
+
const win2 = createApplicationWindow();
|
|
283
|
+
registerApplicationWindow(app, win1);
|
|
284
|
+
setApplicationMainWindow(app, win2);
|
|
285
|
+
expect(getApplicationMainWindow(app)).toBe(win2);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('getApplicationWindows', () => {
|
|
290
|
+
it('returns an empty array initially', () => {
|
|
291
|
+
const app = createApplication();
|
|
292
|
+
expect(getApplicationWindows(app)).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('returns a snapshot of registered windows', () => {
|
|
296
|
+
const app = createApplication();
|
|
297
|
+
const win = createApplicationWindow();
|
|
298
|
+
registerApplicationWindow(app, win);
|
|
299
|
+
const snapshot = getApplicationWindows(app);
|
|
300
|
+
expect(snapshot).toEqual([win]);
|
|
301
|
+
// Snapshot is a copy — mutating it does not affect the registry.
|
|
302
|
+
(snapshot as unknown[]).push(null);
|
|
303
|
+
expect(app.windows.length).toBe(1);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('getLoopBackend', () => {
|
|
308
|
+
it('falls back to a web backend', () => {
|
|
309
|
+
vi.stubGlobal('requestAnimationFrame', vi.fn());
|
|
310
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
311
|
+
const backend = getLoopBackend();
|
|
312
|
+
expect(backend).not.toBeNull();
|
|
313
|
+
vi.unstubAllGlobals();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('returns the registered backend', () => {
|
|
317
|
+
const backend = makeManualLoopBackend();
|
|
318
|
+
setLoopBackend(backend);
|
|
319
|
+
expect(getLoopBackend()).toBe(backend);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('isApplicationRunning', () => {
|
|
324
|
+
it('returns false before loop is started', () => {
|
|
325
|
+
const app = createApplication();
|
|
326
|
+
expect(isApplicationRunning(app)).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns true after loop is started', () => {
|
|
330
|
+
const backend = makeManualLoopBackend();
|
|
331
|
+
setLoopBackend(backend);
|
|
332
|
+
const app = createApplication();
|
|
333
|
+
startApplicationLoop(app);
|
|
334
|
+
expect(isApplicationRunning(app)).toBe(true);
|
|
335
|
+
stopApplicationLoop(app);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns false after loop is stopped', () => {
|
|
339
|
+
const backend = makeManualLoopBackend();
|
|
340
|
+
setLoopBackend(backend);
|
|
341
|
+
const app = createApplication();
|
|
342
|
+
startApplicationLoop(app);
|
|
343
|
+
stopApplicationLoop(app);
|
|
344
|
+
expect(isApplicationRunning(app)).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('pauseApplicationLoop', () => {
|
|
349
|
+
it('pauses emission without cancelling the rAF chain', () => {
|
|
350
|
+
const backend = makeManualLoopBackend();
|
|
351
|
+
setLoopBackend(backend);
|
|
352
|
+
const app = createApplication();
|
|
353
|
+
const updates: number[] = [];
|
|
354
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
355
|
+
|
|
356
|
+
startApplicationLoop(app);
|
|
357
|
+
backend.tick(0);
|
|
358
|
+
backend.tick(16);
|
|
359
|
+
pauseApplicationLoop(app);
|
|
360
|
+
backend.tick(32); // should not emit since paused
|
|
361
|
+
expect(updates.length).toBe(2);
|
|
362
|
+
expect(app.isRunning).toBe(false);
|
|
363
|
+
stopApplicationLoop(app);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('is idempotent', () => {
|
|
367
|
+
const backend = makeManualLoopBackend();
|
|
368
|
+
setLoopBackend(backend);
|
|
369
|
+
const app = createApplication();
|
|
370
|
+
startApplicationLoop(app);
|
|
371
|
+
pauseApplicationLoop(app);
|
|
372
|
+
pauseApplicationLoop(app); // second call should not throw
|
|
373
|
+
expect(app.isRunning).toBe(false);
|
|
374
|
+
stopApplicationLoop(app);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('registerApplicationWindow', () => {
|
|
379
|
+
it('adds a window to the registry', () => {
|
|
380
|
+
const app = createApplication();
|
|
381
|
+
const win = createApplicationWindow();
|
|
382
|
+
registerApplicationWindow(app, win);
|
|
383
|
+
expect(app.windows).toContain(win);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('is idempotent', () => {
|
|
387
|
+
const app = createApplication();
|
|
388
|
+
const win = createApplicationWindow();
|
|
389
|
+
registerApplicationWindow(app, win);
|
|
390
|
+
registerApplicationWindow(app, win);
|
|
391
|
+
expect(app.windows.length).toBe(1);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('resumeApplicationLoop', () => {
|
|
396
|
+
it('resumes emission after pause without dumping the gap delta', () => {
|
|
397
|
+
const backend = makeManualLoopBackend();
|
|
398
|
+
setLoopBackend(backend);
|
|
399
|
+
const app = createApplication();
|
|
400
|
+
const updates: number[] = [];
|
|
401
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
402
|
+
|
|
403
|
+
startApplicationLoop(app);
|
|
404
|
+
backend.tick(0);
|
|
405
|
+
backend.tick(16);
|
|
406
|
+
pauseApplicationLoop(app);
|
|
407
|
+
// Resume and tick — lastTime was reset so first tick after resume has delta 0.
|
|
408
|
+
resumeApplicationLoop(app);
|
|
409
|
+
backend.tick(5000); // large gap should not flood through; lastTime was reset to -1
|
|
410
|
+
// The tick at 5000 should emit delta=0 (first tick after reset).
|
|
411
|
+
expect(updates[updates.length - 1]).toBe(0);
|
|
412
|
+
expect(app.isRunning).toBe(true);
|
|
413
|
+
stopApplicationLoop(app);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('is a no-op when not paused', () => {
|
|
417
|
+
const backend = makeManualLoopBackend();
|
|
418
|
+
setLoopBackend(backend);
|
|
419
|
+
const app = createApplication();
|
|
420
|
+
startApplicationLoop(app);
|
|
421
|
+
resumeApplicationLoop(app); // not paused, should not throw
|
|
422
|
+
expect(app.isRunning).toBe(true);
|
|
423
|
+
stopApplicationLoop(app);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('setApplicationMainWindow', () => {
|
|
428
|
+
it('registers the window if not already registered', () => {
|
|
429
|
+
const app = createApplication();
|
|
430
|
+
const win = createApplicationWindow();
|
|
431
|
+
setApplicationMainWindow(app, win);
|
|
432
|
+
expect(app.windows).toContain(win);
|
|
433
|
+
expect(getApplicationMainWindow(app)).toBe(win);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe('setLoopBackend', () => {
|
|
438
|
+
it('clears back to the web fallback when passed null', () => {
|
|
439
|
+
const backend = makeManualLoopBackend();
|
|
440
|
+
setLoopBackend(backend);
|
|
441
|
+
setLoopBackend(null);
|
|
442
|
+
vi.stubGlobal('requestAnimationFrame', vi.fn());
|
|
443
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
444
|
+
expect(getLoopBackend()).not.toBeNull();
|
|
445
|
+
vi.unstubAllGlobals();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('startApplicationLoop', () => {
|
|
450
|
+
it('sets isRunning to true', () => {
|
|
451
|
+
const backend = makeManualLoopBackend();
|
|
452
|
+
setLoopBackend(backend);
|
|
453
|
+
const app = createApplication();
|
|
454
|
+
startApplicationLoop(app);
|
|
455
|
+
expect(app.isRunning).toBe(true);
|
|
456
|
+
stopApplicationLoop(app);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('replaces a previous loop when called again', () => {
|
|
460
|
+
const backend = makeManualLoopBackend();
|
|
461
|
+
setLoopBackend(backend);
|
|
462
|
+
const app = createApplication();
|
|
463
|
+
startApplicationLoop(app);
|
|
464
|
+
const initialCancelCount = backend.cancelCount;
|
|
465
|
+
startApplicationLoop(app);
|
|
466
|
+
expect(backend.cancelCount).toBeGreaterThan(initialCancelCount);
|
|
467
|
+
stopApplicationLoop(app);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('emits onUpdate with clamped delta and onRender on each tick', () => {
|
|
471
|
+
const backend = makeManualLoopBackend();
|
|
472
|
+
setLoopBackend(backend);
|
|
473
|
+
const app = createApplication();
|
|
474
|
+
const updates: number[] = [];
|
|
475
|
+
let renders = 0;
|
|
476
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
477
|
+
connectSignal(app.onRender, () => renders++);
|
|
478
|
+
|
|
479
|
+
startApplicationLoop(app);
|
|
480
|
+
backend.tick(0);
|
|
481
|
+
backend.tick(100);
|
|
482
|
+
|
|
483
|
+
expect(updates).toEqual([0, 100]);
|
|
484
|
+
expect(renders).toBe(2);
|
|
485
|
+
stopApplicationLoop(app);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('clamps large delta gaps to maxDeltaTime', () => {
|
|
489
|
+
const backend = makeManualLoopBackend();
|
|
490
|
+
setLoopBackend(backend);
|
|
491
|
+
const app = createApplication();
|
|
492
|
+
const updates: number[] = [];
|
|
493
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
494
|
+
|
|
495
|
+
startApplicationLoop(app, { maxDeltaTime: 100 });
|
|
496
|
+
backend.tick(0);
|
|
497
|
+
backend.tick(5000); // 5 second gap clamped to 100ms
|
|
498
|
+
|
|
499
|
+
expect(updates[1]).toBe(100);
|
|
500
|
+
stopApplicationLoop(app);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('defaults to 250ms max delta clamp', () => {
|
|
504
|
+
const backend = makeManualLoopBackend();
|
|
505
|
+
setLoopBackend(backend);
|
|
506
|
+
const app = createApplication();
|
|
507
|
+
const updates: number[] = [];
|
|
508
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
509
|
+
|
|
510
|
+
startApplicationLoop(app);
|
|
511
|
+
backend.tick(0);
|
|
512
|
+
backend.tick(10000); // 10 second gap clamped to 250ms
|
|
513
|
+
|
|
514
|
+
expect(updates[1]).toBe(250);
|
|
515
|
+
stopApplicationLoop(app);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('accumulates frame count and elapsed time', () => {
|
|
519
|
+
const backend = makeManualLoopBackend();
|
|
520
|
+
setLoopBackend(backend);
|
|
521
|
+
const app = createApplication();
|
|
522
|
+
|
|
523
|
+
startApplicationLoop(app);
|
|
524
|
+
backend.tick(0);
|
|
525
|
+
backend.tick(16);
|
|
526
|
+
backend.tick(32);
|
|
527
|
+
|
|
528
|
+
expect(app.frameCount).toBe(3);
|
|
529
|
+
// elapsedTime is in seconds; 0+16+16 = 32ms = 0.032s (clamped)
|
|
530
|
+
expect(app.elapsedTime).toBeCloseTo(0.032, 3);
|
|
531
|
+
stopApplicationLoop(app);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('throttles to targetFrameRate when set', () => {
|
|
535
|
+
const backend = makeManualLoopBackend();
|
|
536
|
+
setLoopBackend(backend);
|
|
537
|
+
const app = createApplication();
|
|
538
|
+
let renders = 0;
|
|
539
|
+
connectSignal(app.onRender, () => renders++);
|
|
540
|
+
|
|
541
|
+
startApplicationLoop(app, { targetFrameRate: 30 }); // 33.33ms interval
|
|
542
|
+
backend.tick(0); // first tick: lastTime=-1 so delta=0, accumulated=0 → emits
|
|
543
|
+
backend.tick(16); // 16ms < 33.33ms → should skip
|
|
544
|
+
backend.tick(34); // 34ms accumulated ≥ 33.33ms → should emit
|
|
545
|
+
|
|
546
|
+
// The first tick always emits (delta=0); accumulated starts at 0.
|
|
547
|
+
// Second tick adds 16ms (below threshold), third tick accumulated to ~34ms triggers.
|
|
548
|
+
expect(renders).toBeGreaterThanOrEqual(2);
|
|
549
|
+
stopApplicationLoop(app);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe('startApplicationLoop (fixed timestep)', () => {
|
|
554
|
+
it('emits onFixedUpdate when fixedTimeStep is set', () => {
|
|
555
|
+
const backend = makeManualLoopBackend();
|
|
556
|
+
setLoopBackend(backend);
|
|
557
|
+
const app = createApplication();
|
|
558
|
+
enableApplicationLifecycleSignals(app);
|
|
559
|
+
const fixedDeltas: number[] = [];
|
|
560
|
+
connectSignal(app.onFixedUpdate!, (dt) => fixedDeltas.push(dt));
|
|
561
|
+
|
|
562
|
+
startApplicationLoop(app, { fixedTimeStep: 16 });
|
|
563
|
+
backend.tick(0);
|
|
564
|
+
backend.tick(48); // 48ms → 3 fixed steps of 16ms
|
|
565
|
+
|
|
566
|
+
expect(fixedDeltas.length).toBeGreaterThanOrEqual(3);
|
|
567
|
+
expect(fixedDeltas[0]).toBe(16);
|
|
568
|
+
stopApplicationLoop(app);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('clamps to maxUpdatesPerFrame to prevent spiral-of-death', () => {
|
|
572
|
+
const backend = makeManualLoopBackend();
|
|
573
|
+
setLoopBackend(backend);
|
|
574
|
+
const app = createApplication();
|
|
575
|
+
enableApplicationLifecycleSignals(app);
|
|
576
|
+
let fixedCount = 0;
|
|
577
|
+
connectSignal(app.onFixedUpdate!, () => fixedCount++);
|
|
578
|
+
|
|
579
|
+
startApplicationLoop(app, { fixedTimeStep: 16, maxUpdatesPerFrame: 3 });
|
|
580
|
+
backend.tick(0);
|
|
581
|
+
backend.tick(10000); // huge gap
|
|
582
|
+
|
|
583
|
+
expect(fixedCount).toBeLessThanOrEqual(3);
|
|
584
|
+
stopApplicationLoop(app);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('sets interpolationAlpha between 0 and 1 during fixed step', () => {
|
|
588
|
+
const backend = makeManualLoopBackend();
|
|
589
|
+
setLoopBackend(backend);
|
|
590
|
+
const app = createApplication();
|
|
591
|
+
enableApplicationLifecycleSignals(app);
|
|
592
|
+
|
|
593
|
+
startApplicationLoop(app, { fixedTimeStep: 16 });
|
|
594
|
+
backend.tick(0);
|
|
595
|
+
backend.tick(24); // 24ms = 1 full step (16ms) + 8ms remainder → alpha = 8/16 = 0.5
|
|
596
|
+
|
|
597
|
+
expect(app.interpolationAlpha).toBeGreaterThanOrEqual(0);
|
|
598
|
+
expect(app.interpolationAlpha).toBeLessThanOrEqual(1);
|
|
599
|
+
stopApplicationLoop(app);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe('startApplicationLoop (tick-error routing)', () => {
|
|
604
|
+
it('routes onUpdate errors to onError when lifecycle signals enabled', () => {
|
|
605
|
+
const backend = makeManualLoopBackend();
|
|
606
|
+
setLoopBackend(backend);
|
|
607
|
+
const app = createApplication();
|
|
608
|
+
enableApplicationLifecycleSignals(app);
|
|
609
|
+
const errors: unknown[] = [];
|
|
610
|
+
connectSignal(app.onError!, (e) => errors.push(e));
|
|
611
|
+
|
|
612
|
+
const boom = new Error('boom');
|
|
613
|
+
connectSignal(app.onUpdate, () => {
|
|
614
|
+
throw boom;
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
startApplicationLoop(app);
|
|
618
|
+
backend.tick(0);
|
|
619
|
+
|
|
620
|
+
expect(errors).toContain(boom);
|
|
621
|
+
// Loop must still be running (rAF chain not killed).
|
|
622
|
+
expect(app.isRunning).toBe(true);
|
|
623
|
+
stopApplicationLoop(app);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe('stepApplicationLoop', () => {
|
|
628
|
+
it('drives one tick with the supplied delta', () => {
|
|
629
|
+
const app = createApplication();
|
|
630
|
+
const updates: number[] = [];
|
|
631
|
+
let renders = 0;
|
|
632
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
633
|
+
connectSignal(app.onRender, () => renders++);
|
|
634
|
+
|
|
635
|
+
stepApplicationLoop(app, 16);
|
|
636
|
+
|
|
637
|
+
expect(updates).toEqual([16]);
|
|
638
|
+
expect(renders).toBe(1);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('clamps the delta to the default maxDeltaTime (250ms)', () => {
|
|
642
|
+
const app = createApplication();
|
|
643
|
+
const updates: number[] = [];
|
|
644
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
645
|
+
|
|
646
|
+
stepApplicationLoop(app, 9999);
|
|
647
|
+
|
|
648
|
+
expect(updates[0]).toBe(250);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('updates frame metrics', () => {
|
|
652
|
+
const app = createApplication();
|
|
653
|
+
stepApplicationLoop(app, 16);
|
|
654
|
+
expect(app.frameCount).toBe(1);
|
|
655
|
+
expect(app.deltaTime).toBe(16);
|
|
656
|
+
expect(app.elapsedTime).toBeCloseTo(0.016, 5);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('uses the default 250ms max clamp when called without a prior loop', () => {
|
|
660
|
+
const app = createApplication();
|
|
661
|
+
const updates: number[] = [];
|
|
662
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
663
|
+
|
|
664
|
+
stepApplicationLoop(app, 9999);
|
|
665
|
+
|
|
666
|
+
expect(updates[0]).toBe(250);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('produces reproducible metrics when stepped to a fixed frame count with a fixed delta', () => {
|
|
670
|
+
// Pins the headless-stepping contract: identical caller-supplied deltas across a fixed frame
|
|
671
|
+
// count must yield identical frameCount / elapsedTime / deltaTime, independent of wall-clock.
|
|
672
|
+
const run = (): Readonly<{
|
|
673
|
+
deltaTime: number;
|
|
674
|
+
elapsedTime: number;
|
|
675
|
+
frameCount: number;
|
|
676
|
+
updates: readonly number[];
|
|
677
|
+
}> => {
|
|
678
|
+
const app = createApplication();
|
|
679
|
+
const updates: number[] = [];
|
|
680
|
+
connectSignal(app.onUpdate, (dt) => updates.push(dt));
|
|
681
|
+
for (let i = 0; i < 10; i++) stepApplicationLoop(app, 16);
|
|
682
|
+
return { deltaTime: app.deltaTime, elapsedTime: app.elapsedTime, frameCount: app.frameCount, updates };
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const first = run();
|
|
686
|
+
const second = run();
|
|
687
|
+
|
|
688
|
+
expect(first.frameCount).toBe(10);
|
|
689
|
+
expect(first.deltaTime).toBe(16);
|
|
690
|
+
expect(first.elapsedTime).toBeCloseTo(0.16, 10);
|
|
691
|
+
expect(first.updates).toEqual(new Array(10).fill(16));
|
|
692
|
+
expect(second).toEqual(first);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
describe('stopApplicationLoop', () => {
|
|
697
|
+
it('sets isRunning to false', () => {
|
|
698
|
+
const backend = makeManualLoopBackend();
|
|
699
|
+
setLoopBackend(backend);
|
|
700
|
+
const app = createApplication();
|
|
701
|
+
startApplicationLoop(app);
|
|
702
|
+
stopApplicationLoop(app);
|
|
703
|
+
expect(app.isRunning).toBe(false);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('stops emitting after stop', () => {
|
|
707
|
+
const backend = makeManualLoopBackend();
|
|
708
|
+
setLoopBackend(backend);
|
|
709
|
+
const app = createApplication();
|
|
710
|
+
let renders = 0;
|
|
711
|
+
connectSignal(app.onRender, () => renders++);
|
|
712
|
+
startApplicationLoop(app);
|
|
713
|
+
backend.tick(0);
|
|
714
|
+
stopApplicationLoop(app);
|
|
715
|
+
// After stop, calling tick should not emit even if the backend fires.
|
|
716
|
+
// (The backend callback reference is cleared, so tick() is a no-op.)
|
|
717
|
+
expect(renders).toBe(1);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe('unregisterApplicationWindow', () => {
|
|
722
|
+
it('removes a registered window', () => {
|
|
723
|
+
const app = createApplication();
|
|
724
|
+
const win = createApplicationWindow();
|
|
725
|
+
registerApplicationWindow(app, win);
|
|
726
|
+
unregisterApplicationWindow(app, win);
|
|
727
|
+
expect(app.windows).not.toContain(win);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('clears the main-window override if removed', () => {
|
|
731
|
+
const app = createApplication();
|
|
732
|
+
const win = createApplicationWindow();
|
|
733
|
+
setApplicationMainWindow(app, win);
|
|
734
|
+
unregisterApplicationWindow(app, win);
|
|
735
|
+
expect(getApplicationMainWindow(app)).toBeNull();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('is a no-op for unregistered windows', () => {
|
|
739
|
+
const app = createApplication();
|
|
740
|
+
const win = createApplicationWindow();
|
|
741
|
+
expect(() => unregisterApplicationWindow(app, win)).not.toThrow();
|
|
742
|
+
});
|
|
743
|
+
});
|