@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-proof",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.8",
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 { isValidElement } from 'react'
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
- type Node,
12
+ isStructure,
14
13
  publish,
15
14
  type RenderEnv,
16
15
  type Scene,
17
16
  type SlotChild,
18
17
  type SlotClass,
19
- type SlotHost,
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
- // the real reduce loop through a capturing FACADE: no DOM, no React reconciler, no
28
- // text matching. A proof never renders through React props are recorded by
29
- // reform's Effect composition logic, and the only view code this engine runs is the
30
- // pure `(props, slots, events) => Node` body. `executeProof`/`driveProof` are the
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
- interface Sink {
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
- // A slot renders to a deferred component (JSX `<slots.Item/>`); map each
170
- // back to the child it stands for so the tree walk can enqueue it.
171
- const enqueueBy = new Map<Function, (childProps: unknown) => void>()
172
- const slots: SlotHost = {
173
- slot: (name) => {
174
- const slotClass = comp.manifest.slots?.[name]
175
- const child = slotClass && bindings.get(slotClass)
176
- const component: (childProps: unknown) => Node = () => null
177
- if (child) enqueueBy.set(component, (childProps) => next.push({ comp: child, props: childProps }))
178
- return component
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
- const env: RenderEnv = { props, tracker: { add: () => {} }, slots }
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: Effect.map(renderTree, () => capturesFor(name)[index]?.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(() => Effect.sync(() => triggerOf(name, index, event)(payload))),
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: { kind: 'ProductRequirement' as const, name: statement, statement, composition },
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
- /** The contract's events as facade actions: payload in, dispatch-and-settle Effect out. */
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<void, never, CompositionService>
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, then settle. */
154
- export type Action = (payload: unknown) => Effect.Effect<void, never, CompositionService>
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
+ })