@playfast/reform-proof 1.2.0 → 1.3.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/assert.d.ts +15 -0
- package/dist/assert.d.ts.map +1 -0
- package/dist/assert.js +39 -0
- package/dist/assert.js.map +1 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +119 -0
- package/dist/engine.js.map +1 -0
- package/dist/engineMounted.d.ts +18 -0
- package/dist/engineMounted.d.ts.map +1 -0
- package/dist/engineMounted.js +96 -0
- package/dist/engineMounted.js.map +1 -0
- package/dist/engineMounts.d.ts +13 -0
- package/dist/engineMounts.d.ts.map +1 -0
- package/dist/engineMounts.js +116 -0
- package/dist/engineMounts.js.map +1 -0
- package/dist/engineRender.d.ts +10 -0
- package/dist/engineRender.d.ts.map +1 -0
- package/dist/engineRender.js +64 -0
- package/dist/engineRender.js.map +1 -0
- package/dist/engineSink.d.ts +66 -0
- package/dist/engineSink.d.ts.map +1 -0
- package/dist/engineSink.js +72 -0
- package/dist/engineSink.js.map +1 -0
- package/dist/engineTree.d.ts +63 -0
- package/dist/engineTree.d.ts.map +1 -0
- package/dist/engineTree.js +137 -0
- package/dist/engineTree.js.map +1 -0
- package/dist/engineTreeDriver.d.ts +5 -0
- package/dist/engineTreeDriver.d.ts.map +1 -0
- package/dist/engineTreeDriver.js +187 -0
- package/dist/engineTreeDriver.js.map +1 -0
- package/dist/engineTreeFacade.d.ts +5 -0
- package/dist/engineTreeFacade.d.ts.map +1 -0
- package/dist/engineTreeFacade.js +105 -0
- package/dist/engineTreeFacade.js.map +1 -0
- package/dist/engineTreeTypes.d.ts +80 -0
- package/dist/engineTreeTypes.d.ts.map +1 -0
- package/dist/engineTreeTypes.js +11 -0
- package/dist/engineTreeTypes.js.map +1 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +30 -0
- package/dist/errors.js.map +1 -0
- package/dist/facade.d.ts +54 -0
- package/dist/facade.d.ts.map +1 -0
- package/dist/facade.js +2 -0
- package/dist/facade.js.map +1 -0
- package/dist/fingerprint.d.ts +2 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +55 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/product.d.ts +56 -0
- package/dist/product.d.ts.map +1 -0
- package/dist/product.js +35 -0
- package/dist/product.js.map +1 -0
- package/dist/proofCase.d.ts +24 -0
- package/dist/proofCase.d.ts.map +1 -0
- package/dist/proofCase.js +2 -0
- package/dist/proofCase.js.map +1 -0
- package/dist/runner.d.ts +15 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +13 -0
- package/dist/runner.js.map +1 -0
- package/package.json +17 -5
- package/src/assert.ts +65 -0
- package/src/engine.ts +30 -1402
- package/src/engineMounted.ts +271 -0
- package/src/engineMounts.ts +165 -0
- package/src/engineRender.ts +136 -0
- package/src/engineSink.ts +195 -0
- package/src/engineTree.ts +298 -0
- package/src/engineTreeDriver.ts +247 -0
- package/src/engineTreeFacade.ts +183 -0
- package/src/engineTreeTypes.ts +103 -0
- package/src/errors.ts +19 -0
- package/src/facade.ts +78 -0
- package/src/index.ts +61 -276
- package/src/product.ts +108 -0
- package/src/proofCase.ts +46 -0
- package/src/test-clock.test.ts +135 -0
package/src/index.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/proofCase.ts
ADDED
|
@@ -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
|
+
})
|