@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 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.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 { 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,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
- // 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
- },
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
- const env: RenderEnv = { props, tracker: { add: () => {} }, slots }
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: Effect.map(renderTree, () => capturesFor(name)[index]?.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(() => Effect.sync(() => triggerOf(name, index, event)(payload))),
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: { kind: 'ProductRequirement' as const, name: statement, statement, composition },
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
- /** The contract's events as facade actions: payload in, dispatch-and-settle Effect out. */
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<void, never, CompositionService>
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, then settle. */
154
- export type Action = (payload: unknown) => Effect.Effect<void, never, CompositionService>
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
+ })