@playfast/reform-proof 0.0.8 → 0.0.10
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 +169 -60
- package/src/index.ts +83 -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.10",
|
|
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,13 +59,23 @@ 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
|
|
|
63
|
-
|
|
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
|
+
|
|
70
|
+
// Engine SPI (Sink/makeSink/RuntimeServices/proofLayer/makeFacade) — exported for
|
|
71
|
+
// @playfast/reform-drive, which builds the no-ceremony scene driver on the same core.
|
|
72
|
+
export interface Sink {
|
|
64
73
|
readonly api: CaptureSinkApi
|
|
65
74
|
readonly captures: ReadonlyArray<UiCapture>
|
|
66
75
|
reset(): void
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
const makeSink = (): Sink => {
|
|
78
|
+
export const makeSink = (): Sink => {
|
|
70
79
|
// A const holder whose array we swap on reset (no reassigned binding).
|
|
71
80
|
const state: { captures: UiCapture[] } = { captures: [] }
|
|
72
81
|
return {
|
|
@@ -80,7 +89,7 @@ const makeSink = (): Sink => {
|
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
type RuntimeServices = CompositionService | SlotChild | Bus
|
|
92
|
+
export type RuntimeServices = CompositionService | SlotChild | Bus
|
|
84
93
|
|
|
85
94
|
/**
|
|
86
95
|
* Build a proof's runtime layer: the scene's closed wiring with the capture sink
|
|
@@ -92,45 +101,29 @@ type RuntimeServices = CompositionService | SlotChild | Bus
|
|
|
92
101
|
* is the one erasure boundary, the same seam the react host crosses when it reads
|
|
93
102
|
* a slot tag with the requirement erased.
|
|
94
103
|
*/
|
|
95
|
-
const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
|
|
104
|
+
export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
|
|
96
105
|
const sceneLayer = scene.provide.reduce((a, b) =>
|
|
97
106
|
Layer.merge(a, b),
|
|
98
107
|
) as unknown as Layer.Layer<RuntimeServices, never, never>
|
|
99
108
|
return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
/** A composition queued to render, with the props its parent handed it.
|
|
111
|
+
/** A composition queued to render, with the props its parent handed it. On the
|
|
112
|
+
* structure path it also carries the slot-fill `key` it was mounted under, so the
|
|
113
|
+
* facade can select a child by key (`byKey`); the legacy Node path omits it. */
|
|
103
114
|
interface Mounted {
|
|
104
115
|
readonly comp: CompositionClass<unknown>
|
|
105
116
|
readonly props: unknown
|
|
117
|
+
readonly key?: string
|
|
106
118
|
}
|
|
107
119
|
|
|
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)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const makeFacade = (
|
|
120
|
+
export const makeFacade = (
|
|
131
121
|
runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
|
|
132
122
|
root: CompositionClass<unknown>,
|
|
133
123
|
sink: Sink,
|
|
124
|
+
// Records every event name dispatched through the facade, so a proof's
|
|
125
|
+
// requirement-declared event coverage can be verified after the body runs.
|
|
126
|
+
dispatched: Set<string>,
|
|
134
127
|
): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
|
|
135
128
|
// Slot bindings (`provide(slot, composition)`) are static, so resolve the
|
|
136
129
|
// whole child tree once, up front — keeping `renderTree` and slot navigation
|
|
@@ -164,27 +157,76 @@ const makeFacade = (
|
|
|
164
157
|
Effect.gen(function* () {
|
|
165
158
|
if (frontier.length === 0) return
|
|
166
159
|
const next: Array<Mounted> = []
|
|
167
|
-
for (const { comp, props } of frontier) {
|
|
160
|
+
for (const { comp, props, key } of frontier) {
|
|
168
161
|
const service = yield* comp.tag
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return
|
|
179
|
-
|
|
162
|
+
const env: RenderEnv = { props, tracker: { add: () => {} } }
|
|
163
|
+
const frame = yield* Composition.render(service, env)
|
|
164
|
+
// Every composition returns a `Structure` (no view is ever executed — the
|
|
165
|
+
// proof is view-free / React-free): read its computed props straight off the
|
|
166
|
+
// value, record them + the events it carries into the sink (so the facade,
|
|
167
|
+
// `triggerOf`, and navigation work uniformly), and walk its slot FILLS to
|
|
168
|
+
// enqueue children.
|
|
169
|
+
if (!isStructure(frame)) {
|
|
170
|
+
return yield* Effect.dieMessage(
|
|
171
|
+
`reform-proof: composition ${uiNameOf(comp)} did not return a Structure`,
|
|
172
|
+
)
|
|
180
173
|
}
|
|
181
|
-
|
|
182
|
-
const node = yield* Composition.render(service, env)
|
|
183
|
-
drive(node, enqueueBy)
|
|
174
|
+
driveStructure(comp, frame, next, key)
|
|
184
175
|
}
|
|
185
176
|
yield* renderLevel(next)
|
|
186
177
|
})
|
|
187
178
|
|
|
179
|
+
// Consume a `Structure` frame: record the node's computed props (the structure
|
|
180
|
+
// value already holds them — the proof never derives them from a view) AND the
|
|
181
|
+
// event triggers it carries on `structure.events` (plan 03b), then enqueue one
|
|
182
|
+
// child per slot fill with the per-item props the fill carries. Recording the
|
|
183
|
+
// events into the capture the same way the view path does means `triggerOf` /
|
|
184
|
+
// `app.actions` resolve identically for Structure frames; the index-keyed enqueue
|
|
185
|
+
// order matches the legacy node walk.
|
|
186
|
+
const driveStructure = (
|
|
187
|
+
comp: CompositionClass<unknown>,
|
|
188
|
+
structure: Structure<UiContract>,
|
|
189
|
+
next: Array<Mounted>,
|
|
190
|
+
key: string | undefined,
|
|
191
|
+
): void => {
|
|
192
|
+
sink.api.record({
|
|
193
|
+
name: uiNameOf(comp),
|
|
194
|
+
props: structure.props,
|
|
195
|
+
events: eventsOf(structure),
|
|
196
|
+
...(key !== undefined ? { key } : {}),
|
|
197
|
+
})
|
|
198
|
+
const fills: Record<string, SlotFill<unknown>> = structure.slots
|
|
199
|
+
for (const [slotName, fill] of Object.entries(fills)) {
|
|
200
|
+
const slotClass = comp.manifest.slots?.[slotName]
|
|
201
|
+
const child = slotClass && bindings.get(slotClass)
|
|
202
|
+
if (child === undefined) continue
|
|
203
|
+
enqueueFill(slotName, fill, child, next)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Expand one slot fill into the children to render next. `Each` mounts one
|
|
208
|
+
// child per keyed item with that item's props and key; `One` mounts a single
|
|
209
|
+
// child under a synthesized singleton key; `Absent` mounts nothing (the
|
|
210
|
+
// data-driven `cond && …`). The key rides each `Mounted` so the child's capture
|
|
211
|
+
// can record it, letting the facade select by key (`byKey`).
|
|
212
|
+
const enqueueFill = (
|
|
213
|
+
slotName: string,
|
|
214
|
+
fill: SlotFill<unknown>,
|
|
215
|
+
child: CompositionClass<unknown>,
|
|
216
|
+
next: Array<Mounted>,
|
|
217
|
+
): void => {
|
|
218
|
+
Match.value(fill).pipe(
|
|
219
|
+
Match.when({ _tag: 'Each' }, (each) => {
|
|
220
|
+
for (const item of each.items) next.push({ comp: child, props: item.props, key: item.key })
|
|
221
|
+
}),
|
|
222
|
+
Match.when({ _tag: 'One' }, (oneFill) => {
|
|
223
|
+
next.push({ comp: child, props: oneFill.props, key: `${slotName}.0` })
|
|
224
|
+
}),
|
|
225
|
+
Match.when({ _tag: 'Absent' }, () => {}),
|
|
226
|
+
Match.exhaustive,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
188
230
|
const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
189
231
|
sink.reset()
|
|
190
232
|
yield* renderLevel([{ comp: root, props: {} }])
|
|
@@ -226,18 +268,41 @@ const makeFacade = (
|
|
|
226
268
|
return trigger
|
|
227
269
|
}
|
|
228
270
|
|
|
271
|
+
const propsFor = (name: string, index: number): Effect.Effect<unknown, never, CompositionService> =>
|
|
272
|
+
Effect.map(renderTree, () => capturesFor(name)[index]?.props)
|
|
273
|
+
|
|
229
274
|
const nodeFacade = (name: string, index: number, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
230
|
-
props:
|
|
275
|
+
props: propsFor(name, index),
|
|
276
|
+
// Assert the computed props match a typed subset. Reuses the same partial-match
|
|
277
|
+
// logic as `expect(...).toMatchObject`; throws `AssertionFailed` on mismatch.
|
|
278
|
+
expectProps: (partial) =>
|
|
279
|
+
propsFor(name, index).pipe(
|
|
280
|
+
Effect.flatMap((props) => {
|
|
281
|
+
const mismatch = matchProps(props, partial)
|
|
282
|
+
return mismatch === undefined
|
|
283
|
+
? Effect.void
|
|
284
|
+
: Effect.sync(() => {
|
|
285
|
+
throw new AssertionFailed({ detail: mismatch })
|
|
286
|
+
})
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
231
289
|
// `keyed` resolves event/slot names at runtime; cast the string-keyed proxy
|
|
232
290
|
// to the contract-typed surface. This is the one reflection boundary, the
|
|
233
291
|
// same seam as `definitionClass` — every name the proxy serves is a real
|
|
234
|
-
// contract member, so the cast is sound.
|
|
292
|
+
// contract member, so the cast is sound. The action resolves to the props
|
|
293
|
+
// this node computed once the dispatch settled (the typed "result" of the event).
|
|
235
294
|
actions: keyed(
|
|
236
295
|
(event): Action =>
|
|
237
296
|
(payload) =>
|
|
238
297
|
renderTree.pipe(
|
|
239
|
-
Effect.flatMap(() =>
|
|
298
|
+
Effect.flatMap(() =>
|
|
299
|
+
Effect.sync(() => {
|
|
300
|
+
dispatched.add(event)
|
|
301
|
+
triggerOf(name, index, event)(payload)
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
240
304
|
Effect.flatMap(() => settle),
|
|
305
|
+
Effect.flatMap(() => propsFor(name, index)),
|
|
241
306
|
),
|
|
242
307
|
) as AnyFacade['actions'],
|
|
243
308
|
slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
|
|
@@ -254,14 +319,39 @@ const makeFacade = (
|
|
|
254
319
|
const at = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
|
|
255
320
|
Effect.as(renderTree, nodeFacade(childName, index, child))
|
|
256
321
|
const first = nodeFacade(childName, 0, child)
|
|
322
|
+
// The render index of the child whose structure-fill `key` matches (plan 06).
|
|
323
|
+
// Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
|
|
324
|
+
// selection is by stable identity, not render order. Throws `UnknownSlot` (with
|
|
325
|
+
// the missing key in the slot position) when no fill carries it.
|
|
326
|
+
const indexOfKey = (key: string): number => {
|
|
327
|
+
const index = capturesFor(childName).findIndex((capture) => capture.key === key)
|
|
328
|
+
if (index < 0) {
|
|
329
|
+
throw new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` })
|
|
330
|
+
}
|
|
331
|
+
return index
|
|
332
|
+
}
|
|
257
333
|
return {
|
|
258
334
|
first: at(0),
|
|
259
335
|
at,
|
|
260
336
|
all: Effect.map(renderTree, () =>
|
|
261
337
|
capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
|
|
262
338
|
),
|
|
339
|
+
// Select the one child mounted under `key` (the `each` item key). Re-renders
|
|
340
|
+
// first so the fill keys reflect the latest frame, then resolves its facade.
|
|
341
|
+
byKey: (key) => Effect.map(renderTree, () => nodeFacade(childName, indexOfKey(key), child)),
|
|
342
|
+
// Every child whose computed props satisfy `predicate`. Re-renders, then maps
|
|
343
|
+
// the matching captures back to their facades by render index.
|
|
344
|
+
where: (predicate) =>
|
|
345
|
+
Effect.map(renderTree, () =>
|
|
346
|
+
capturesFor(childName).flatMap((capture, index) =>
|
|
347
|
+
predicate(capture.props) ? [nodeFacade(childName, index, child)] : [],
|
|
348
|
+
),
|
|
349
|
+
),
|
|
350
|
+
// How many children this slot mounted this frame — the fill length.
|
|
351
|
+
count: Effect.map(renderTree, () => capturesFor(childName).length),
|
|
263
352
|
// Used directly, a slot behaves as its first instance.
|
|
264
353
|
props: first.props,
|
|
354
|
+
expectProps: first.expectProps,
|
|
265
355
|
actions: first.actions,
|
|
266
356
|
slots: first.slots,
|
|
267
357
|
frame: first.frame,
|
|
@@ -271,6 +361,13 @@ const makeFacade = (
|
|
|
271
361
|
return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
|
|
272
362
|
}
|
|
273
363
|
|
|
364
|
+
/** Declared-but-never-dispatched events for a proof's requirement (coverage). */
|
|
365
|
+
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> => {
|
|
366
|
+
const declared = proof.requirement.manifest.events
|
|
367
|
+
if (declared === undefined) return []
|
|
368
|
+
return declared.map(String).filter((event) => !dispatched.has(event))
|
|
369
|
+
}
|
|
370
|
+
|
|
274
371
|
/**
|
|
275
372
|
* Run one proof against a fresh runtime, returning its pass/fail result. Each
|
|
276
373
|
* proof gets its own isolated environment, so nothing leaks between proofs. Boots
|
|
@@ -279,14 +376,23 @@ const makeFacade = (
|
|
|
279
376
|
export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
280
377
|
const sink = makeSink()
|
|
281
378
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
379
|
+
const dispatched = new Set<string>()
|
|
282
380
|
try {
|
|
283
|
-
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink)
|
|
381
|
+
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
284
382
|
await runtime.runPromise(
|
|
285
383
|
Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
|
|
286
384
|
Effect.flatMap(() => settle),
|
|
287
385
|
),
|
|
288
386
|
)
|
|
289
387
|
await runtime.runPromise(Effect.gen(() => proof.body(facade)))
|
|
388
|
+
const missing = missingCoverage(proof, dispatched)
|
|
389
|
+
if (missing.length > 0) {
|
|
390
|
+
return {
|
|
391
|
+
requirement: proof.requirement.manifest.statement,
|
|
392
|
+
ok: false,
|
|
393
|
+
error: `requirement declares events never dispatched: ${missing.join(', ')}`,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
290
396
|
return { requirement: proof.requirement.manifest.statement, ok: true }
|
|
291
397
|
} catch (error) {
|
|
292
398
|
return {
|
|
@@ -313,8 +419,11 @@ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
|
313
419
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
314
420
|
const frames: Array<StepFrame> = []
|
|
315
421
|
const statement = proof.requirement.manifest.statement
|
|
422
|
+
// The driver's per-frame timeline does not enforce coverage; collect into a
|
|
423
|
+
// throwaway set so `makeFacade`'s contract is satisfied.
|
|
424
|
+
const dispatched = new Set<string>()
|
|
316
425
|
try {
|
|
317
|
-
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink)
|
|
426
|
+
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
318
427
|
await runtime.runPromise(
|
|
319
428
|
Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
|
|
320
429
|
Effect.flatMap(() => settle),
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,12 @@ import type {
|
|
|
26
26
|
export { ProofRunner, proofRunnerLayer, withProofRunner }
|
|
27
27
|
export type { ProofRunnerApi }
|
|
28
28
|
|
|
29
|
+
// Engine SPI consumed by @playfast/reform-drive — the no-ceremony scene driver builds
|
|
30
|
+
// on the same facade engine. Re-exported from the package index (not the engine
|
|
31
|
+
// subpath) so consumers load the package in its normal init order.
|
|
32
|
+
export { makeFacade, makeSink, proofLayer } from './engine'
|
|
33
|
+
export type { RuntimeServices, Sink } from './engine'
|
|
34
|
+
|
|
29
35
|
// ---------------------------------------------------------------------------
|
|
30
36
|
// Definitions: ProductRequirement + Product
|
|
31
37
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +46,13 @@ export interface RequirementManifest<Comp extends AnyComposition, Statement exte
|
|
|
40
46
|
readonly statement: Statement
|
|
41
47
|
/** The composition this requirement specifies — its contract types the facade. */
|
|
42
48
|
readonly composition: Comp
|
|
49
|
+
/**
|
|
50
|
+
* Optional: the contract events this requirement exercises. Typed to the
|
|
51
|
+
* composition's event names, and verified at run — a proof that never
|
|
52
|
+
* dispatches a declared event fails, so the requirement and its proof can't
|
|
53
|
+
* silently drift (the definition→implementation link past the statement string).
|
|
54
|
+
*/
|
|
55
|
+
readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
@@ -67,9 +80,16 @@ export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
|
67
80
|
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
68
81
|
composition: Comp,
|
|
69
82
|
statement: Statement,
|
|
83
|
+
options?: { readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>> },
|
|
70
84
|
): RequirementClass<Comp, Statement> =>
|
|
71
85
|
Object.assign(class {}, {
|
|
72
|
-
manifest: {
|
|
86
|
+
manifest: {
|
|
87
|
+
kind: 'ProductRequirement' as const,
|
|
88
|
+
name: statement,
|
|
89
|
+
statement,
|
|
90
|
+
composition,
|
|
91
|
+
...(options?.events !== undefined ? { events: options.events } : {}),
|
|
92
|
+
},
|
|
73
93
|
})
|
|
74
94
|
|
|
75
95
|
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
@@ -120,8 +140,39 @@ export const expect = <A>(actual: A) => ({
|
|
|
120
140
|
Array.isArray(actual) && actual.some((item) => deepEqual(item, expected))
|
|
121
141
|
? Effect.void
|
|
122
142
|
: fail(`expected ${JSON.stringify(actual)} to contain ${JSON.stringify(expected)}`),
|
|
143
|
+
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
144
|
+
const mismatch = matchPartial(actual, expected)
|
|
145
|
+
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
146
|
+
},
|
|
123
147
|
})
|
|
124
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Deep-match every key of `expected` against `actual`; returns an error message
|
|
151
|
+
* naming the first failing key, or `undefined` on a full match. Shared by
|
|
152
|
+
* `expect(...).toMatchObject` and the facade's `expectProps`.
|
|
153
|
+
*/
|
|
154
|
+
const matchPartial = (actual: unknown, expected: unknown): string | undefined => {
|
|
155
|
+
if (typeof expected !== 'object' || expected === null) {
|
|
156
|
+
return deepEqual(actual, expected)
|
|
157
|
+
? undefined
|
|
158
|
+
: `expected ${JSON.stringify(actual)} to match ${JSON.stringify(expected)}`
|
|
159
|
+
}
|
|
160
|
+
if (typeof actual !== 'object' || actual === null) {
|
|
161
|
+
return `expected ${JSON.stringify(actual)} to be an object matching ${JSON.stringify(expected)}`
|
|
162
|
+
}
|
|
163
|
+
const actualRecord = actual as Record<string, unknown>
|
|
164
|
+
const expectedRecord = expected as Record<string, unknown>
|
|
165
|
+
for (const key of Object.keys(expectedRecord)) {
|
|
166
|
+
if (!deepEqual(actualRecord[key], expectedRecord[key])) {
|
|
167
|
+
return `expected key ${JSON.stringify(key)} to match ${JSON.stringify(expectedRecord[key])}, got ${JSON.stringify(actualRecord[key])}`
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Internal: the facade's `expectProps` reuses the same partial-match logic. */
|
|
174
|
+
export const matchProps: (actual: unknown, expected: unknown) => string | undefined = matchPartial
|
|
175
|
+
|
|
125
176
|
// ---------------------------------------------------------------------------
|
|
126
177
|
// Facade — a headless view over the live composition tree
|
|
127
178
|
// ---------------------------------------------------------------------------
|
|
@@ -139,19 +190,24 @@ export type ContractOf<Comp> = Comp extends CompositionClass<any, infer C> ? C :
|
|
|
139
190
|
/** The child contract a slot stands for — the contract of the composition it holds. */
|
|
140
191
|
type ContractOfSlot<S> = S extends SlotInstance<infer Comp> ? ContractOf<Comp> : never
|
|
141
192
|
|
|
142
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* The contract's events as facade actions: payload in, dispatch-and-settle Effect
|
|
195
|
+
* out. The Effect resolves to the props this node computed *after* the dispatch
|
|
196
|
+
* settled — the proof analog of chat-tests' `emitToolCall → ToolOutput`: in a
|
|
197
|
+
* fire-and-forget reduce loop the typed "result" of an event is the next state.
|
|
198
|
+
*/
|
|
143
199
|
export type ActionsOf<C extends UiContract> = {
|
|
144
200
|
readonly [K in keyof EventsOf<C>]: (
|
|
145
201
|
payload: PayloadOf<EventsOf<C>[K]>,
|
|
146
|
-
) => Effect.Effect<
|
|
202
|
+
) => Effect.Effect<C['props'], never, CompositionService>
|
|
147
203
|
}
|
|
148
204
|
/** The contract's slots as child facades, each typed by the child's own contract. */
|
|
149
205
|
export type SlotFacadesOf<C extends UiContract> = {
|
|
150
206
|
readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>>
|
|
151
207
|
}
|
|
152
208
|
|
|
153
|
-
/** The Effect a facade action returns: dispatch the event,
|
|
154
|
-
export type Action = (payload: unknown) => Effect.Effect<
|
|
209
|
+
/** The Effect a facade action returns: dispatch the event, settle, read props. */
|
|
210
|
+
export type Action = (payload: unknown) => Effect.Effect<unknown, never, CompositionService>
|
|
155
211
|
|
|
156
212
|
/**
|
|
157
213
|
* The handle a proof drives — the same surface the production UI receives, fully
|
|
@@ -162,6 +218,12 @@ export type Action = (payload: unknown) => Effect.Effect<void, never, Compositio
|
|
|
162
218
|
export interface Facade<C extends UiContract> {
|
|
163
219
|
/** Re-render the tree and read the props this node last computed. */
|
|
164
220
|
readonly props: Effect.Effect<C['props'], never, CompositionService>
|
|
221
|
+
/**
|
|
222
|
+
* Re-render and assert the computed props match `partial` (a subset, deep).
|
|
223
|
+
* Typed from the contract, so a mistyped or unknown prop key is a compile
|
|
224
|
+
* error — unlike reading `props` and comparing a free-form object.
|
|
225
|
+
*/
|
|
226
|
+
readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, CompositionService>
|
|
165
227
|
/** The contract's events as callables; calling one dispatches and settles. */
|
|
166
228
|
readonly actions: ActionsOf<C>
|
|
167
229
|
/** Child composition facades, keyed by slot name. */
|
|
@@ -175,6 +237,22 @@ export interface SlotFacade<C extends UiContract> extends Facade<C> {
|
|
|
175
237
|
readonly first: Effect.Effect<Facade<C>, never, CompositionService>
|
|
176
238
|
readonly at: (index: number) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
177
239
|
readonly all: Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
240
|
+
/**
|
|
241
|
+
* Select the one child mounted under `key` — the `each` item key the structure
|
|
242
|
+
* carried (the SINGLE source of the wire key and the per-item family key, so it
|
|
243
|
+
* can't drift from what the view places). The returned facade is itself typed by
|
|
244
|
+
* the child contract `C`, so `.slots` keeps descending type-safely:
|
|
245
|
+
* `app.slots.List.slots.Item.byKey('todo-1').slots…`. Fails the proof
|
|
246
|
+
* (`UnknownSlot`) when no fill carries the key.
|
|
247
|
+
*/
|
|
248
|
+
readonly byKey: (key: string) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
249
|
+
/** Every child whose computed props satisfy `predicate` — the structure-driven
|
|
250
|
+
* analog of a query, resolved against this frame's fills (props typed by `C`). */
|
|
251
|
+
readonly where: (
|
|
252
|
+
predicate: (props: C['props']) => boolean,
|
|
253
|
+
) => Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
254
|
+
/** How many children this slot mounted this frame (the fill length). */
|
|
255
|
+
readonly count: Effect.Effect<number, never, CompositionService>
|
|
178
256
|
}
|
|
179
257
|
|
|
180
258
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
+
})
|