packages/core/src/features/physics/2d-testing.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 /**
2 * Deterministic test support for public 2D physics compositions.
3 *
4 * The harness drives Pibbl's existing realm scheduler. `advanceFrames()` advances
5 * host frames; a physics world may run zero, one, or several fixed ticks in a
6 * host frame according to its public fixed-step configuration.
7 */
8 import { pibbl, type PibblController, type PibblNode } from "@pibbl/core";
9 import {
10 PibblInternalManualSchedulerDriver,
11 pibblInternalInstallTestSchedulerDriver,
12 pibblInternalSnapshotRealmScheduler,
13 } from "@pibbl/core/internal";
14
15 /**
16 * Caller-owned canvas, root factory, and observation callback for deterministic physics testing.
17 *
18 * @see {@link PibblNode}
19 * @see {@link createPhysicsTestHarness2D}
20 */
21 export interface PibblPhysicsTestHarness2DOptions<Observation> {
22 /** The caller owns this canvas; disposing the harness never removes it. */
23 readonly canvas: HTMLCanvasElement;
24 /**
25 * Creates a new public Pibbl root for the initial mount and each reset.
26 * @returns The Pibbl root tree mounted by the test harness. See {@link PibblNode}.
27 */
28 readonly root: () => PibblNode;
29 /**
30 * Reads application state after a frame or action.
31 * @returns The semantic state to expose through the harness's observe method.
32 */
33 readonly observe: () => Observation;
34 }
35
36 /**
37 * A disposable test mount with explicit host-frame advancement and public-state observations.
38 *
39 * @see {@link createPhysicsTestHarness2D}
40 */
41 export interface PibblPhysicsTestHarness2D<Observation> {
42 /** Number of host callbacks currently requested by Pibbl (zero or one). */
43 readonly pendingFrames: number;
44 /**
45 * Delivers one already-requested host frame at the current host time.
46 * @returns Whether a pending frame was flushed.
47 */
48 flush(): boolean;
49 /**
50 * Advances up to `count` requested host frames by `milliseconds` each.
51 * This advances the host clock, not a guaranteed count of physics fixed ticks.
52 * @param count - Number of frames to advance.
53 * @param milliseconds - Duration of each frame in milliseconds.
54 * @returns The number of frames advanced.
55 */
56 advanceFrames(count: number, milliseconds?: number): number;
57 /**
58 * Runs an application action and delivers one resulting frame at the current time.
59 * @param callback - Action to run within the harness's deterministic scheduling context.
60 * @returns The action's return value.
61 */
62 act<Result>(callback: () => Result): Result;
63 /**
64 * Reads the caller-provided public observation without scheduling work.
65 * @returns The current observation from the configured observer.
66 */
67 observe(): Observation;
68 /** Disposes the mounted root and mounts a new one without moving host time backward. */
69 reset(): void;
70 /** Disposes the root and restores the previous realm scheduler driver. */
71 dispose(): void;
72 }
73
74 const DEFAULT_FRAME_MILLISECONDS = 1000 / 60;
75
76 /**
77 * Creates an isolated manual-clock test mount for one Pibbl 2D physics composition.
78 *
79 * It refuses a realm with an existing root because a Pibbl realm deliberately has
80 * one scheduler. Use ordinary browser tests when a test needs multiple roots.
81 *
82 * @param options - Caller-owned canvas, root factory, and semantic state observer. See
83 * {@link PibblPhysicsTestHarness2DOptions} .
84 * @returns A harness owning the test mount and scheduler lifetime; dispose it after use. See
85 * {@link PibblPhysicsTestHarness2D} .
86 *
87 * @see {@link PibblPhysicsTestHarness2DOptions}
88 * @see {@link PibblPhysicsTestHarness2D}
89 */
90 export function createPhysicsTestHarness2D<Observation>(
91 options: PibblPhysicsTestHarness2DOptions<Observation>,
92 ): PibblPhysicsTestHarness2D<Observation> {
93 const before = pibblInternalSnapshotRealmScheduler();
94 if (before.registeredRoots !== 0) {
95 throw new Error(
96 "createPhysicsTestHarness2D() requires an isolated Pibbl realm; dispose existing Pibbl roots before creating the harness.",
97 );
98 }
99 if (before.phase !== "idle" || before.pendingHostFrames !== 0) {
100 throw new Error(
101 "createPhysicsTestHarness2D() cannot replace the Pibbl clock while realm work is active.",
102 );
103 }
104
105 const driver = new PibblInternalManualSchedulerDriver();
106 let restoreDriver: (() => void) | undefined;
107 let controller: PibblController | undefined;
108 let disposed = false;
109 let hostTime = 0;
110
111 try {
112 restoreDriver = pibblInternalInstallTestSchedulerDriver(driver);
113 controller = mountRoot(options);
114 } catch (error) {
115 try {
116 controller?.dispose();
117 } finally {
118 controller = undefined;
119 restoreSchedulerDriver();
120 }
121 throw error;
122 }
123
124 const requireLive = (): void => {
125 if (disposed) {
126 throw new Error("This Pibbl physics 2D test harness has been disposed.");
127 }
128 };
129 const flush = (): boolean => {
130 requireLive();
131 if (driver.pendingHostFrames === 0) return false;
132 driver.flush(hostTime);
133 return true;
134 };
135
136 return Object.freeze({
137 get pendingFrames(): number {
138 return driver.pendingHostFrames;
139 },
140 flush,
141 advanceFrames(
142 count: number,
143 milliseconds = DEFAULT_FRAME_MILLISECONDS,
144 ): number {
145 requireLive();
146 requireFrameCount(count);
147 requireMilliseconds(milliseconds);
148 let advanced = 0;
149 for (let index = 0; index < count; index++) {
150 if (driver.pendingHostFrames === 0) break;
151 const nextHostTime = hostTime + milliseconds;
152 if (!Number.isFinite(nextHostTime)) {
153 throw new RangeError(
154 "advanceFrames() cannot advance the host clock beyond a finite time.",
155 );
156 }
157 hostTime = nextHostTime;
158 driver.flush(hostTime);
159 advanced++;
160 }
161 return advanced;
162 },
163 act<Result>(callback: () => Result): Result {
164 requireLive();
165 const result = callback();
166 flush();
167 return result;
168 },
169 observe(): Observation {
170 requireLive();
171 return options.observe();
172 },
173 reset(): void {
174 requireLive();
175 const previousController = controller;
176 controller = undefined;
177 try {
178 previousController?.dispose();
179 controller = mountRoot(options);
180 } catch (error) {
181 disposed = true;
182 try {
183 restoreSchedulerDriver();
184 } catch (restoreError) {
185 throw new AggregateError(
186 [error, restoreError],
187 "Pibbl physics 2D test harness reset failed and could not restore the scheduler driver.",
188 { cause: restoreError },
189 );
190 }
191 throw error;
192 }
193 },
194 dispose(): void {
195 if (disposed) return;
196 disposed = true;
197 const currentController = controller;
198 controller = undefined;
199 try {
200 currentController?.dispose();
201 } finally {
202 restoreSchedulerDriver();
203 }
204 },
205 });
206
207 function restoreSchedulerDriver(): void {
208 const restore = restoreDriver;
209 restoreDriver = undefined;
210 restore?.();
211 }
212 }
213
214 function mountRoot<Observation>(
215 options: PibblPhysicsTestHarness2DOptions<Observation>,
216 ): PibblController {
217 return pibbl(options.canvas, options.root());
218 }
219
220 function requireFrameCount(count: number): void {
221 if (!Number.isSafeInteger(count) || count < 0) {
222 throw new RangeError(
223 "advanceFrames(count) requires a non-negative safe integer.",
224 );
225 }
226 }
227
228 function requireMilliseconds(milliseconds: number): void {
229 if (!Number.isFinite(milliseconds) || milliseconds < 0) {
230 throw new RangeError(
231 "advanceFrames(milliseconds) requires a finite non-negative number.",
232 );
233 }
234 }
235
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.