Skip to content

packages/core/src/features/physics/2d-testing.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.