@playfast/reform-proof 1.2.0 → 1.2.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.
package/src/index.ts CHANGED
@@ -1,17 +1,21 @@
1
- import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
2
- import type { YieldWrap } from 'effect/Utils'
3
- import { AssertionFailed } from './errors'
4
- import { formatFingerprint } from './fingerprint'
1
+ import { Effect, Layer, TestContext, type TestServices } from 'effect'
5
2
  import { ProofRunner, type ProofRunnerApi, proofRunnerLayer, withProofRunner } from './runner'
3
+ import { isRecord } from './assert'
4
+ import type { ContractOf, Facade, SlotsOf } from './facade'
5
+ import type {
6
+ AnyRequirementClass,
7
+ ProductClass,
8
+ ProductComposition,
9
+ RequirementClass,
10
+ } from './product'
11
+ import type { ProofCapture, ProofCase, ProofGenerator, SuiteResult } from './proofCase'
6
12
  import type {
7
13
  CapturedScene,
8
14
  Scene,
9
15
  SlotComposition as ReformSlotComposition,
10
- SlotContract as ReformSlotContract,
11
- Trigger,
12
16
  UiContract,
13
17
  } from '@playfast/reform'
14
- import type { AnyComposition, AnyScene, CapturedRender } from '@playfast/reform/internal'
18
+ import type { AnyScene, CapturedRender } from '@playfast/reform/internal'
15
19
 
16
20
  export { ProofRunner, proofRunnerLayer, withProofRunner }
17
21
  export type { ProofRunnerApi }
@@ -32,262 +36,27 @@ export type {
32
36
  Sink,
33
37
  } from './engine'
34
38
 
35
- export type ProductComposition = AnyComposition & { readonly identity: symbol }
36
-
37
- const RequirementCompositionTypeId: unique symbol = Symbol.for(
38
- 'reform-proof/RequirementComposition',
39
- )
40
-
41
- export interface AnyRequirementClass {
42
- new (): {}
43
- readonly manifest: {
44
- readonly kind: 'ProductRequirement'
45
- readonly name: string
46
- readonly statement: string
47
- readonly composition: ProductComposition
48
- readonly events: Option.Option<ReadonlyArray<PropertyKey>>
49
- }
50
- readonly capture: <Result>(visit: RequirementCapture<Result>) => Result
51
- }
52
-
53
- export type RequirementCapture<Result> = <
54
- Comp extends ProductComposition,
55
- Statement extends string,
56
- >(
57
- requirement: RequirementClass<Comp, Statement>,
58
- ) => Result
59
-
60
- export interface RequirementManifest<Comp extends ProductComposition, Statement extends string> {
61
- readonly kind: 'ProductRequirement'
62
- readonly name: Statement
63
- readonly statement: Statement
64
- readonly composition: Comp
65
- readonly events: Option.Option<ReadonlyArray<EventNamesOf<ContractOf<Comp>>>>
66
- }
67
-
68
- export interface RequirementClass<
69
- Comp extends ProductComposition,
70
- Statement extends string,
71
- > extends AnyRequirementClass {
72
- readonly manifest: RequirementManifest<Comp, Statement>
73
- readonly [RequirementCompositionTypeId]: (composition: Comp) => Comp
74
- }
75
-
76
- export interface ProductManifest<Comp extends ProductComposition> {
77
- readonly kind: 'Product'
78
- readonly name: string
79
- readonly composition: Comp
80
- readonly requirements: ReadonlyArray<AnyRequirementClass>
81
- }
82
-
83
- export interface ProductClass<Comp extends ProductComposition = ProductComposition> {
84
- new (): {}
85
- readonly manifest: ProductManifest<Comp>
86
- }
87
-
88
- interface MakeRequirementOptionsExternalApi<Comp extends ProductComposition> {
89
- readonly events?: ReadonlyArray<EventNamesOf<ContractOf<Comp>>>
90
- }
91
-
92
- const makeRequirement = <Comp extends ProductComposition, const Statement extends string>(
93
- composition: Comp,
94
- statement: Statement,
95
- options?: MakeRequirementOptionsExternalApi<Comp>,
96
- ): RequirementClass<Comp, Statement> => {
97
- const manifest: RequirementManifest<Comp, Statement> = {
98
- kind: 'ProductRequirement',
99
- name: statement,
100
- statement,
101
- composition,
102
- events: Option.fromNullable(options?.events),
103
- }
104
- class Requirement {
105
- static readonly manifest = manifest
106
- static readonly [RequirementCompositionTypeId] = (exact: Comp): Comp => exact
107
- static readonly capture = <Result>(visit: RequirementCapture<Result>): Result =>
108
- visit(Requirement)
109
- }
110
- return Requirement
111
- }
112
-
113
- export const ProductRequirement: { readonly make: typeof makeRequirement } = {
114
- make: makeRequirement,
115
- }
116
-
117
- interface MakeProductConfig<Comp extends ProductComposition> {
118
- readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
119
- }
120
-
121
- const makeProduct = <Comp extends ProductComposition>(
122
- composition: Comp,
123
- config: MakeProductConfig<Comp>,
124
- ): ProductClass<Comp> => {
125
- const manifest: ProductManifest<Comp> = {
126
- kind: 'Product',
127
- name: composition.manifest.name,
128
- composition,
129
- requirements: config.requirements,
130
- }
131
- return class {
132
- static readonly manifest = manifest
133
- }
134
- }
135
-
136
- export const Product: { readonly make: typeof makeProduct } = {
137
- make: makeProduct,
138
- }
139
-
140
- interface ComparePair {
141
- readonly left: unknown
142
- readonly right: unknown
143
- }
144
-
145
- const show = (subject: unknown): string => formatFingerprint(subject)
146
-
147
- const deepEqual = ({ left, right }: ComparePair): boolean =>
148
- Object.is(left, right) || show(left) === show(right)
149
-
150
- const fail = (message: string): Effect.Effect<never> =>
151
- Effect.dieMessage(new AssertionFailed({ detail: message }).message)
152
-
153
- export const expect = <A>(actual: A) => ({
154
- toBe: (expected: A): Effect.Effect<void> =>
155
- Object.is(actual, expected)
156
- ? Effect.void
157
- : fail(`expected ${show(actual)} to be ${show(expected)}`),
158
- toEqual: (expected: A): Effect.Effect<void> =>
159
- deepEqual({ left: actual, right: expected })
160
- ? Effect.void
161
- : fail(`expected ${show(actual)} to equal ${show(expected)}`),
162
- toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
163
- Array.isArray(actual) && actual.some((element) => deepEqual({ left: element, right: expected }))
164
- ? Effect.void
165
- : fail(`expected ${show(actual)} to contain ${show(expected)}`),
166
- toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
167
- const mismatch = matchPartial({ actual, expected })
168
- return mismatch === undefined ? Effect.void : fail(mismatch)
169
- },
170
- })
171
-
172
- const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
173
- typeof candidate === 'object' && candidate !== null
174
-
175
- interface MatchInput {
176
- readonly actual: unknown
177
- readonly expected: unknown
178
- }
179
-
180
- const matchPartial = ({ actual, expected }: MatchInput): string | undefined => {
181
- if (!isRecord(expected)) {
182
- return deepEqual({ left: actual, right: expected })
183
- ? undefined
184
- : `expected ${show(actual)} to match ${show(expected)}`
185
- }
186
- if (!isRecord(actual)) {
187
- return `expected ${show(actual)} to be an object matching ${show(expected)}`
188
- }
189
- const mismatch = Arr.findFirst(
190
- Rec.keys(expected),
191
- (key) => !deepEqual({ left: actual[key], right: expected[key] }),
192
- )
193
- return Option.match(mismatch, {
194
- onNone: () => undefined,
195
- onSome: (key) =>
196
- `expected key ${show(key)} to match ${show(expected[key])}, got ${show(actual[key])}`,
197
- })
198
- }
199
-
200
- export const matchProps: (input: MatchInput) => string | undefined = matchPartial
201
-
202
- export type EventsOf<C extends UiContract> = C extends {
203
- readonly events: infer E extends NonNullable<UiContract['events']>
204
- }
205
- ? E
206
- : Readonly<Record<PropertyKey, never>>
207
- type EventNamesOf<C extends UiContract> = C extends {
208
- readonly events: NonNullable<UiContract['events']>
209
- }
210
- ? keyof EventsOf<C>
211
- : never
212
- type SlotsOf<C extends UiContract> = C extends {
213
- readonly slots: infer S extends NonNullable<UiContract['slots']>
214
- }
215
- ? S
216
- : Readonly<Record<PropertyKey, never>>
217
- type PayloadOf<T> = T extends Trigger<infer P> ? P : never
218
- export type ContractOf<Comp> = Comp extends {
219
- readonly Contract: infer C extends UiContract
220
- }
221
- ? C
222
- : never
223
- type ContractOfSlot<S> = ReformSlotContract<S>
224
-
225
- export type ActionsOf<C extends UiContract, R = never> = C extends {
226
- readonly events: NonNullable<UiContract['events']>
227
- }
228
- ? {
229
- readonly [K in keyof EventsOf<C>]: (
230
- payload: PayloadOf<EventsOf<C>[K]>,
231
- ) => Effect.Effect<C['props'], never, R>
232
- }
233
- : Readonly<Record<PropertyKey, never>>
234
- export type SlotFacadesOf<C extends UiContract, R = never> = C extends {
235
- readonly slots: NonNullable<UiContract['slots']>
236
- }
237
- ? {
238
- readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>, R>
239
- }
240
- : Readonly<Record<PropertyKey, never>>
241
-
242
- export type Action<R = never> = (payload: unknown) => Effect.Effect<unknown, never, R>
243
-
244
- export interface Facade<C extends UiContract, R = never> {
245
- readonly props: Effect.Effect<C['props'], never, R>
246
- readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, R>
247
- readonly actions: ActionsOf<C, R>
248
- readonly slots: SlotFacadesOf<C, R>
249
- readonly frame: Effect.Effect<void, never, R>
250
- }
251
-
252
- export interface SlotFacade<C extends UiContract, R = never> extends Facade<C, R> {
253
- readonly first: Effect.Effect<Facade<C, R>, never, R>
254
- readonly at: (index: number) => Effect.Effect<Facade<C, R>, never, R>
255
- readonly all: Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
256
- readonly byKey: (key: string) => Effect.Effect<Facade<C, R>, never, R>
257
- readonly where: (
258
- predicate: (props: C['props']) => boolean,
259
- ) => Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
260
- readonly count: Effect.Effect<number, never, R>
261
- }
262
-
263
- type ProofGenerator = Generator<YieldWrap<Effect.Effect<unknown, unknown, never>>, void, unknown>
264
-
265
- export interface ProofCase<
266
- Comp extends ProductComposition,
267
- C extends UiContract,
268
- S extends ReadonlyArray<unknown>,
269
- Services,
270
- P,
271
- N extends string,
272
- Identity,
273
- > {
274
- readonly requirement: RequirementClass<Comp, string>
275
- readonly scene: CapturedScene<C, S, Services, P, N, Identity>
276
- readonly placement: 'Root' | 'Slot'
277
- readonly body: (app: Facade<C, never>) => ProofGenerator
278
- }
279
-
280
- export type ProofCapture<Result> = <
281
- Comp extends ProductComposition,
282
- C extends UiContract,
283
- S extends ReadonlyArray<unknown>,
284
- Services,
285
- P,
286
- N extends string,
287
- Identity,
288
- >(
289
- proof: ProofCase<Comp, C, S, Services, P, N, Identity>,
290
- ) => Result
39
+ export { Product, ProductRequirement } from './product'
40
+ export type {
41
+ AnyRequirementClass,
42
+ ProductClass,
43
+ ProductComposition,
44
+ ProductManifest,
45
+ RequirementCapture,
46
+ RequirementClass,
47
+ RequirementManifest,
48
+ } from './product'
49
+ export { expect, matchProps } from './assert'
50
+ export type {
51
+ Action,
52
+ ActionsOf,
53
+ ContractOf,
54
+ EventsOf,
55
+ Facade,
56
+ SlotFacade,
57
+ SlotFacadesOf,
58
+ } from './facade'
59
+ export type { ProofCapture, ProofCase, ProofResult, SuiteResult } from './proofCase'
291
60
 
292
61
  export interface Proof {
293
62
  readonly requirement: AnyRequirementClass
@@ -301,18 +70,6 @@ export interface ProofSuite {
301
70
  readonly proofs: ReadonlyArray<Proof>
302
71
  }
303
72
 
304
- export interface ProofResult {
305
- readonly requirement: string
306
- readonly ok: boolean
307
- readonly error: string | undefined
308
- }
309
-
310
- export interface SuiteResult {
311
- readonly product: string
312
- readonly results: ReadonlyArray<ProofResult>
313
- readonly ok: boolean
314
- }
315
-
316
73
  const implement = <Comp extends ProductComposition>(
317
74
  requirement: RequirementClass<Comp, string>,
318
75
  scene: Scene<ContractOf<NoInfer<Comp>>, NoInfer<Comp>['States']>,
@@ -438,6 +195,32 @@ const driver = (proofSuite: ProofSuite): ReadonlyArray<ProofDriver> =>
438
195
  run: () => withProofRunner((runner: ProofRunnerApi) => runner.driveProof(proof)),
439
196
  }))
440
197
 
198
+ /**
199
+ * Wrap a scene's layers in a virtual clock, so every `Effect.sleep`, `Schedule` and
200
+ * `Clock` read its procedures make elapses only when the proof says so via
201
+ * `app.advance(...)`:
202
+ *
203
+ * ```ts
204
+ * const MyScene = scene(MyComposition, { provide: [Proof.withTestClock(App)] })
205
+ * ```
206
+ *
207
+ * It has to WRAP the scene's layers, never sit beside them in `provide`. The engine
208
+ * forks its loop while its layer is being built, and a forked fiber inherits the
209
+ * clock in scope at that moment — so a clock merged as a sibling reaches the proof
210
+ * body but not the procedures whose timers it is meant to control. That mis-wiring
211
+ * is silent (`app.advance(...)` finds a clock, adjusts it, and no product timer
212
+ * moves), which is why the clock is exposed as this combinator, not a bare layer.
213
+ *
214
+ * Opt-in per scene rather than on by default: a scene without it keeps the real
215
+ * clock, so proofs that observe product timers by waiting stay correct. Once a scene
216
+ * has it, a product timer NEVER fires on its own — an unadvanced proof sees the
217
+ * pre-timer state, which is the point.
218
+ */
219
+ const withTestClock = <ROut, E, RIn>(
220
+ layer: Layer.Layer<ROut, E, RIn>,
221
+ ): Layer.Layer<ROut | TestServices.TestServices, E, RIn> =>
222
+ layer.pipe(Layer.provideMerge(TestContext.TestContext))
223
+
441
224
  export const Proof: {
442
225
  readonly implement: typeof implement
443
226
  readonly implementVia: typeof implementVia
@@ -445,4 +228,6 @@ export const Proof: {
445
228
  readonly run: typeof run
446
229
  readonly runEffect: typeof runEffect
447
230
  readonly driver: typeof driver
448
- } = { implement, implementVia, suite, run, runEffect, driver }
231
+ readonly withTestClock: typeof withTestClock
232
+ } = { implement, implementVia, suite, run, runEffect, driver, withTestClock }
233
+
package/src/product.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { Option } from 'effect'
2
+ import type { AnyComposition } from '@playfast/reform/internal'
3
+ import type { ContractOf, EventNamesOf } from './facade'
4
+
5
+ export type ProductComposition = AnyComposition & { readonly identity: symbol }
6
+
7
+ const RequirementCompositionTypeId: unique symbol = Symbol.for(
8
+ 'reform-proof/RequirementComposition',
9
+ )
10
+
11
+ export interface AnyRequirementClass {
12
+ new (): {}
13
+ readonly manifest: {
14
+ readonly kind: 'ProductRequirement'
15
+ readonly name: string
16
+ readonly statement: string
17
+ readonly composition: ProductComposition
18
+ readonly events: Option.Option<ReadonlyArray<PropertyKey>>
19
+ }
20
+ readonly capture: <Result>(visit: RequirementCapture<Result>) => Result
21
+ }
22
+
23
+ export type RequirementCapture<Result> = <
24
+ Comp extends ProductComposition,
25
+ Statement extends string,
26
+ >(
27
+ requirement: RequirementClass<Comp, Statement>,
28
+ ) => Result
29
+
30
+ export interface RequirementManifest<Comp extends ProductComposition, Statement extends string> {
31
+ readonly kind: 'ProductRequirement'
32
+ readonly name: Statement
33
+ readonly statement: Statement
34
+ readonly composition: Comp
35
+ readonly events: Option.Option<ReadonlyArray<EventNamesOf<ContractOf<Comp>>>>
36
+ }
37
+
38
+ export interface RequirementClass<
39
+ Comp extends ProductComposition,
40
+ Statement extends string,
41
+ > extends AnyRequirementClass {
42
+ readonly manifest: RequirementManifest<Comp, Statement>
43
+ readonly [RequirementCompositionTypeId]: (composition: Comp) => Comp
44
+ }
45
+
46
+ export interface ProductManifest<Comp extends ProductComposition> {
47
+ readonly kind: 'Product'
48
+ readonly name: string
49
+ readonly composition: Comp
50
+ readonly requirements: ReadonlyArray<AnyRequirementClass>
51
+ }
52
+
53
+ export interface ProductClass<Comp extends ProductComposition = ProductComposition> {
54
+ new (): {}
55
+ readonly manifest: ProductManifest<Comp>
56
+ }
57
+
58
+ interface MakeRequirementOptionsExternalApi<Comp extends ProductComposition> {
59
+ readonly events?: ReadonlyArray<EventNamesOf<ContractOf<Comp>>>
60
+ }
61
+
62
+ const makeRequirement = <Comp extends ProductComposition, const Statement extends string>(
63
+ composition: Comp,
64
+ statement: Statement,
65
+ options?: MakeRequirementOptionsExternalApi<Comp>,
66
+ ): RequirementClass<Comp, Statement> => {
67
+ const manifest: RequirementManifest<Comp, Statement> = {
68
+ kind: 'ProductRequirement',
69
+ name: statement,
70
+ statement,
71
+ composition,
72
+ events: Option.fromNullable(options?.events),
73
+ }
74
+ class Requirement {
75
+ static readonly manifest = manifest
76
+ static readonly [RequirementCompositionTypeId] = (exact: Comp): Comp => exact
77
+ static readonly capture = <Result>(visit: RequirementCapture<Result>): Result =>
78
+ visit(Requirement)
79
+ }
80
+ return Requirement
81
+ }
82
+
83
+ export const ProductRequirement: { readonly make: typeof makeRequirement } = {
84
+ make: makeRequirement,
85
+ }
86
+
87
+ interface MakeProductConfig<Comp extends ProductComposition> {
88
+ readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
89
+ }
90
+
91
+ const makeProduct = <Comp extends ProductComposition>(
92
+ composition: Comp,
93
+ config: MakeProductConfig<Comp>,
94
+ ): ProductClass<Comp> => {
95
+ const manifest: ProductManifest<Comp> = {
96
+ kind: 'Product',
97
+ name: composition.manifest.name,
98
+ composition,
99
+ requirements: config.requirements,
100
+ }
101
+ return class {
102
+ static readonly manifest = manifest
103
+ }
104
+ }
105
+
106
+ export const Product: { readonly make: typeof makeProduct } = {
107
+ make: makeProduct,
108
+ }
@@ -0,0 +1,46 @@
1
+ import type { Effect } from 'effect'
2
+ import type { YieldWrap } from 'effect/Utils'
3
+ import type { CapturedScene, UiContract } from '@playfast/reform'
4
+ import type { Facade } from './facade'
5
+ import type { ProductComposition, RequirementClass } from './product'
6
+
7
+ export type ProofGenerator = Generator<YieldWrap<Effect.Effect<unknown, unknown, never>>, void, unknown>
8
+
9
+ export interface ProofCase<
10
+ Comp extends ProductComposition,
11
+ C extends UiContract,
12
+ S extends ReadonlyArray<unknown>,
13
+ Services,
14
+ P,
15
+ N extends string,
16
+ Identity,
17
+ > {
18
+ readonly requirement: RequirementClass<Comp, string>
19
+ readonly scene: CapturedScene<C, S, Services, P, N, Identity>
20
+ readonly placement: 'Root' | 'Slot'
21
+ readonly body: (app: Facade<C, never>) => ProofGenerator
22
+ }
23
+
24
+ export type ProofCapture<Result> = <
25
+ Comp extends ProductComposition,
26
+ C extends UiContract,
27
+ S extends ReadonlyArray<unknown>,
28
+ Services,
29
+ P,
30
+ N extends string,
31
+ Identity,
32
+ >(
33
+ proof: ProofCase<Comp, C, S, Services, P, N, Identity>,
34
+ ) => Result
35
+
36
+ export interface ProofResult {
37
+ readonly requirement: string
38
+ readonly ok: boolean
39
+ readonly error: string | undefined
40
+ }
41
+
42
+ export interface SuiteResult {
43
+ readonly product: string
44
+ readonly results: ReadonlyArray<ProofResult>
45
+ readonly ok: boolean
46
+ }
@@ -0,0 +1,135 @@
1
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
2
+ import {
3
+ Channel,
4
+ Composition,
5
+ Engine,
6
+ Event,
7
+ mount,
8
+ Procedure,
9
+ provide,
10
+ publish,
11
+ Reducer,
12
+ scene,
13
+ State,
14
+ StateGroup,
15
+ type Trigger,
16
+ Ui,
17
+ ui,
18
+ } from '@playfast/reform'
19
+ import { describe, expect, test } from 'vitest'
20
+ import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
21
+
22
+ // A scene's product timers live inside its procedures, on the real clock: a proof
23
+ // could only observe one by waiting it out, which spends real seconds AND races the
24
+ // runner (a fixed sleep past the timer assumes the woken fiber is scheduled before
25
+ // the next read). `Proof.withTestClock` puts a virtual clock behind the scene so
26
+ // `app.advance(...)` moves that time exactly, instantly, and only on demand.
27
+
28
+ const FLASH = Duration.millis(1500)
29
+
30
+ class FlashState extends State.make('flash', S.Boolean) {}
31
+ class FlashStates extends StateGroup.make(FlashState) {}
32
+
33
+ class Submitted extends Event.make('TCSubmitted', S.Struct({})) {}
34
+ class FlashEnded extends Event.make('TCFlashEnded', S.Struct({})) {}
35
+
36
+ class EndFlash extends Reducer.make('TCEndFlash', {
37
+ states: [FlashState],
38
+ events: [FlashEnded],
39
+ }) {}
40
+ const EndFlashLive = Reducer.live(EndFlash, () => false)
41
+
42
+ class FlashLane extends Channel.make('TCFlashLane', { policy: { _tag: 'latest' } }) {}
43
+ class FlashTimer extends Procedure.make('TCFlashTimer', {
44
+ events: [Submitted],
45
+ channel: FlashLane,
46
+ }) {}
47
+ // The product timer under test: a success flash that clears itself 1500ms later.
48
+ const FlashTimerLive = Procedure.live(FlashTimer, function* () {
49
+ yield* Effect.sleep(FLASH)
50
+ yield* publish('Normal', Event.construct(FlashEnded, {}))
51
+ })
52
+
53
+ class FlashUi extends ui('TCFlash')<{
54
+ props: { flash: boolean }
55
+ events: { submit: Trigger<Record<string, never>> }
56
+ }>() {}
57
+ class Flash extends Composition.make('TCFlash', {
58
+ title: 'TCFlash',
59
+ states: [FlashStates],
60
+ events: [Submitted],
61
+ ui: FlashUi,
62
+ })<Flash>() {}
63
+ const FlashLive = Composition.live(Flash, function* () {
64
+ const flash = yield* StateGroup.select(FlashStates, 'flash')
65
+ const submit = yield* Event.trigger(Submitted)
66
+ return mount({ props: { flash }, slots: {}, events: { submit } })
67
+ })
68
+
69
+ const presentations = provide(FlashUi, Ui.make(FlashUi, () => null))
70
+ const Views = FlashLive.pipe(Layer.provideMerge(presentations))
71
+ const Logic = Layer.mergeAll(EndFlashLive, FlashTimerLive, Channel.live(FlashLane)).pipe(
72
+ Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(FlashStates, { flash: true }))),
73
+ )
74
+ const App = Views.pipe(Layer.provideMerge(Logic))
75
+
76
+ const VirtualScene = scene(Flash, { provide: [Proof.withTestClock(App)] })
77
+ const RealScene = scene(Flash, { provide: [App] })
78
+
79
+ class ClearsFlash extends ProductRequirement.make(Flash, 'clears the flash after the window', {
80
+ events: ['submit'],
81
+ }) {}
82
+ class FlashProduct extends Product.make(Flash, { requirements: [ClearsFlash] }) {}
83
+
84
+ describe('Proof.withTestClock', () => {
85
+ test('app.advance elapses a product timer without spending real time', async () => {
86
+ const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
87
+ yield* proofExpect((yield* app.props).flash).toBe(true)
88
+ yield* app.actions.submit({})
89
+ // The timer is armed but no virtual time has passed, so the flash still shows.
90
+ // On the real clock this frame is a race; here it is a fact.
91
+ yield* proofExpect((yield* app.props).flash).toBe(true)
92
+ yield* app.advance(FLASH)
93
+ yield* proofExpect((yield* app.props).flash).toBe(false)
94
+ })
95
+
96
+ const startedAt = performance.now()
97
+ const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
98
+ const elapsed = performance.now() - startedAt
99
+
100
+ expect(result.results.map((entry) => entry.error)).toEqual([undefined])
101
+ expect(result.ok).toBe(true)
102
+ // The assertion that matters: 1500ms of product time cost a fraction of it in
103
+ // real time. Generous enough not to flake on a loaded runner, tight enough that
104
+ // a regression to the real clock (>= 1500ms) fails here.
105
+ expect(elapsed).toBeLessThan(750)
106
+ })
107
+
108
+ test('the timer does not fire on its own — only advancing moves it', async () => {
109
+ // The complement of the test above: if the virtual clock were a no-op and the
110
+ // procedure were really sleeping on the wall clock, this proof would go green the
111
+ // moment the settle loop outlasted the window. It must stay red instead.
112
+ const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
113
+ yield* app.actions.submit({})
114
+ yield* app.frame
115
+ yield* app.frame
116
+ yield* proofExpect((yield* app.props).flash).toBe(false)
117
+ })
118
+
119
+ const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
120
+
121
+ expect(result.ok).toBe(false)
122
+ })
123
+
124
+ test('app.advance against the real clock dies rather than silently no-opping', async () => {
125
+ const proof = Proof.implement(ClearsFlash, RealScene, function* (app) {
126
+ yield* app.actions.submit({})
127
+ yield* app.advance(FLASH)
128
+ })
129
+
130
+ const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
131
+
132
+ expect(result.ok).toBe(false)
133
+ expect(result.results[0]?.error).toContain('Proof.withTestClock')
134
+ })
135
+ })