@weasel-js/labkit 1.8.0 → 1.8.1

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.
Files changed (189) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
  5. package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
  6. package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  7. package/dist/canvas/index.d.ts +4 -4
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +2 -2
  10. package/dist/chrome/index.js +7 -7
  11. package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
  12. package/dist/chunk-4RCVWX4M.js.map +1 -0
  13. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  14. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  15. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  16. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  17. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  18. package/dist/chunk-7ALJOVGP.js.map +1 -0
  19. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  20. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  21. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  22. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  23. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  24. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  25. package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
  26. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  27. package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
  28. package/dist/chunk-NHIO2O3O.js.map +1 -0
  29. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  30. package/dist/chunk-QMAY74TP.js.map +1 -0
  31. package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
  32. package/dist/chunk-RN7T7OBO.js.map +1 -0
  33. package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
  34. package/dist/chunk-RXO5UEGT.js.map +1 -0
  35. package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
  36. package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
  37. package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
  38. package/dist/chunk-T5OEUNCB.js.map +1 -0
  39. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  40. package/dist/chunk-UX66USTN.js.map +1 -0
  41. package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
  42. package/dist/chunk-V4SMGEYY.js.map +1 -0
  43. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  44. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  45. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  46. package/dist/chunk-XWR2P6MR.js.map +1 -0
  47. package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
  48. package/dist/chunk-YZA73U4A.js.map +1 -0
  49. package/dist/config/index.d.ts +2 -2
  50. package/dist/config/index.js +2 -2
  51. package/dist/controls/index.js +5 -5
  52. package/dist/dragdrop/index.d.ts +1 -1
  53. package/dist/dragdrop/index.js +2 -2
  54. package/dist/index.d.ts +157 -15
  55. package/dist/index.js +893 -258
  56. package/dist/index.js.map +1 -1
  57. package/dist/layers/index.d.ts +1 -1
  58. package/dist/layers/index.js +3 -3
  59. package/dist/loupe/index.d.ts +175 -27
  60. package/dist/loupe/index.js +547 -141
  61. package/dist/loupe/index.js.map +1 -1
  62. package/dist/overview/index.js +5 -5
  63. package/dist/passthrough/weasel-ui.js +3 -3
  64. package/dist/primitives/index.d.ts +4 -19
  65. package/dist/primitives/index.js +7 -7
  66. package/dist/state/index.d.ts +5 -7
  67. package/dist/state/index.js +5 -5
  68. package/dist/styles.css +173 -38
  69. package/dist/surface/index.d.ts +20 -1
  70. package/dist/surface/index.js +2 -2
  71. package/dist/undo/index.d.ts +18 -7
  72. package/dist/undo/index.js +1 -1
  73. package/package.json +11 -11
  74. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  75. package/src/annotations/AnnotationOverlay.tsx +10 -1
  76. package/src/annotations/Annotations.less +2 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  78. package/src/canvas/CanvasStack.test.tsx +35 -0
  79. package/src/canvas/CanvasStack.tsx +12 -1
  80. package/src/canvas/useLayerScheduler.ts +14 -0
  81. package/src/chrome/builtins.test.ts +14 -0
  82. package/src/chrome/builtins.tsx +17 -2
  83. package/src/chrome/regions/StatusRegion.tsx +20 -14
  84. package/src/chrome/regions/regions.test.tsx +28 -0
  85. package/src/chrome/types.ts +8 -0
  86. package/src/clock/clock.browser.test.less +5 -0
  87. package/src/clock/clock.browser.test.tsx +77 -0
  88. package/src/clock/clock.integration.test.tsx +95 -0
  89. package/src/clock/clockRegistry.test.ts +33 -0
  90. package/src/clock/clockRegistry.ts +46 -0
  91. package/src/clock/hooks.ts +44 -0
  92. package/src/clock/index.ts +2 -0
  93. package/src/clock/trialClock.test.ts +265 -0
  94. package/src/clock/trialClock.ts +245 -0
  95. package/src/clock/useClockLoop.ts +45 -0
  96. package/src/config/resolve.test.ts +12 -0
  97. package/src/config/resolve.ts +5 -0
  98. package/src/controls/ControlMatrix.tsx +56 -54
  99. package/src/index.ts +3 -2
  100. package/src/instrument/types.ts +21 -1
  101. package/src/lab/Lab.chrome.test.tsx +8 -1
  102. package/src/lab/Lab.presentation.test.tsx +113 -0
  103. package/src/lab/Lab.surface.test.tsx +20 -9
  104. package/src/lab/Lab.tsx +132 -257
  105. package/src/lab/LabLightbox.browser.test.less +17 -0
  106. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  107. package/src/lab/LabShell.less +10 -3
  108. package/src/lab/LabSurface.tsx +129 -0
  109. package/src/lab/Workspace.stories.tsx +18 -0
  110. package/src/lab/Workspace.surface.test.tsx +4 -0
  111. package/src/lab/Workspace.test.tsx +37 -1
  112. package/src/lab/Workspace.tsx +34 -6
  113. package/src/lab/index.ts +2 -0
  114. package/src/lab/nebula.ts +30 -0
  115. package/src/lab/openLab.test.ts +81 -0
  116. package/src/lab/openLab.ts +119 -0
  117. package/src/lab/presentation.browser.test.less +5 -0
  118. package/src/lab/presentation.browser.test.tsx +119 -0
  119. package/src/lab/presentation.less +64 -0
  120. package/src/lab/presentation.tsx +47 -0
  121. package/src/lab/useFocusPick.ts +43 -0
  122. package/src/lightbox/Lightbox.browser.test.less +14 -0
  123. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  124. package/src/lightbox/Lightbox.less +128 -0
  125. package/src/lightbox/Lightbox.test.tsx +195 -0
  126. package/src/lightbox/Lightbox.tsx +205 -0
  127. package/src/lightbox/LightboxContext.ts +38 -0
  128. package/src/lightbox/LightboxLayers.tsx +40 -0
  129. package/src/lightbox/index.ts +8 -0
  130. package/src/lightbox/modal.ts +94 -0
  131. package/src/loupe/AGENTS.md +40 -2
  132. package/src/loupe/CanvasLoupe.tsx +5 -4
  133. package/src/loupe/DomLoupe.tsx +7 -5
  134. package/src/loupe/Loupe.less +15 -0
  135. package/src/loupe/LoupeBubble.tsx +35 -13
  136. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  137. package/src/loupe/SourceLoupe.tsx +60 -0
  138. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  139. package/src/loupe/TrialLoupe.tsx +126 -28
  140. package/src/loupe/canvasLens.ts +20 -17
  141. package/src/loupe/index.ts +26 -2
  142. package/src/loupe/sourceLens.test.ts +65 -0
  143. package/src/loupe/sourceLens.ts +97 -0
  144. package/src/loupe/types.test.ts +6 -0
  145. package/src/loupe/types.ts +109 -7
  146. package/src/loupe/useLoupe.test.tsx +188 -3
  147. package/src/loupe/useLoupe.ts +106 -11
  148. package/src/primitives/index.ts +2 -2
  149. package/src/specimen/Specimen.tsx +0 -7
  150. package/src/state/helpers.test.ts +4 -11
  151. package/src/state/helpers.ts +2 -8
  152. package/src/state/index.ts +0 -2
  153. package/src/state/openLabStore.test.ts +10 -8
  154. package/src/state/openLabStore.ts +1 -1
  155. package/src/state/store.test.ts +2 -2
  156. package/src/state/store.ts +15 -16
  157. package/src/state/types.ts +9 -9
  158. package/src/styles.less +2 -1
  159. package/src/surface/AGENTS.md +13 -0
  160. package/src/surface/index.ts +1 -0
  161. package/src/surface/useSurfaceTile.test.tsx +4 -0
  162. package/src/surface/useSurfaceTile.ts +17 -1
  163. package/src/surface/useTiledSurface.test.tsx +65 -0
  164. package/src/surface/useTiledSurface.ts +68 -1
  165. package/src/trial/Trial.stories.tsx +0 -1
  166. package/src/trial/Trial.test.tsx +40 -3
  167. package/src/trial/Trial.tsx +87 -43
  168. package/src/trial/TrialChrome.tsx +20 -2
  169. package/src/trial/trialOps.test.ts +1 -2
  170. package/src/trial/trialOps.ts +2 -3
  171. package/src/undo/stateOp.ts +21 -0
  172. package/src/undo/undoStack.ts +18 -6
  173. package/dist/chunk-3L4MLLQB.js.map +0 -1
  174. package/dist/chunk-7KAETQSS.js.map +0 -1
  175. package/dist/chunk-EESJUSYR.js.map +0 -1
  176. package/dist/chunk-J5HJJDLF.js.map +0 -1
  177. package/dist/chunk-NVKBL7ZE.js.map +0 -1
  178. package/dist/chunk-PMLZP7RT.js.map +0 -1
  179. package/dist/chunk-RL2LOLNI.js.map +0 -1
  180. package/dist/chunk-T6DLGEJB.js.map +0 -1
  181. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  182. package/dist/chunk-WURZTJGQ.js.map +0 -1
  183. package/dist/chunk-XXACAPVU.js.map +0 -1
  184. package/dist/chunk-YDE6XATS.js.map +0 -1
  185. package/dist/chunk-YX75W6EK.js.map +0 -1
  186. package/src/primitives/StatusBar.less +0 -34
  187. package/src/primitives/StatusBar.stories.tsx +0 -24
  188. package/src/primitives/StatusBar.test.tsx +0 -25
  189. package/src/primitives/StatusBar.tsx +0 -28
@@ -0,0 +1,95 @@
1
+ import { act, fireEvent, screen } from '@testing-library/react';
2
+ import { renderSettled } from '@weasel-js/react/testing/renderSettled';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { Lab } from '../lab/Lab';
6
+ import { useLabContext } from '../lab/LabContext';
7
+ import { useLabStore } from '../state/context';
8
+ import { useTrialClock } from './hooks';
9
+
10
+ function Readout({ label }: { label: string }) {
11
+ const clock = useTrialClock();
12
+ return <output aria-label={label}>{clock ? `${clock.elapsed}@${clock.rate}` : 'none'}</output>;
13
+ }
14
+
15
+ function Persisted({ trialId }: { trialId: string }) {
16
+ const clock = useLabStore().trials.find((t) => t.id === trialId)?.clock;
17
+ return (
18
+ <output aria-label="persisted">{clock ? `${clock.elapsed}@${clock.rate}` : 'none'}</output>
19
+ );
20
+ }
21
+
22
+ function ResetButton({ trialId }: { trialId: string }) {
23
+ const lab = useLabContext();
24
+ return (
25
+ <button type="button" onClick={() => lab.resetTrial(trialId)}>
26
+ reset trial
27
+ </button>
28
+ );
29
+ }
30
+
31
+ const timed: Instrument = {
32
+ name: 'Timed',
33
+ defaultConfig: () => ({}),
34
+ initialState: () => ({}),
35
+ clock: { duration: 1000 },
36
+ render: (ctx) => (
37
+ <>
38
+ <button type="button" onClick={() => ctx.trial.clock?.seek(500)}>
39
+ seek
40
+ </button>
41
+ <button
42
+ type="button"
43
+ onClick={() => {
44
+ if (ctx.trial.clock) ctx.trial.clock.rate = 2;
45
+ }}
46
+ >
47
+ fast
48
+ </button>
49
+ <Readout label="inside" />
50
+ <Persisted trialId={ctx.trial.id} />
51
+ <ResetButton trialId={ctx.trial.id} />
52
+ </>
53
+ ),
54
+ };
55
+
56
+ describe('a trial clock in a mounted lab', () => {
57
+ it('reaches the instrument, the trial, the lab, the record and Reset', async () => {
58
+ await renderSettled(
59
+ <Lab title="T" instruments={[timed]} defaultInstrument="Timed">
60
+ <Readout label="outside" />
61
+ </Lab>,
62
+ );
63
+ expect(screen.getByLabelText('inside')).toHaveTextContent('0@0');
64
+ expect(screen.getByLabelText('outside')).toHaveTextContent('0@0');
65
+
66
+ await act(async () => {
67
+ fireEvent.click(screen.getByRole('button', { name: 'seek' }));
68
+ });
69
+ expect(screen.getByLabelText('inside')).toHaveTextContent('500@0');
70
+ expect(screen.getByLabelText('outside')).toHaveTextContent('500@0');
71
+ expect(screen.getByLabelText('persisted')).toHaveTextContent('500@0');
72
+
73
+ await act(async () => {
74
+ fireEvent.click(screen.getByRole('button', { name: 'fast' }));
75
+ });
76
+ expect(screen.getByLabelText('persisted')).toHaveTextContent('500@2');
77
+
78
+ await act(async () => {
79
+ fireEvent.click(screen.getByRole('button', { name: 'reset trial' }));
80
+ });
81
+ expect(screen.getByLabelText('inside')).toHaveTextContent('0@0');
82
+ expect(screen.getByLabelText('persisted')).toHaveTextContent('0@0');
83
+ });
84
+
85
+ it('gives an instrument without a clock none', async () => {
86
+ const plain: Instrument = {
87
+ ...timed,
88
+ name: 'Plain',
89
+ clock: undefined,
90
+ render: () => <Readout label="inside" />,
91
+ };
92
+ await renderSettled(<Lab title="T" instruments={[plain]} defaultInstrument="Plain" />);
93
+ expect(screen.getByLabelText('inside')).toHaveTextContent('none');
94
+ });
95
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createClockRegistry } from './clockRegistry';
3
+ import { createTrialClock } from './trialClock';
4
+
5
+ describe('createClockRegistry', () => {
6
+ it('publishes a clock under its trial id until released', () => {
7
+ const registry = createClockRegistry();
8
+ const handle = createTrialClock({});
9
+ const release = registry.register('t1', handle);
10
+ expect(registry.get('t1')).toBe(handle);
11
+ expect([...registry.all()]).toEqual([handle]);
12
+ release();
13
+ expect(registry.get('t1')).toBeNull();
14
+ });
15
+
16
+ it('notifies on register and release', () => {
17
+ const registry = createClockRegistry();
18
+ const listener = vi.fn();
19
+ registry.subscribe(listener);
20
+ registry.register('t1', createTrialClock({}))();
21
+ expect(listener).toHaveBeenCalledTimes(2);
22
+ });
23
+
24
+ it('leaves a newer registration alone when a stale release runs', () => {
25
+ const registry = createClockRegistry();
26
+ const first = createTrialClock({});
27
+ const second = createTrialClock({});
28
+ const releaseFirst = registry.register('t1', first);
29
+ registry.register('t1', second);
30
+ releaseFirst();
31
+ expect(registry.get('t1')).toBe(second);
32
+ });
33
+ });
@@ -0,0 +1,46 @@
1
+ import { createContext } from 'react';
2
+ import type { TrialClockHandle } from './trialClock';
3
+
4
+ /** A lab's trial clocks, by trial id. A trial has at most one. */
5
+ export interface ClockRegistry {
6
+ /** Publish `handle` under `trialId` and return its release. */
7
+ register(trialId: string, handle: TrialClockHandle): () => void;
8
+ get(trialId: string): TrialClockHandle | null;
9
+ all(): Iterable<TrialClockHandle>;
10
+ /** Fires after any register or release. Shaped for `useSyncExternalStore`. */
11
+ subscribe(listener: () => void): () => void;
12
+ }
13
+
14
+ /** An empty in-memory `ClockRegistry`. */
15
+ export function createClockRegistry(): ClockRegistry {
16
+ const handles = new Map<string, TrialClockHandle>();
17
+ const listeners = new Set<() => void>();
18
+ const notify = (): void => {
19
+ for (const l of [...listeners]) l();
20
+ };
21
+ return {
22
+ register(trialId, handle) {
23
+ handles.set(trialId, handle);
24
+ notify();
25
+ return () => {
26
+ if (handles.get(trialId) !== handle) return;
27
+ handles.delete(trialId);
28
+ notify();
29
+ };
30
+ },
31
+ get: (trialId) => handles.get(trialId) ?? null,
32
+ all: () => handles.values(),
33
+ subscribe(listener) {
34
+ listeners.add(listener);
35
+ return () => {
36
+ listeners.delete(listener);
37
+ };
38
+ },
39
+ };
40
+ }
41
+
42
+ /** The registry a lab keeps its trials' clocks in. */
43
+ export const ClockRegistryContext = createContext<ClockRegistry | null>(null);
44
+
45
+ /** The clock of the trial a component renders inside, when it declares one. */
46
+ export const TrialClockContext = createContext<TrialClockHandle | null>(null);
@@ -0,0 +1,44 @@
1
+ import { useLatest } from '@weasel-js/core';
2
+ import { useContext, useEffect, useReducer, useSyncExternalStore } from 'react';
3
+ import { LabContext } from '../lab/LabContext';
4
+ import { ClockRegistryContext, TrialClockContext } from './clockRegistry';
5
+ import type { TrialClock } from './trialClock';
6
+
7
+ const NO_SUBSCRIBE = () => () => {};
8
+
9
+ /**
10
+ * A trial's clock: `trialId`'s, else the one of the trial this renders inside,
11
+ * else the lab's focused trial's. `null` when that trial declares no clock.
12
+ * Re-renders on rate, seek and loop changes, never per frame — read `elapsed`
13
+ * per frame through {@link useClockFrame}.
14
+ */
15
+ export function useTrialClock(trialId?: string): TrialClock | null {
16
+ const registry = useContext(ClockRegistryContext);
17
+ const own = useContext(TrialClockContext);
18
+ const lab = useContext(LabContext);
19
+ useSyncExternalStore(registry?.subscribe ?? NO_SUBSCRIBE, () => (registry ? resolve() : own));
20
+ function resolve() {
21
+ if (trialId !== undefined) return registry?.get(trialId) ?? null;
22
+ if (own) return own;
23
+ const focused = lab?.focusedTrialId;
24
+ return focused ? (registry?.get(focused) ?? null) : null;
25
+ }
26
+ const clock = resolve()?.clock ?? null;
27
+ const [, bump] = useReducer((n: number) => n + 1, 0);
28
+ useEffect(() => clock?.subscribe(bump), [clock]);
29
+ return clock;
30
+ }
31
+
32
+ /**
33
+ * Run `fn` after each frame the clock of the trial this renders inside moves,
34
+ * with its `elapsed` and `pass`, outside React's render — for a renderer that
35
+ * draws imperatively, or text that changes every frame written into a ref.
36
+ */
37
+ export function useClockFrame(fn: (elapsed: number, pass: number) => void): void {
38
+ const handle = useContext(TrialClockContext);
39
+ const latest = useLatest(fn);
40
+ useEffect(
41
+ () => handle?.onFrame((elapsed, pass) => latest.current(elapsed, pass)),
42
+ [handle, latest],
43
+ );
44
+ }
@@ -0,0 +1,2 @@
1
+ export { useClockFrame, useTrialClock } from './hooks';
2
+ export type { ClockCapability, TrialClock } from './trialClock';
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createTrialClock } from './trialClock';
3
+
4
+ /** A clock already synced once at t=0, so the next sync advances it. */
5
+ function started(...args: Parameters<typeof createTrialClock>) {
6
+ const c = createTrialClock(...args);
7
+ c.clock.sync(0);
8
+ return c;
9
+ }
10
+
11
+ describe('createTrialClock', () => {
12
+ it('opens paused at 0, and inert', () => {
13
+ const { clock } = createTrialClock({});
14
+ expect(clock.elapsed).toBe(0);
15
+ expect(clock.rate).toBe(0);
16
+ expect(clock.inert).toBe(true);
17
+ });
18
+
19
+ it('opens at the declared rate', () => {
20
+ const { clock } = createTrialClock({ rate: 2 });
21
+ expect(clock.rate).toBe(2);
22
+ expect(clock.inert).toBe(false);
23
+ });
24
+
25
+ it('advances by rate times real time', () => {
26
+ const { clock } = started({ rate: 2 });
27
+ clock.sync(100);
28
+ expect(clock.elapsed).toBe(200);
29
+ });
30
+
31
+ it('records the time on its first sync without advancing', () => {
32
+ const { clock } = createTrialClock({ rate: 1 });
33
+ clock.sync(5000);
34
+ expect(clock.elapsed).toBe(0);
35
+ clock.sync(5016);
36
+ expect(clock.elapsed).toBe(16);
37
+ });
38
+
39
+ it('plays backward at a negative rate', () => {
40
+ const { clock } = started({ rate: -1 }, { elapsed: 500, rate: -1 });
41
+ clock.sync(200);
42
+ expect(clock.elapsed).toBe(300);
43
+ });
44
+
45
+ it('does not charge a sleep to the clock when it wakes', () => {
46
+ const { clock } = started({ rate: 1 });
47
+ clock.sync(10);
48
+ expect(clock.elapsed).toBe(10);
49
+ clock.rate = 0;
50
+ clock.sync(20);
51
+ expect(clock.inert).toBe(true);
52
+ clock.rate = 1;
53
+ clock.sync(100_000);
54
+ expect(clock.elapsed).toBe(10);
55
+ clock.sync(100_016);
56
+ expect(clock.elapsed).toBe(26);
57
+ });
58
+
59
+ it('drops the time between a rebase and the next sync', () => {
60
+ const { clock } = started({ rate: 1 });
61
+ clock.sync(100);
62
+ clock.rebase();
63
+ clock.sync(60_000);
64
+ expect(clock.elapsed).toBe(100);
65
+ clock.sync(60_010);
66
+ expect(clock.elapsed).toBe(110);
67
+ });
68
+
69
+ describe('ends', () => {
70
+ it('holds at the end of a run and pauses', () => {
71
+ const listener = vi.fn();
72
+ const { clock } = started({ duration: 1000, rate: 1 });
73
+ clock.subscribe(listener);
74
+ clock.sync(1500);
75
+ expect(clock.elapsed).toBe(1000);
76
+ expect(clock.rate).toBe(0);
77
+ expect(listener).toHaveBeenCalled();
78
+ });
79
+
80
+ it('holds at 0 playing backward and pauses', () => {
81
+ const { clock } = started({ duration: 1000 }, { elapsed: 100, rate: -1 });
82
+ clock.sync(500);
83
+ expect(clock.elapsed).toBe(0);
84
+ expect(clock.rate).toBe(0);
85
+ });
86
+
87
+ it('never ends with no duration', () => {
88
+ const { clock } = started({ rate: 1 });
89
+ clock.sync(1e9);
90
+ expect(clock.elapsed).toBe(1e9);
91
+ expect(clock.rate).toBe(1);
92
+ });
93
+
94
+ it('runs n passes when loop is n, counting them', () => {
95
+ const { clock } = started({ duration: 100, loop: 3, rate: 1 });
96
+ clock.sync(250);
97
+ expect(clock.pass).toBe(2);
98
+ expect(clock.phase).toBeCloseTo(0.5);
99
+ clock.sync(400);
100
+ expect(clock.elapsed).toBe(300);
101
+ expect(clock.pass).toBe(2);
102
+ expect(clock.phase).toBe(1);
103
+ expect(clock.rate).toBe(0);
104
+ });
105
+
106
+ it('loops forever on loop: true, the phase wrapping both ways', () => {
107
+ const { clock } = started({ duration: 100, loop: true, rate: 1 });
108
+ clock.sync(1050);
109
+ expect(clock.pass).toBe(10);
110
+ expect(clock.phase).toBeCloseTo(0.5);
111
+ clock.rate = -1;
112
+ clock.sync(1050);
113
+ clock.sync(1150);
114
+ expect(clock.pass).toBe(9);
115
+ expect(clock.phase).toBeCloseTo(0.5);
116
+ });
117
+
118
+ it('plays once by default', () => {
119
+ const { clock } = started({ duration: 100, rate: 1 });
120
+ clock.sync(150);
121
+ expect(clock.elapsed).toBe(100);
122
+ });
123
+ });
124
+
125
+ describe('seek', () => {
126
+ it('is absolute, across passes', () => {
127
+ const { clock } = createTrialClock({ duration: 100, loop: true });
128
+ clock.seek(250);
129
+ expect(clock.elapsed).toBe(250);
130
+ expect(clock.pass).toBe(2);
131
+ });
132
+
133
+ it('clamps to the run', () => {
134
+ const { clock } = createTrialClock({ duration: 100, loop: 2 });
135
+ clock.seek(-5);
136
+ expect(clock.elapsed).toBe(0);
137
+ clock.seek(999);
138
+ expect(clock.elapsed).toBe(200);
139
+ });
140
+
141
+ it('wakes a paused clock for one frame', () => {
142
+ const wake = vi.fn();
143
+ const { clock } = started({});
144
+ clock.onWake(wake);
145
+ clock.seek(50);
146
+ expect(wake).toHaveBeenCalledTimes(1);
147
+ expect(clock.inert).toBe(false);
148
+ clock.sync(16);
149
+ expect(clock.inert).toBe(true);
150
+ });
151
+ });
152
+
153
+ describe('seekable: false', () => {
154
+ it('throws on any seek', () => {
155
+ const { clock } = createTrialClock({ seekable: false });
156
+ expect(() => clock.seek(0)).toThrow();
157
+ expect(() => clock.seek(10)).toThrow();
158
+ });
159
+
160
+ it('throws on a negative rate, set or ramped', () => {
161
+ const { clock } = createTrialClock({ seekable: false });
162
+ expect(() => {
163
+ clock.rate = -1;
164
+ }).toThrow();
165
+ expect(() => clock.ramp(-1, 100)).toThrow();
166
+ });
167
+
168
+ it('still resets to 0 at its declared rate', () => {
169
+ const c = started({ seekable: false, rate: 1 });
170
+ c.clock.sync(500);
171
+ c.clock.rate = 3;
172
+ c.reset();
173
+ expect(c.clock.elapsed).toBe(0);
174
+ expect(c.clock.rate).toBe(1);
175
+ });
176
+ });
177
+
178
+ describe('ramp', () => {
179
+ it('moves rate linearly and integrates position', () => {
180
+ const { clock } = started({ rate: 0 });
181
+ clock.ramp(2, 100);
182
+ clock.sync(10); // woken: records the time
183
+ clock.sync(110);
184
+ expect(clock.rate).toBe(2);
185
+ expect(clock.elapsed).toBeCloseTo(100);
186
+ clock.sync(160);
187
+ expect(clock.elapsed).toBeCloseTo(200);
188
+ });
189
+
190
+ it('finishes mid-frame and runs on at the new rate', () => {
191
+ const { clock } = started({ rate: 1 });
192
+ clock.ramp(3, 100);
193
+ clock.sync(200);
194
+ expect(clock.rate).toBe(3);
195
+ expect(clock.elapsed).toBeCloseTo(200 + 300);
196
+ });
197
+
198
+ it('is replaced by a rate write', () => {
199
+ const { clock } = started({ rate: 1 });
200
+ clock.ramp(5, 1000);
201
+ clock.rate = 0;
202
+ clock.sync(500);
203
+ expect(clock.elapsed).toBe(0);
204
+ });
205
+ });
206
+
207
+ describe('notifications', () => {
208
+ it('subscribe fires on rate, seek and loop changes, not per frame', () => {
209
+ const listener = vi.fn();
210
+ const { clock } = started({ rate: 1, duration: 1000 });
211
+ clock.subscribe(listener);
212
+ clock.sync(16);
213
+ clock.sync(32);
214
+ expect(listener).not.toHaveBeenCalled();
215
+ clock.rate = 2;
216
+ clock.seek(10);
217
+ clock.loop = true;
218
+ expect(listener).toHaveBeenCalledTimes(3);
219
+ });
220
+
221
+ it('onWake fires once until the next sync', () => {
222
+ const wake = vi.fn();
223
+ const { clock } = started({});
224
+ clock.onWake(wake);
225
+ clock.rate = 1;
226
+ clock.rate = 2;
227
+ expect(wake).toHaveBeenCalledTimes(1);
228
+ clock.sync(16);
229
+ clock.rate = 0;
230
+ expect(wake).toHaveBeenCalledTimes(2);
231
+ });
232
+
233
+ it('onFrame fires after a sync that moved the clock, with elapsed and pass', () => {
234
+ const frame = vi.fn();
235
+ const c = started({ rate: 1, duration: 100, loop: true });
236
+ c.onFrame(frame);
237
+ c.clock.sync(150);
238
+ expect(frame).toHaveBeenCalledWith(150, 1);
239
+ c.clock.rate = 0;
240
+ c.clock.sync(160);
241
+ frame.mockClear();
242
+ c.clock.sync(170);
243
+ expect(frame).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it('onFrame fires once after a seek, though paused', () => {
247
+ const frame = vi.fn();
248
+ const c = started({});
249
+ c.onFrame(frame);
250
+ c.clock.seek(40);
251
+ c.clock.sync(16);
252
+ expect(frame).toHaveBeenCalledWith(40, 0);
253
+ });
254
+ });
255
+
256
+ it('rejects a rate that is not a finite number', () => {
257
+ const { clock } = createTrialClock({});
258
+ expect(() => {
259
+ clock.rate = Number.NaN;
260
+ }).toThrow();
261
+ expect(() => {
262
+ clock.rate = Number.POSITIVE_INFINITY;
263
+ }).toThrow();
264
+ });
265
+ });