@playfast/reform-proof 0.0.8 → 0.0.9
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/package.json +1 -1
- package/src/engine.ts +162 -55
- package/src/index.ts +77 -5
- package/src/structure-events.test.ts +74 -0
- package/src/structure-locators.test.ts +137 -0
- package/src/typed-seams.test.ts +166 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-proof",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Headless testing toolkit for reform — drive a scene's compositions and assert on the props they compute, with no DOM, timers, or text matching.",
|
|
7
7
|
"keywords": [
|
package/src/engine.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { Effect, Layer, ManagedRuntime } from 'effect'
|
|
1
|
+
import { Effect, Layer, Match, ManagedRuntime } from 'effect'
|
|
2
2
|
import { yieldWrapGet } from 'effect/Utils'
|
|
3
|
-
import {
|
|
4
|
-
import { UnknownAction, UnknownSlot } from './errors'
|
|
3
|
+
import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
|
|
5
4
|
import {
|
|
6
5
|
Bus,
|
|
7
6
|
CaptureSink,
|
|
@@ -10,26 +9,26 @@ import {
|
|
|
10
9
|
type CompositionClass,
|
|
11
10
|
type CompositionService,
|
|
12
11
|
isFeatureBinding,
|
|
13
|
-
|
|
12
|
+
isStructure,
|
|
14
13
|
publish,
|
|
15
14
|
type RenderEnv,
|
|
16
15
|
type Scene,
|
|
17
16
|
type SlotChild,
|
|
18
17
|
type SlotClass,
|
|
19
|
-
type
|
|
18
|
+
type SlotFill,
|
|
19
|
+
type Structure,
|
|
20
20
|
type Trigger,
|
|
21
21
|
type UiCapture,
|
|
22
22
|
type UiContract,
|
|
23
23
|
} from '@playfast/reform'
|
|
24
|
+
import { matchProps } from './index'
|
|
24
25
|
import type { Action, DriveResult, Facade, Proof, ProofResult, SlotFacade, StepFrame } from './index'
|
|
25
26
|
|
|
26
|
-
// reform-proof engine — the headless renderer the proof system runs on. It drives
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// two seams the `ProofRunner` layer exposes (see ./runner): one runs a proof to
|
|
32
|
-
// completion, the other steps it a frame at a time for the editor's timeline.
|
|
27
|
+
// reform-proof engine — the headless renderer the proof system runs on. It drives the
|
|
28
|
+
// real reduce loop through a capturing FACADE: no DOM, no React, no view eval — each
|
|
29
|
+
// composition returns a `Structure`, and the engine reads props/events/fills off the
|
|
30
|
+
// data. `executeProof`/`driveProof` are the two seams the `ProofRunner` layer exposes
|
|
31
|
+
// (see ./runner): run a proof to completion, or step it a frame at a time.
|
|
33
32
|
|
|
34
33
|
/** An erased facade — the runtime shape before the contract type is re-attached. */
|
|
35
34
|
type AnyFacade = Facade<UiContract>
|
|
@@ -60,6 +59,14 @@ const keyed = <V>(get: (key: string) => V): Record<string, V> => {
|
|
|
60
59
|
|
|
61
60
|
const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
|
|
62
61
|
|
|
62
|
+
// The event triggers a `Structure` frame carries (plan 03b). At the erased
|
|
63
|
+
// `UiContract` boundary the contract's event map is `Record<never, never>`, so
|
|
64
|
+
// each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
|
|
65
|
+
// omitted `events` defaults to the empty map. This is the structure-path analog of
|
|
66
|
+
// the view path's `capture.events`.
|
|
67
|
+
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
68
|
+
Object.fromEntries(Object.entries(structure.events ?? {}))
|
|
69
|
+
|
|
63
70
|
interface Sink {
|
|
64
71
|
readonly api: CaptureSinkApi
|
|
65
72
|
readonly captures: ReadonlyArray<UiCapture>
|
|
@@ -99,38 +106,22 @@ const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, neve
|
|
|
99
106
|
return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
/** A composition queued to render, with the props its parent handed it.
|
|
109
|
+
/** A composition queued to render, with the props its parent handed it. On the
|
|
110
|
+
* structure path it also carries the slot-fill `key` it was mounted under, so the
|
|
111
|
+
* facade can select a child by key (`byKey`); the legacy Node path omits it. */
|
|
103
112
|
interface Mounted {
|
|
104
113
|
readonly comp: CompositionClass<unknown>
|
|
105
114
|
readonly props: unknown
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Walk a rendered node tree and invoke any slot components it contains (JSX
|
|
110
|
-
* defers them to the host; headless we drive them ourselves), enqueuing the
|
|
111
|
-
* child each stands for. Slot components are matched by identity, so this never
|
|
112
|
-
* calls — and never needs hooks from — ordinary components.
|
|
113
|
-
*/
|
|
114
|
-
const drive = (node: Node, enqueueBy: Map<Function, (props: unknown) => void>): void => {
|
|
115
|
-
if (Array.isArray(node)) {
|
|
116
|
-
for (const child of node) drive(child, enqueueBy)
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
if (!isValidElement<{ readonly children?: Node }>(node)) return
|
|
120
|
-
if (node.type instanceof Function) {
|
|
121
|
-
const enqueue = enqueueBy.get(node.type)
|
|
122
|
-
if (enqueue !== undefined) {
|
|
123
|
-
enqueue(node.props)
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
drive(node.props.children, enqueueBy)
|
|
115
|
+
readonly key?: string
|
|
128
116
|
}
|
|
129
117
|
|
|
130
118
|
const makeFacade = (
|
|
131
119
|
runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
|
|
132
120
|
root: CompositionClass<unknown>,
|
|
133
121
|
sink: Sink,
|
|
122
|
+
// Records every event name dispatched through the facade, so a proof's
|
|
123
|
+
// requirement-declared event coverage can be verified after the body runs.
|
|
124
|
+
dispatched: Set<string>,
|
|
134
125
|
): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
|
|
135
126
|
// Slot bindings (`provide(slot, composition)`) are static, so resolve the
|
|
136
127
|
// whole child tree once, up front — keeping `renderTree` and slot navigation
|
|
@@ -164,27 +155,76 @@ const makeFacade = (
|
|
|
164
155
|
Effect.gen(function* () {
|
|
165
156
|
if (frontier.length === 0) return
|
|
166
157
|
const next: Array<Mounted> = []
|
|
167
|
-
for (const { comp, props } of frontier) {
|
|
158
|
+
for (const { comp, props, key } of frontier) {
|
|
168
159
|
const service = yield* comp.tag
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return
|
|
179
|
-
|
|
160
|
+
const env: RenderEnv = { props, tracker: { add: () => {} } }
|
|
161
|
+
const frame = yield* Composition.render(service, env)
|
|
162
|
+
// Every composition returns a `Structure` (no view is ever executed — the
|
|
163
|
+
// proof is view-free / React-free): read its computed props straight off the
|
|
164
|
+
// value, record them + the events it carries into the sink (so the facade,
|
|
165
|
+
// `triggerOf`, and navigation work uniformly), and walk its slot FILLS to
|
|
166
|
+
// enqueue children.
|
|
167
|
+
if (!isStructure(frame)) {
|
|
168
|
+
return yield* Effect.dieMessage(
|
|
169
|
+
`reform-proof: composition ${uiNameOf(comp)} did not return a Structure`,
|
|
170
|
+
)
|
|
180
171
|
}
|
|
181
|
-
|
|
182
|
-
const node = yield* Composition.render(service, env)
|
|
183
|
-
drive(node, enqueueBy)
|
|
172
|
+
driveStructure(comp, frame, next, key)
|
|
184
173
|
}
|
|
185
174
|
yield* renderLevel(next)
|
|
186
175
|
})
|
|
187
176
|
|
|
177
|
+
// Consume a `Structure` frame: record the node's computed props (the structure
|
|
178
|
+
// value already holds them — the proof never derives them from a view) AND the
|
|
179
|
+
// event triggers it carries on `structure.events` (plan 03b), then enqueue one
|
|
180
|
+
// child per slot fill with the per-item props the fill carries. Recording the
|
|
181
|
+
// events into the capture the same way the view path does means `triggerOf` /
|
|
182
|
+
// `app.actions` resolve identically for Structure frames; the index-keyed enqueue
|
|
183
|
+
// order matches the legacy node walk.
|
|
184
|
+
const driveStructure = (
|
|
185
|
+
comp: CompositionClass<unknown>,
|
|
186
|
+
structure: Structure<UiContract>,
|
|
187
|
+
next: Array<Mounted>,
|
|
188
|
+
key: string | undefined,
|
|
189
|
+
): void => {
|
|
190
|
+
sink.api.record({
|
|
191
|
+
name: uiNameOf(comp),
|
|
192
|
+
props: structure.props,
|
|
193
|
+
events: eventsOf(structure),
|
|
194
|
+
...(key !== undefined ? { key } : {}),
|
|
195
|
+
})
|
|
196
|
+
const fills: Record<string, SlotFill<unknown>> = structure.slots
|
|
197
|
+
for (const [slotName, fill] of Object.entries(fills)) {
|
|
198
|
+
const slotClass = comp.manifest.slots?.[slotName]
|
|
199
|
+
const child = slotClass && bindings.get(slotClass)
|
|
200
|
+
if (child === undefined) continue
|
|
201
|
+
enqueueFill(slotName, fill, child, next)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Expand one slot fill into the children to render next. `Each` mounts one
|
|
206
|
+
// child per keyed item with that item's props and key; `One` mounts a single
|
|
207
|
+
// child under a synthesized singleton key; `Absent` mounts nothing (the
|
|
208
|
+
// data-driven `cond && …`). The key rides each `Mounted` so the child's capture
|
|
209
|
+
// can record it, letting the facade select by key (`byKey`).
|
|
210
|
+
const enqueueFill = (
|
|
211
|
+
slotName: string,
|
|
212
|
+
fill: SlotFill<unknown>,
|
|
213
|
+
child: CompositionClass<unknown>,
|
|
214
|
+
next: Array<Mounted>,
|
|
215
|
+
): void => {
|
|
216
|
+
Match.value(fill).pipe(
|
|
217
|
+
Match.when({ _tag: 'Each' }, (each) => {
|
|
218
|
+
for (const item of each.items) next.push({ comp: child, props: item.props, key: item.key })
|
|
219
|
+
}),
|
|
220
|
+
Match.when({ _tag: 'One' }, (oneFill) => {
|
|
221
|
+
next.push({ comp: child, props: oneFill.props, key: `${slotName}.0` })
|
|
222
|
+
}),
|
|
223
|
+
Match.when({ _tag: 'Absent' }, () => {}),
|
|
224
|
+
Match.exhaustive,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
188
228
|
const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
189
229
|
sink.reset()
|
|
190
230
|
yield* renderLevel([{ comp: root, props: {} }])
|
|
@@ -226,18 +266,41 @@ const makeFacade = (
|
|
|
226
266
|
return trigger
|
|
227
267
|
}
|
|
228
268
|
|
|
269
|
+
const propsFor = (name: string, index: number): Effect.Effect<unknown, never, CompositionService> =>
|
|
270
|
+
Effect.map(renderTree, () => capturesFor(name)[index]?.props)
|
|
271
|
+
|
|
229
272
|
const nodeFacade = (name: string, index: number, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
230
|
-
props:
|
|
273
|
+
props: propsFor(name, index),
|
|
274
|
+
// Assert the computed props match a typed subset. Reuses the same partial-match
|
|
275
|
+
// logic as `expect(...).toMatchObject`; throws `AssertionFailed` on mismatch.
|
|
276
|
+
expectProps: (partial) =>
|
|
277
|
+
propsFor(name, index).pipe(
|
|
278
|
+
Effect.flatMap((props) => {
|
|
279
|
+
const mismatch = matchProps(props, partial)
|
|
280
|
+
return mismatch === undefined
|
|
281
|
+
? Effect.void
|
|
282
|
+
: Effect.sync(() => {
|
|
283
|
+
throw new AssertionFailed({ detail: mismatch })
|
|
284
|
+
})
|
|
285
|
+
}),
|
|
286
|
+
),
|
|
231
287
|
// `keyed` resolves event/slot names at runtime; cast the string-keyed proxy
|
|
232
288
|
// to the contract-typed surface. This is the one reflection boundary, the
|
|
233
289
|
// same seam as `definitionClass` — every name the proxy serves is a real
|
|
234
|
-
// contract member, so the cast is sound.
|
|
290
|
+
// contract member, so the cast is sound. The action resolves to the props
|
|
291
|
+
// this node computed once the dispatch settled (the typed "result" of the event).
|
|
235
292
|
actions: keyed(
|
|
236
293
|
(event): Action =>
|
|
237
294
|
(payload) =>
|
|
238
295
|
renderTree.pipe(
|
|
239
|
-
Effect.flatMap(() =>
|
|
296
|
+
Effect.flatMap(() =>
|
|
297
|
+
Effect.sync(() => {
|
|
298
|
+
dispatched.add(event)
|
|
299
|
+
triggerOf(name, index, event)(payload)
|
|
300
|
+
}),
|
|
301
|
+
),
|
|
240
302
|
Effect.flatMap(() => settle),
|
|
303
|
+
Effect.flatMap(() => propsFor(name, index)),
|
|
241
304
|
),
|
|
242
305
|
) as AnyFacade['actions'],
|
|
243
306
|
slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
|
|
@@ -254,14 +317,39 @@ const makeFacade = (
|
|
|
254
317
|
const at = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
|
|
255
318
|
Effect.as(renderTree, nodeFacade(childName, index, child))
|
|
256
319
|
const first = nodeFacade(childName, 0, child)
|
|
320
|
+
// The render index of the child whose structure-fill `key` matches (plan 06).
|
|
321
|
+
// Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
|
|
322
|
+
// selection is by stable identity, not render order. Throws `UnknownSlot` (with
|
|
323
|
+
// the missing key in the slot position) when no fill carries it.
|
|
324
|
+
const indexOfKey = (key: string): number => {
|
|
325
|
+
const index = capturesFor(childName).findIndex((capture) => capture.key === key)
|
|
326
|
+
if (index < 0) {
|
|
327
|
+
throw new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` })
|
|
328
|
+
}
|
|
329
|
+
return index
|
|
330
|
+
}
|
|
257
331
|
return {
|
|
258
332
|
first: at(0),
|
|
259
333
|
at,
|
|
260
334
|
all: Effect.map(renderTree, () =>
|
|
261
335
|
capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
|
|
262
336
|
),
|
|
337
|
+
// Select the one child mounted under `key` (the `each` item key). Re-renders
|
|
338
|
+
// first so the fill keys reflect the latest frame, then resolves its facade.
|
|
339
|
+
byKey: (key) => Effect.map(renderTree, () => nodeFacade(childName, indexOfKey(key), child)),
|
|
340
|
+
// Every child whose computed props satisfy `predicate`. Re-renders, then maps
|
|
341
|
+
// the matching captures back to their facades by render index.
|
|
342
|
+
where: (predicate) =>
|
|
343
|
+
Effect.map(renderTree, () =>
|
|
344
|
+
capturesFor(childName).flatMap((capture, index) =>
|
|
345
|
+
predicate(capture.props) ? [nodeFacade(childName, index, child)] : [],
|
|
346
|
+
),
|
|
347
|
+
),
|
|
348
|
+
// How many children this slot mounted this frame — the fill length.
|
|
349
|
+
count: Effect.map(renderTree, () => capturesFor(childName).length),
|
|
263
350
|
// Used directly, a slot behaves as its first instance.
|
|
264
351
|
props: first.props,
|
|
352
|
+
expectProps: first.expectProps,
|
|
265
353
|
actions: first.actions,
|
|
266
354
|
slots: first.slots,
|
|
267
355
|
frame: first.frame,
|
|
@@ -271,6 +359,13 @@ const makeFacade = (
|
|
|
271
359
|
return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
|
|
272
360
|
}
|
|
273
361
|
|
|
362
|
+
/** Declared-but-never-dispatched events for a proof's requirement (coverage). */
|
|
363
|
+
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> => {
|
|
364
|
+
const declared = proof.requirement.manifest.events
|
|
365
|
+
if (declared === undefined) return []
|
|
366
|
+
return declared.map(String).filter((event) => !dispatched.has(event))
|
|
367
|
+
}
|
|
368
|
+
|
|
274
369
|
/**
|
|
275
370
|
* Run one proof against a fresh runtime, returning its pass/fail result. Each
|
|
276
371
|
* proof gets its own isolated environment, so nothing leaks between proofs. Boots
|
|
@@ -279,14 +374,23 @@ const makeFacade = (
|
|
|
279
374
|
export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
280
375
|
const sink = makeSink()
|
|
281
376
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
377
|
+
const dispatched = new Set<string>()
|
|
282
378
|
try {
|
|
283
|
-
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink)
|
|
379
|
+
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
284
380
|
await runtime.runPromise(
|
|
285
381
|
Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
|
|
286
382
|
Effect.flatMap(() => settle),
|
|
287
383
|
),
|
|
288
384
|
)
|
|
289
385
|
await runtime.runPromise(Effect.gen(() => proof.body(facade)))
|
|
386
|
+
const missing = missingCoverage(proof, dispatched)
|
|
387
|
+
if (missing.length > 0) {
|
|
388
|
+
return {
|
|
389
|
+
requirement: proof.requirement.manifest.statement,
|
|
390
|
+
ok: false,
|
|
391
|
+
error: `requirement declares events never dispatched: ${missing.join(', ')}`,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
290
394
|
return { requirement: proof.requirement.manifest.statement, ok: true }
|
|
291
395
|
} catch (error) {
|
|
292
396
|
return {
|
|
@@ -313,8 +417,11 @@ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
|
313
417
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
314
418
|
const frames: Array<StepFrame> = []
|
|
315
419
|
const statement = proof.requirement.manifest.statement
|
|
420
|
+
// The driver's per-frame timeline does not enforce coverage; collect into a
|
|
421
|
+
// throwaway set so `makeFacade`'s contract is satisfied.
|
|
422
|
+
const dispatched = new Set<string>()
|
|
316
423
|
try {
|
|
317
|
-
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink)
|
|
424
|
+
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
318
425
|
await runtime.runPromise(
|
|
319
426
|
Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
|
|
320
427
|
Effect.flatMap(() => settle),
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,13 @@ export interface RequirementManifest<Comp extends AnyComposition, Statement exte
|
|
|
40
40
|
readonly statement: Statement
|
|
41
41
|
/** The composition this requirement specifies — its contract types the facade. */
|
|
42
42
|
readonly composition: Comp
|
|
43
|
+
/**
|
|
44
|
+
* Optional: the contract events this requirement exercises. Typed to the
|
|
45
|
+
* composition's event names, and verified at run — a proof that never
|
|
46
|
+
* dispatches a declared event fails, so the requirement and its proof can't
|
|
47
|
+
* silently drift (the definition→implementation link past the statement string).
|
|
48
|
+
*/
|
|
49
|
+
readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
@@ -67,9 +74,16 @@ export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
|
67
74
|
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
68
75
|
composition: Comp,
|
|
69
76
|
statement: Statement,
|
|
77
|
+
options?: { readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>> },
|
|
70
78
|
): RequirementClass<Comp, Statement> =>
|
|
71
79
|
Object.assign(class {}, {
|
|
72
|
-
manifest: {
|
|
80
|
+
manifest: {
|
|
81
|
+
kind: 'ProductRequirement' as const,
|
|
82
|
+
name: statement,
|
|
83
|
+
statement,
|
|
84
|
+
composition,
|
|
85
|
+
...(options?.events !== undefined ? { events: options.events } : {}),
|
|
86
|
+
},
|
|
73
87
|
})
|
|
74
88
|
|
|
75
89
|
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
@@ -120,8 +134,39 @@ export const expect = <A>(actual: A) => ({
|
|
|
120
134
|
Array.isArray(actual) && actual.some((item) => deepEqual(item, expected))
|
|
121
135
|
? Effect.void
|
|
122
136
|
: fail(`expected ${JSON.stringify(actual)} to contain ${JSON.stringify(expected)}`),
|
|
137
|
+
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
138
|
+
const mismatch = matchPartial(actual, expected)
|
|
139
|
+
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
140
|
+
},
|
|
123
141
|
})
|
|
124
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Deep-match every key of `expected` against `actual`; returns an error message
|
|
145
|
+
* naming the first failing key, or `undefined` on a full match. Shared by
|
|
146
|
+
* `expect(...).toMatchObject` and the facade's `expectProps`.
|
|
147
|
+
*/
|
|
148
|
+
const matchPartial = (actual: unknown, expected: unknown): string | undefined => {
|
|
149
|
+
if (typeof expected !== 'object' || expected === null) {
|
|
150
|
+
return deepEqual(actual, expected)
|
|
151
|
+
? undefined
|
|
152
|
+
: `expected ${JSON.stringify(actual)} to match ${JSON.stringify(expected)}`
|
|
153
|
+
}
|
|
154
|
+
if (typeof actual !== 'object' || actual === null) {
|
|
155
|
+
return `expected ${JSON.stringify(actual)} to be an object matching ${JSON.stringify(expected)}`
|
|
156
|
+
}
|
|
157
|
+
const actualRecord = actual as Record<string, unknown>
|
|
158
|
+
const expectedRecord = expected as Record<string, unknown>
|
|
159
|
+
for (const key of Object.keys(expectedRecord)) {
|
|
160
|
+
if (!deepEqual(actualRecord[key], expectedRecord[key])) {
|
|
161
|
+
return `expected key ${JSON.stringify(key)} to match ${JSON.stringify(expectedRecord[key])}, got ${JSON.stringify(actualRecord[key])}`
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Internal: the facade's `expectProps` reuses the same partial-match logic. */
|
|
168
|
+
export const matchProps: (actual: unknown, expected: unknown) => string | undefined = matchPartial
|
|
169
|
+
|
|
125
170
|
// ---------------------------------------------------------------------------
|
|
126
171
|
// Facade — a headless view over the live composition tree
|
|
127
172
|
// ---------------------------------------------------------------------------
|
|
@@ -139,19 +184,24 @@ export type ContractOf<Comp> = Comp extends CompositionClass<any, infer C> ? C :
|
|
|
139
184
|
/** The child contract a slot stands for — the contract of the composition it holds. */
|
|
140
185
|
type ContractOfSlot<S> = S extends SlotInstance<infer Comp> ? ContractOf<Comp> : never
|
|
141
186
|
|
|
142
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* The contract's events as facade actions: payload in, dispatch-and-settle Effect
|
|
189
|
+
* out. The Effect resolves to the props this node computed *after* the dispatch
|
|
190
|
+
* settled — the proof analog of chat-tests' `emitToolCall → ToolOutput`: in a
|
|
191
|
+
* fire-and-forget reduce loop the typed "result" of an event is the next state.
|
|
192
|
+
*/
|
|
143
193
|
export type ActionsOf<C extends UiContract> = {
|
|
144
194
|
readonly [K in keyof EventsOf<C>]: (
|
|
145
195
|
payload: PayloadOf<EventsOf<C>[K]>,
|
|
146
|
-
) => Effect.Effect<
|
|
196
|
+
) => Effect.Effect<C['props'], never, CompositionService>
|
|
147
197
|
}
|
|
148
198
|
/** The contract's slots as child facades, each typed by the child's own contract. */
|
|
149
199
|
export type SlotFacadesOf<C extends UiContract> = {
|
|
150
200
|
readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>>
|
|
151
201
|
}
|
|
152
202
|
|
|
153
|
-
/** The Effect a facade action returns: dispatch the event,
|
|
154
|
-
export type Action = (payload: unknown) => Effect.Effect<
|
|
203
|
+
/** The Effect a facade action returns: dispatch the event, settle, read props. */
|
|
204
|
+
export type Action = (payload: unknown) => Effect.Effect<unknown, never, CompositionService>
|
|
155
205
|
|
|
156
206
|
/**
|
|
157
207
|
* The handle a proof drives — the same surface the production UI receives, fully
|
|
@@ -162,6 +212,12 @@ export type Action = (payload: unknown) => Effect.Effect<void, never, Compositio
|
|
|
162
212
|
export interface Facade<C extends UiContract> {
|
|
163
213
|
/** Re-render the tree and read the props this node last computed. */
|
|
164
214
|
readonly props: Effect.Effect<C['props'], never, CompositionService>
|
|
215
|
+
/**
|
|
216
|
+
* Re-render and assert the computed props match `partial` (a subset, deep).
|
|
217
|
+
* Typed from the contract, so a mistyped or unknown prop key is a compile
|
|
218
|
+
* error — unlike reading `props` and comparing a free-form object.
|
|
219
|
+
*/
|
|
220
|
+
readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, CompositionService>
|
|
165
221
|
/** The contract's events as callables; calling one dispatches and settles. */
|
|
166
222
|
readonly actions: ActionsOf<C>
|
|
167
223
|
/** Child composition facades, keyed by slot name. */
|
|
@@ -175,6 +231,22 @@ export interface SlotFacade<C extends UiContract> extends Facade<C> {
|
|
|
175
231
|
readonly first: Effect.Effect<Facade<C>, never, CompositionService>
|
|
176
232
|
readonly at: (index: number) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
177
233
|
readonly all: Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
234
|
+
/**
|
|
235
|
+
* Select the one child mounted under `key` — the `each` item key the structure
|
|
236
|
+
* carried (the SINGLE source of the wire key and the per-item family key, so it
|
|
237
|
+
* can't drift from what the view places). The returned facade is itself typed by
|
|
238
|
+
* the child contract `C`, so `.slots` keeps descending type-safely:
|
|
239
|
+
* `app.slots.List.slots.Item.byKey('todo-1').slots…`. Fails the proof
|
|
240
|
+
* (`UnknownSlot`) when no fill carries the key.
|
|
241
|
+
*/
|
|
242
|
+
readonly byKey: (key: string) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
243
|
+
/** Every child whose computed props satisfy `predicate` — the structure-driven
|
|
244
|
+
* analog of a query, resolved against this frame's fills (props typed by `C`). */
|
|
245
|
+
readonly where: (
|
|
246
|
+
predicate: (props: C['props']) => boolean,
|
|
247
|
+
) => Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
248
|
+
/** How many children this slot mounted this frame (the fill length). */
|
|
249
|
+
readonly count: Effect.Effect<number, never, CompositionService>
|
|
178
250
|
}
|
|
179
251
|
|
|
180
252
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Composition,
|
|
4
|
+
Engine,
|
|
5
|
+
Event,
|
|
6
|
+
mount,
|
|
7
|
+
provide,
|
|
8
|
+
Reducer,
|
|
9
|
+
scene,
|
|
10
|
+
State,
|
|
11
|
+
StateGroup,
|
|
12
|
+
type Trigger,
|
|
13
|
+
Ui,
|
|
14
|
+
ui,
|
|
15
|
+
} from '@playfast/reform'
|
|
16
|
+
import { describe, expect, test } from 'vitest'
|
|
17
|
+
import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
18
|
+
|
|
19
|
+
// Plan 03b — events ride ON the structure. A composition whose `live` body returns
|
|
20
|
+
// `mount({ props, events })` never calls its view, so the event triggers it acquires
|
|
21
|
+
// (`yield* Event.trigger(Bumped)`) are NOT captured by a view's CaptureSink — they
|
|
22
|
+
// travel with the frame on `structure.events`. The proof engine's `driveStructure`
|
|
23
|
+
// records them into the same capture the view path uses, so `app.actions.bump()`
|
|
24
|
+
// resolves and settles identically for a Structure frame. This file is the only
|
|
25
|
+
// runtime exercise of the structure EVENT path; the view path stays covered by
|
|
26
|
+
// typed-seams.test.ts.
|
|
27
|
+
|
|
28
|
+
class CountState extends State.make('scount', S.Number) {}
|
|
29
|
+
class MiniStates extends StateGroup.make(CountState) {}
|
|
30
|
+
class Bumped extends Event.make('SBumped', S.Struct({})) {}
|
|
31
|
+
class BumpReducer extends Reducer.make('SBumpReducer', { states: [CountState], events: [Bumped] }) {}
|
|
32
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
33
|
+
|
|
34
|
+
class CounterUi extends ui('SCounter')<{
|
|
35
|
+
props: { count: number }
|
|
36
|
+
events: { bump: Trigger<Record<string, never>> }
|
|
37
|
+
}>() {}
|
|
38
|
+
class Counter extends Composition.make('SCounter', {
|
|
39
|
+
title: 'SCounter',
|
|
40
|
+
states: [MiniStates],
|
|
41
|
+
events: [Bumped],
|
|
42
|
+
ui: CounterUi,
|
|
43
|
+
}) {}
|
|
44
|
+
// Body returns a STRUCTURE carrying the bump trigger on `events` — the view is never
|
|
45
|
+
// called, so the trigger must ride on the frame for the proof to resolve it.
|
|
46
|
+
const CounterLive = Composition.live(Counter, function* () {
|
|
47
|
+
const count = yield* StateGroup.select(MiniStates, 'scount')
|
|
48
|
+
const bump = yield* Event.trigger(Bumped)
|
|
49
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const presentations = provide(CounterUi, Ui.make(CounterUi, () => null))
|
|
53
|
+
const Views = CounterLive.pipe(Layer.provideMerge(presentations))
|
|
54
|
+
const Logic = BumpReducerLive.pipe(
|
|
55
|
+
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { scount: 5 }))),
|
|
56
|
+
)
|
|
57
|
+
const MiniApp = Views.pipe(Layer.provideMerge(Logic))
|
|
58
|
+
|
|
59
|
+
const CounterScene = scene(Counter, { provide: [MiniApp] })
|
|
60
|
+
|
|
61
|
+
class Bumps extends ProductRequirement.make(Counter, 'bumps the count', { events: ['bump'] }) {}
|
|
62
|
+
class CounterProduct extends Product.make(Counter, { requirements: [Bumps] }) {}
|
|
63
|
+
|
|
64
|
+
describe('events ride on a Structure frame (plan 03b)', () => {
|
|
65
|
+
test('app.actions.x() dispatches and settles for a mount(...)-returning body', async () => {
|
|
66
|
+
const proof = Proof.implement(Bumps, CounterScene, function* (app) {
|
|
67
|
+
yield* proofExpect((yield* app.props).count).toBe(5)
|
|
68
|
+
const next = yield* app.actions.bump({})
|
|
69
|
+
yield* proofExpect(next.count).toBe(6)
|
|
70
|
+
})
|
|
71
|
+
const result = await Proof.run(Proof.suite(CounterProduct, { proofs: [proof] }))
|
|
72
|
+
expect(result.ok).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Composition,
|
|
4
|
+
Engine,
|
|
5
|
+
each,
|
|
6
|
+
mount,
|
|
7
|
+
Props,
|
|
8
|
+
provide,
|
|
9
|
+
scene,
|
|
10
|
+
slot,
|
|
11
|
+
State,
|
|
12
|
+
StateGroup,
|
|
13
|
+
Ui,
|
|
14
|
+
ui,
|
|
15
|
+
} from '@playfast/reform'
|
|
16
|
+
import { describe, expect, test } from 'vitest'
|
|
17
|
+
import { type Facade, Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
18
|
+
|
|
19
|
+
// Plan 06 — typed recursive proof navigation. A list parent returns a STRUCTURE
|
|
20
|
+
// whose `Item` slot is filled with `each`, so each child rides a stable `key` (the
|
|
21
|
+
// SINGLE source of the wire key + family key). The proof facade selects a child by
|
|
22
|
+
// that key (`byKey`), filters by computed props (`where`), and reads the fill length
|
|
23
|
+
// (`count`) — all against the structure tree, no view evaluated. `byKey` returns a
|
|
24
|
+
// facade still typed by the child contract, so `.expectProps` checks the CHILD's
|
|
25
|
+
// props and `.slots` would keep descending type-safely.
|
|
26
|
+
|
|
27
|
+
// A list of todos lives in state; the parent computes its props + the `each` fill.
|
|
28
|
+
class TodosState extends State.make(
|
|
29
|
+
'locTodos',
|
|
30
|
+
S.Array(S.Struct({ id: S.String, done: S.Boolean })),
|
|
31
|
+
) {}
|
|
32
|
+
class TodoStates extends StateGroup.make(TodosState) {}
|
|
33
|
+
|
|
34
|
+
class ItemUi extends ui('LocItem')<{ props: { id: string; done: boolean } }>() {}
|
|
35
|
+
class Item extends Composition.make('LocItem', {
|
|
36
|
+
title: 'LocItem',
|
|
37
|
+
props: S.Struct({ id: S.String, done: S.Boolean }),
|
|
38
|
+
ui: ItemUi,
|
|
39
|
+
}) {}
|
|
40
|
+
class ItemSlot extends slot('LocItem')<typeof Item>() {}
|
|
41
|
+
|
|
42
|
+
class ListUi extends ui('LocList')<{
|
|
43
|
+
props: { total: number }
|
|
44
|
+
slots: { Item: ItemSlot }
|
|
45
|
+
}>() {}
|
|
46
|
+
class List extends Composition.make('LocList', {
|
|
47
|
+
title: 'LocList',
|
|
48
|
+
states: [TodoStates],
|
|
49
|
+
slots: { Item: ItemSlot },
|
|
50
|
+
ui: ListUi,
|
|
51
|
+
}) {}
|
|
52
|
+
|
|
53
|
+
// The parent body returns pure data: its own props, and the `Item` slot filled with
|
|
54
|
+
// one keyed child per todo. No `.map`, no JSX — `each` owns multiplicity + key.
|
|
55
|
+
const ListLive = Composition.live(List, function* () {
|
|
56
|
+
const todos = yield* StateGroup.select(TodoStates, 'locTodos')
|
|
57
|
+
return mount({
|
|
58
|
+
props: { total: todos.length },
|
|
59
|
+
slots: {
|
|
60
|
+
Item: each(todos, { key: (t) => t.id, props: (t) => ({ id: t.id, done: t.done }) }),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
// A leaf child in structure-as-data echoes the props its parent's `each` fed it
|
|
65
|
+
// (read via the `Props` service), so the recorded capture carries the per-item
|
|
66
|
+
// props `byKey`/`where`/`expectProps` assert against. No view, hook-free.
|
|
67
|
+
const ItemLive = Composition.live(Item, function* () {
|
|
68
|
+
const props: { id: string; done: boolean } = yield* Props
|
|
69
|
+
return mount({ props, slots: {} })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const presentations = Layer.mergeAll(
|
|
73
|
+
provide(ListUi, Ui.make(ListUi, () => null)),
|
|
74
|
+
provide(ItemUi, Ui.make(ItemUi, () => null)),
|
|
75
|
+
)
|
|
76
|
+
const wiring = provide(ItemSlot, Item)
|
|
77
|
+
const Views = Layer.mergeAll(ListLive, ItemLive, wiring).pipe(Layer.provideMerge(presentations))
|
|
78
|
+
const Logic = Layer.mergeAll(
|
|
79
|
+
Engine,
|
|
80
|
+
StateGroup.live(TodoStates, {
|
|
81
|
+
locTodos: [
|
|
82
|
+
{ id: 'todo-1', done: false },
|
|
83
|
+
{ id: 'todo-2', done: true },
|
|
84
|
+
{ id: 'todo-3', done: false },
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
const ListApp = Views.pipe(Layer.provideMerge(Logic))
|
|
89
|
+
const ListScene = scene(List, { provide: [ListApp] })
|
|
90
|
+
|
|
91
|
+
class Locates extends ProductRequirement.make(List, 'locates list children by key/props') {}
|
|
92
|
+
class ListProduct extends Product.make(List, { requirements: [Locates] }) {}
|
|
93
|
+
|
|
94
|
+
describe('typed recursive proof navigation (plan 06)', () => {
|
|
95
|
+
test('byKey / where / count resolve children from the structure tree', async () => {
|
|
96
|
+
const proof = Proof.implement(Locates, ListScene, function* (app) {
|
|
97
|
+
// The parent's computed props.
|
|
98
|
+
yield* proofExpect((yield* app.props).total).toBe(3)
|
|
99
|
+
// count = the fill length.
|
|
100
|
+
yield* proofExpect(yield* app.slots.Item.count).toBe(3)
|
|
101
|
+
// byKey selects the one child mounted under that `each` key; the facade is
|
|
102
|
+
// typed by the CHILD contract, so `expectProps` checks the child's props.
|
|
103
|
+
const second = yield* app.slots.Item.byKey('todo-2')
|
|
104
|
+
yield* second.expectProps({ id: 'todo-2', done: true })
|
|
105
|
+
const first = yield* app.slots.Item.byKey('todo-1')
|
|
106
|
+
yield* first.expectProps({ id: 'todo-1', done: false })
|
|
107
|
+
// where filters children by their computed props (predicate typed by the
|
|
108
|
+
// CHILD contract — `p.done` is known).
|
|
109
|
+
const doneItems = yield* app.slots.Item.where((p) => p.done)
|
|
110
|
+
yield* proofExpect(doneItems.length).toBe(1)
|
|
111
|
+
yield* doneItems[0]!.expectProps({ id: 'todo-2' })
|
|
112
|
+
})
|
|
113
|
+
const result = await Proof.run(Proof.suite(ListProduct, { proofs: [proof] }))
|
|
114
|
+
expect(result.ok).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('byKey on a missing key fails the proof', async () => {
|
|
118
|
+
const proof = Proof.implement(Locates, ListScene, function* (app) {
|
|
119
|
+
yield* app.slots.Item.byKey('nope')
|
|
120
|
+
})
|
|
121
|
+
const result = await Proof.run(Proof.suite(ListProduct, { proofs: [proof] }))
|
|
122
|
+
expect(result.ok).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Type-only: the `byKey`-selected facade is typed by the Item CHILD contract
|
|
127
|
+
// (`{ id, done }`) — recursive typed selection. A wrong prop key in `expectProps`
|
|
128
|
+
// is a compile error. Non-exported, never called; exists only to be typechecked.
|
|
129
|
+
const _negativeTypeCheck = (app: Facade<Ui.Contract<typeof ListUi>>): void => {
|
|
130
|
+
Proof.implement(Locates, ListScene, function* () {
|
|
131
|
+
const child = yield* app.slots.Item.byKey('todo-1')
|
|
132
|
+
yield* child.expectProps({ id: 'x', done: true })
|
|
133
|
+
// @ts-expect-error 'nope' is not a key of the Item child contract props
|
|
134
|
+
yield* child.expectProps({ nope: 1 })
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
void _negativeTypeCheck
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Composition,
|
|
4
|
+
Engine,
|
|
5
|
+
Event,
|
|
6
|
+
mount,
|
|
7
|
+
one,
|
|
8
|
+
provide,
|
|
9
|
+
Reducer,
|
|
10
|
+
scene,
|
|
11
|
+
seedScene,
|
|
12
|
+
slot,
|
|
13
|
+
State,
|
|
14
|
+
StateGroup,
|
|
15
|
+
type Trigger,
|
|
16
|
+
ui,
|
|
17
|
+
} from '@playfast/reform'
|
|
18
|
+
import { describe, expect, test } from 'vitest'
|
|
19
|
+
import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
20
|
+
|
|
21
|
+
// Exercises the four type-safety seams closed in this change, each against a
|
|
22
|
+
// self-contained mini Counter (the same fixture shape as the stepper test):
|
|
23
|
+
// #1 typed seeds — seedScene is keyed/valued by the state tuple
|
|
24
|
+
// #2 action results — an action resolves to the settled next props
|
|
25
|
+
// #4 typed assertions — app.expectProps is a contract-typed subset match
|
|
26
|
+
// #5 event coverage — a requirement's declared events must be dispatched
|
|
27
|
+
// Negative cases are compile-time (`@ts-expect-error`) — the whole point is that
|
|
28
|
+
// the bad input never reaches runtime.
|
|
29
|
+
|
|
30
|
+
class CountState extends State.make('count', S.Number) {}
|
|
31
|
+
class MiniStates extends StateGroup.make(CountState) {}
|
|
32
|
+
class Bumped extends Event.make('Bumped', S.Struct({})) {}
|
|
33
|
+
class BumpReducer extends Reducer.make('BumpReducer', { states: [CountState], events: [Bumped] }) {}
|
|
34
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
35
|
+
|
|
36
|
+
// A trivial slot child: a proof runtime resolves slot-bound compositions, so the
|
|
37
|
+
// app needs at least one slot for the suite's provide layer to type-close.
|
|
38
|
+
class LabelUi extends ui('Label')<{ props: { text: string } }>() {}
|
|
39
|
+
class Label extends Composition.make('Label', { title: 'Label', ui: LabelUi }) {}
|
|
40
|
+
const LabelLive = Composition.live(Label, function* () {
|
|
41
|
+
return mount({ props: { text: 'count' }, slots: {} })
|
|
42
|
+
})
|
|
43
|
+
class LabelSlot extends slot('Label')<typeof Label>() {}
|
|
44
|
+
|
|
45
|
+
class CounterUi extends ui('Counter')<{
|
|
46
|
+
props: { count: number }
|
|
47
|
+
slots: { Label: LabelSlot }
|
|
48
|
+
events: { bump: Trigger<Record<string, never>> }
|
|
49
|
+
}>() {}
|
|
50
|
+
class Counter extends Composition.make('Counter', {
|
|
51
|
+
title: 'Counter',
|
|
52
|
+
states: [MiniStates],
|
|
53
|
+
events: [Bumped],
|
|
54
|
+
slots: { Label: LabelSlot },
|
|
55
|
+
ui: CounterUi,
|
|
56
|
+
}) {}
|
|
57
|
+
const CounterLive = Composition.live(Counter, function* () {
|
|
58
|
+
const count = yield* StateGroup.select(MiniStates, 'count')
|
|
59
|
+
const bump = yield* Event.trigger(Bumped)
|
|
60
|
+
return mount({
|
|
61
|
+
props: { count },
|
|
62
|
+
slots: { Label: one({}) },
|
|
63
|
+
events: { bump },
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const presentations = provide(LabelSlot, Label)
|
|
68
|
+
const Views = Layer.mergeAll(CounterLive, LabelLive).pipe(Layer.provideMerge(presentations))
|
|
69
|
+
const Logic = Layer.mergeAll(BumpReducerLive).pipe(
|
|
70
|
+
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 5 }))),
|
|
71
|
+
)
|
|
72
|
+
const MiniApp = Views.pipe(Layer.provideMerge(Logic))
|
|
73
|
+
|
|
74
|
+
const CounterScene = scene(Counter, { provide: [MiniApp] })
|
|
75
|
+
|
|
76
|
+
class Bumps extends ProductRequirement.make(Counter, 'bumps the count') {}
|
|
77
|
+
class CounterProduct extends Product.make(Counter, { requirements: [Bumps] }) {}
|
|
78
|
+
|
|
79
|
+
describe('#1 typed seeds', () => {
|
|
80
|
+
test('seedScene overrides the authored seed, typed by the state tuple', async () => {
|
|
81
|
+
const seeded = seedScene(CounterScene, { count: 9 })
|
|
82
|
+
const boots = Proof.implement(Bumps, seeded, function* (app) {
|
|
83
|
+
const props = yield* app.props
|
|
84
|
+
yield* proofExpect(props.count).toBe(9)
|
|
85
|
+
})
|
|
86
|
+
const result = await Proof.run(Proof.suite(CounterProduct, { proofs: [boots] }))
|
|
87
|
+
expect(result.ok).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('an unknown key or mistyped value is a compile error', () => {
|
|
91
|
+
// @ts-expect-error 'other' is not a state member of Counter
|
|
92
|
+
seedScene(CounterScene, { other: 1 })
|
|
93
|
+
// @ts-expect-error 'count' is a number, not a string
|
|
94
|
+
seedScene(CounterScene, { count: 'nope' })
|
|
95
|
+
expect(true).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('#2 actions return post-settle props', () => {
|
|
100
|
+
test('dispatching an event resolves to the settled next props', async () => {
|
|
101
|
+
const bumps = Proof.implement(Bumps, CounterScene, function* (app) {
|
|
102
|
+
const props = yield* app.actions.bump({})
|
|
103
|
+
yield* proofExpect(props.count).toBe(6)
|
|
104
|
+
})
|
|
105
|
+
const result = await Proof.run(Proof.suite(CounterProduct, { proofs: [bumps] }))
|
|
106
|
+
expect(result.ok).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('#4 contract-typed prop assertions', () => {
|
|
111
|
+
test('expectProps matches a typed subset of the computed props', async () => {
|
|
112
|
+
const matches = Proof.implement(Bumps, CounterScene, function* (app) {
|
|
113
|
+
yield* app.expectProps({ count: 5 })
|
|
114
|
+
yield* app.actions.bump({})
|
|
115
|
+
yield* app.expectProps({ count: 6 })
|
|
116
|
+
})
|
|
117
|
+
const result = await Proof.run(Proof.suite(CounterProduct, { proofs: [matches] }))
|
|
118
|
+
expect(result.ok).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a non-matching expectProps fails the proof', async () => {
|
|
122
|
+
const wrong = Proof.implement(Bumps, CounterScene, function* (app) {
|
|
123
|
+
yield* app.expectProps({ count: 999 })
|
|
124
|
+
})
|
|
125
|
+
const result = await Proof.run(Proof.suite(CounterProduct, { proofs: [wrong] }))
|
|
126
|
+
expect(result.ok).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('a typo’d prop key is a compile error', () => {
|
|
130
|
+
Proof.implement(Bumps, CounterScene, function* (app) {
|
|
131
|
+
// @ts-expect-error 'cuont' is not a prop of Counter
|
|
132
|
+
yield* app.expectProps({ cuont: 5 })
|
|
133
|
+
})
|
|
134
|
+
expect(true).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('#5 requirement-bound event coverage', () => {
|
|
139
|
+
class CoveredBump extends ProductRequirement.make(Counter, 'bumps via the bump event', {
|
|
140
|
+
events: ['bump'],
|
|
141
|
+
}) {}
|
|
142
|
+
class CoveredProduct extends Product.make(Counter, { requirements: [CoveredBump] }) {}
|
|
143
|
+
|
|
144
|
+
test('passes when the declared event is dispatched', async () => {
|
|
145
|
+
const proof = Proof.implement(CoveredBump, CounterScene, function* (app) {
|
|
146
|
+
yield* app.actions.bump({})
|
|
147
|
+
})
|
|
148
|
+
const result = await Proof.run(Proof.suite(CoveredProduct, { proofs: [proof] }))
|
|
149
|
+
expect(result.ok).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('fails, naming the event, when a declared event is never dispatched', async () => {
|
|
153
|
+
const proof = Proof.implement(CoveredBump, CounterScene, function* (app) {
|
|
154
|
+
yield* app.props
|
|
155
|
+
})
|
|
156
|
+
const result = await Proof.run(Proof.suite(CoveredProduct, { proofs: [proof] }))
|
|
157
|
+
expect(result.ok).toBe(false)
|
|
158
|
+
expect(result.results[0]?.error).toContain('bump')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('an event name outside the contract is a compile error', () => {
|
|
162
|
+
// @ts-expect-error 'nope' is not an event of Counter
|
|
163
|
+
ProductRequirement.make(Counter, 'invalid coverage', { events: ['nope'] })
|
|
164
|
+
expect(true).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
})
|