@playfast/reform-proof 0.1.0 → 1.0.1
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 +289 -220
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-proof",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "1.0.1",
|
|
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,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Array as Arr,
|
|
3
|
+
Cause,
|
|
4
|
+
Effect,
|
|
5
|
+
Layer,
|
|
6
|
+
ManagedRuntime,
|
|
7
|
+
Match,
|
|
8
|
+
MutableRef,
|
|
9
|
+
Option,
|
|
10
|
+
Record as Rec,
|
|
11
|
+
Ref,
|
|
12
|
+
} from 'effect'
|
|
2
13
|
import { yieldWrapGet } from 'effect/Utils'
|
|
3
14
|
import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
|
|
4
15
|
import {
|
|
@@ -8,11 +19,14 @@ import {
|
|
|
8
19
|
Composition,
|
|
9
20
|
type CompositionClass,
|
|
10
21
|
type CompositionService,
|
|
22
|
+
type Instrumentation,
|
|
11
23
|
isFeatureBinding,
|
|
12
24
|
isStructure,
|
|
25
|
+
noopInstrumentation,
|
|
13
26
|
publish,
|
|
14
27
|
type RenderEnv,
|
|
15
28
|
type Scene,
|
|
29
|
+
sceneInstrumentation,
|
|
16
30
|
type SlotChild,
|
|
17
31
|
type SlotClass,
|
|
18
32
|
type SlotFill,
|
|
@@ -77,23 +91,26 @@ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unkn
|
|
|
77
91
|
export interface Sink {
|
|
78
92
|
readonly api: CaptureSinkApi
|
|
79
93
|
readonly captures: ReadonlyArray<UiCapture>
|
|
94
|
+
/** The scene's profiling hooks — render spans record here (noop unless `profileScene`d). */
|
|
95
|
+
readonly instrumentation: Instrumentation
|
|
80
96
|
reset(): void
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
export const makeSink = (): Sink => {
|
|
84
|
-
//
|
|
85
|
-
const
|
|
99
|
+
export const makeSink = (instrumentation: Instrumentation = noopInstrumentation): Sink => {
|
|
100
|
+
// CaptureSink.record is synchronous, so this uses MutableRef rather than Ref.
|
|
101
|
+
const captures = MutableRef.make<UiCapture[]>([])
|
|
86
102
|
return {
|
|
87
103
|
api: {
|
|
88
104
|
record: (capture) => {
|
|
89
|
-
|
|
105
|
+
MutableRef.update(captures, (current) => [...current, capture])
|
|
90
106
|
},
|
|
91
107
|
},
|
|
92
108
|
get captures() {
|
|
93
|
-
return
|
|
109
|
+
return MutableRef.get(captures)
|
|
94
110
|
},
|
|
111
|
+
instrumentation,
|
|
95
112
|
reset: () => {
|
|
96
|
-
|
|
113
|
+
MutableRef.set(captures, [])
|
|
97
114
|
},
|
|
98
115
|
}
|
|
99
116
|
}
|
|
@@ -135,10 +152,11 @@ interface NodeRef {
|
|
|
135
152
|
readonly index: number
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
/** Settle progress: the previous fingerprint
|
|
155
|
+
/** Settle progress: the previous fingerprint, remaining renders, and early-stop state. */
|
|
139
156
|
interface SettleProgress {
|
|
140
157
|
readonly previous: string
|
|
141
158
|
readonly remaining: number
|
|
159
|
+
readonly stable: boolean
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
/** One step of the driver's generator pump: the value to feed in and the frame index. */
|
|
@@ -147,184 +165,220 @@ interface PumpStep {
|
|
|
147
165
|
readonly index: number
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
|
|
151
|
-
|
|
168
|
+
// A slot's child is a plain composition or a `FeatureBinding`. A proof drives the
|
|
169
|
+
// composition the feature mounts; for a `default` feature that composition's logic
|
|
170
|
+
// is already live (its eager `.live` was merged into the scene by `provide`), so
|
|
171
|
+
// unwrapping is all that's needed. (Driving a `lazy` feature — load gap +
|
|
172
|
+
// placeholders — is the proof host's Part B increment; until then a scene wires
|
|
173
|
+
// features as `default`, the eagerly-resolvable form.)
|
|
174
|
+
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
175
|
+
isFeatureBinding(child) ? child.composition : child
|
|
176
|
+
|
|
177
|
+
/** A composition's declared slot map, defaulting to an empty record when it declares none. */
|
|
178
|
+
const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
|
|
179
|
+
Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
|
|
180
|
+
|
|
181
|
+
// Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
|
|
182
|
+
// UiContract>` erases its per-slot fill types at this boundary, so recover them
|
|
183
|
+
// structurally before traversing.
|
|
184
|
+
const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
|
|
185
|
+
typeof candidate === 'object' &&
|
|
186
|
+
candidate !== null &&
|
|
187
|
+
'_tag' in candidate &&
|
|
188
|
+
(candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
|
|
189
|
+
|
|
190
|
+
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
191
|
+
Rec.fromEntries(
|
|
192
|
+
Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
|
|
193
|
+
isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// Slot bindings (`provide(slot, composition)`) are static, so resolve the whole
|
|
198
|
+
// child tree once, up front — keeping `renderTree` and slot navigation
|
|
199
|
+
// synchronous (no nested runtime drive).
|
|
200
|
+
const collectBindings = Effect.fn('collectBindings')(function* (
|
|
152
201
|
root: CompositionClass<unknown>,
|
|
153
|
-
|
|
154
|
-
// Records every event name dispatched through the facade, so a proof's
|
|
155
|
-
// requirement-declared event coverage can be verified after the body runs.
|
|
156
|
-
dispatched: Set<string>,
|
|
157
|
-
): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
|
|
158
|
-
// Slot bindings (`provide(slot, composition)`) are static, so resolve the
|
|
159
|
-
// whole child tree once, up front — keeping `renderTree` and slot navigation
|
|
160
|
-
// synchronous (no nested runtime drive).
|
|
202
|
+
): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
|
|
161
203
|
const bindings = new Map<SlotClass, CompositionClass<unknown>>()
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
204
|
+
const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
|
|
205
|
+
'collectBindings.walk',
|
|
206
|
+
)(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
|
|
207
|
+
yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
|
|
208
|
+
Effect.gen(function* () {
|
|
209
|
+
if (bindings.has(slotClass)) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const child = childComposition(yield* slotClass.tag)
|
|
213
|
+
bindings.set(slotClass, child)
|
|
214
|
+
yield* walk(child)
|
|
215
|
+
}),
|
|
174
216
|
)
|
|
175
|
-
const bindChild = Effect.fn('bindChild')(function* (
|
|
176
|
-
slotClass: SlotClass,
|
|
177
|
-
): Effect.fn.Return<void, never, SlotChild> {
|
|
178
|
-
const child = childComposition(yield* slotClass.tag)
|
|
179
|
-
bindings.set(slotClass, child)
|
|
180
|
-
yield* collect(child)
|
|
181
217
|
})
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
218
|
+
yield* walk(root)
|
|
219
|
+
return bindings
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Expand one slot fill into the children to render next. `Each` mounts one child
|
|
223
|
+
// per keyed item with that item's props and key; `One` mounts a single child
|
|
224
|
+
// under a synthesized singleton key; `Absent` mounts nothing.
|
|
225
|
+
const enqueueFill = (
|
|
226
|
+
slotName: string,
|
|
227
|
+
fill: SlotFill<unknown>,
|
|
228
|
+
child: CompositionClass<unknown>,
|
|
229
|
+
): ReadonlyArray<Mounted> =>
|
|
230
|
+
Match.value(fill).pipe(
|
|
231
|
+
Match.when({ _tag: 'Each' }, (each) =>
|
|
232
|
+
each.items.map((entry) => ({
|
|
233
|
+
comp: child,
|
|
234
|
+
props: entry.props,
|
|
235
|
+
key: Option.some(entry.key),
|
|
236
|
+
})),
|
|
237
|
+
),
|
|
238
|
+
Match.when({ _tag: 'One' }, (oneFill) => [
|
|
239
|
+
{ comp: child, props: oneFill.props, key: Option.some(`${slotName}.0`) },
|
|
240
|
+
]),
|
|
241
|
+
Match.when({ _tag: 'Absent' }, () => []),
|
|
242
|
+
Match.exhaustive,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
// Consume a `Structure` frame: record computed props and event triggers, then
|
|
246
|
+
// enqueue one child per slot fill with the per-item props the fill carries.
|
|
247
|
+
const driveStructure = (
|
|
248
|
+
comp: CompositionClass<unknown>,
|
|
249
|
+
structure: Structure<UiContract>,
|
|
250
|
+
key: Option.Option<string>,
|
|
251
|
+
sink: Sink,
|
|
252
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
253
|
+
): ReadonlyArray<Mounted> => {
|
|
254
|
+
sink.api.record({
|
|
255
|
+
name: uiNameOf(comp),
|
|
256
|
+
props: structure.props,
|
|
257
|
+
events: eventsOf(structure),
|
|
258
|
+
...(Option.isSome(key) ? { key: key.value } : {}),
|
|
259
|
+
})
|
|
260
|
+
const fills = structureFills(structure)
|
|
261
|
+
return Rec.toEntries(slotsOf(comp)).flatMap(([slotName, slotClass]) => {
|
|
262
|
+
const child = bindings.get(slotClass)
|
|
263
|
+
const fill = fills[slotName]
|
|
264
|
+
return child === undefined || fill === undefined ? [] : enqueueFill(slotName, fill, child)
|
|
209
265
|
})
|
|
266
|
+
}
|
|
210
267
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
268
|
+
// Render one mounted composition, returning the children its fills enqueue. Every
|
|
269
|
+
// composition returns a `Structure` (no view is ever executed — the proof is
|
|
270
|
+
// view-free / React-free).
|
|
271
|
+
const renderMounted = Effect.fn('renderMounted')(function* (
|
|
272
|
+
mounted: Mounted,
|
|
273
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
274
|
+
sink: Sink,
|
|
275
|
+
): Effect.fn.Return<ReadonlyArray<Mounted>, never, CompositionService> {
|
|
276
|
+
const service = yield* mounted.comp.tag
|
|
277
|
+
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
278
|
+
const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
|
|
279
|
+
const frame = yield* Composition.render(service, env)
|
|
280
|
+
endRenderSpan()
|
|
281
|
+
if (!isStructure(frame)) {
|
|
282
|
+
return yield* Effect.dieMessage(
|
|
283
|
+
`reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
return driveStructure(mounted.comp, frame, mounted.key, sink, bindings)
|
|
287
|
+
})
|
|
224
288
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
name: uiNameOf(comp),
|
|
239
|
-
props: structure.props,
|
|
240
|
-
events: eventsOf(structure),
|
|
241
|
-
...(Option.isSome(key) ? { key: key.value } : {}),
|
|
242
|
-
})
|
|
243
|
-
const fills: Record<string, SlotFill<unknown>> = structure.slots
|
|
244
|
-
return Rec.toEntries(fills).flatMap(([slotName, fill]) => {
|
|
245
|
-
const slotClass = comp.manifest.slots?.[slotName]
|
|
246
|
-
const child = slotClass && bindings.get(slotClass)
|
|
247
|
-
return child === undefined ? [] : enqueueFill(slotName, fill, child)
|
|
248
|
-
})
|
|
289
|
+
// One breadth-first render of a whole level. Each mounted node reports its
|
|
290
|
+
// `(props, events)` to the sink and yields the next level of children.
|
|
291
|
+
const renderLevel: (
|
|
292
|
+
frontier: ReadonlyArray<Mounted>,
|
|
293
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
294
|
+
sink: Sink,
|
|
295
|
+
) => Effect.Effect<void, never, CompositionService> = Effect.fn('renderLevel')(function* (
|
|
296
|
+
frontier: ReadonlyArray<Mounted>,
|
|
297
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
298
|
+
sink: Sink,
|
|
299
|
+
): Effect.fn.Return<void, never, CompositionService> {
|
|
300
|
+
if (frontier.length === 0) {
|
|
301
|
+
return
|
|
249
302
|
}
|
|
303
|
+
const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted, bindings, sink))
|
|
304
|
+
yield* renderLevel(levels.flat(), bindings, sink)
|
|
305
|
+
})
|
|
250
306
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
child: CompositionClass<unknown>,
|
|
260
|
-
): ReadonlyArray<Mounted> =>
|
|
261
|
-
Match.value(fill).pipe(
|
|
262
|
-
Match.when({ _tag: 'Each' }, (each) =>
|
|
263
|
-
each.items.map((entry) => ({
|
|
264
|
-
comp: child,
|
|
265
|
-
props: entry.props,
|
|
266
|
-
key: Option.some(entry.key),
|
|
267
|
-
})),
|
|
268
|
-
),
|
|
269
|
-
Match.when({ _tag: 'One' }, (oneFill) => [
|
|
270
|
-
{ comp: child, props: oneFill.props, key: Option.some(`${slotName}.0`) },
|
|
271
|
-
]),
|
|
272
|
-
Match.when({ _tag: 'Absent' }, () => []),
|
|
273
|
-
Match.exhaustive,
|
|
274
|
-
)
|
|
307
|
+
const renderTree = Effect.fn('renderTree')(function* (
|
|
308
|
+
root: CompositionClass<unknown>,
|
|
309
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
310
|
+
sink: Sink,
|
|
311
|
+
): Effect.fn.Return<void, never, CompositionService> {
|
|
312
|
+
sink.reset()
|
|
313
|
+
yield* renderLevel([{ comp: root, props: {}, key: Option.none() }], bindings, sink)
|
|
314
|
+
})
|
|
275
315
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
yield* renderLevel([{ comp: root, props: {}, key: Option.none() }])
|
|
279
|
-
})
|
|
316
|
+
const capturesFor = (sink: Sink, name: string): ReadonlyArray<UiCapture> =>
|
|
317
|
+
sink.captures.filter((capture) => capture.name === name)
|
|
280
318
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
319
|
+
// A cheap fingerprint of the rendered tree; when two successive renders match,
|
|
320
|
+
// the engine has stopped producing new state and the read is safe.
|
|
321
|
+
const fingerprint = (sink: Sink): string =>
|
|
322
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- structural fixpoint fingerprint of the captured tree; not Schema-typed data
|
|
323
|
+
JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
|
|
324
|
+
|
|
325
|
+
const settleTree = Effect.fn('settleTree')(function* (
|
|
326
|
+
root: CompositionClass<unknown>,
|
|
327
|
+
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
328
|
+
sink: Sink,
|
|
329
|
+
): Effect.fn.Return<void, never, CompositionService> {
|
|
330
|
+
yield* settleDrain
|
|
331
|
+
yield* renderTree(root, bindings, sink)
|
|
332
|
+
yield* Effect.iterate(
|
|
333
|
+
{ previous: fingerprint(sink), remaining: SETTLE_MAX_RENDERS, stable: false },
|
|
334
|
+
{
|
|
335
|
+
while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
|
|
336
|
+
body: (progress) =>
|
|
337
|
+
Effect.gen(function* () {
|
|
338
|
+
yield* settleDrain
|
|
339
|
+
yield* Effect.sleep(SETTLE_STEP)
|
|
340
|
+
yield* renderTree(root, bindings, sink)
|
|
341
|
+
const current = fingerprint(sink)
|
|
342
|
+
return {
|
|
343
|
+
previous: current,
|
|
344
|
+
remaining: progress.remaining - 1,
|
|
345
|
+
stable: current === progress.previous,
|
|
346
|
+
}
|
|
347
|
+
}),
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const makeFacadeEffect = Effect.fn('makeFacadeEffect')(function* (
|
|
353
|
+
root: CompositionClass<unknown>,
|
|
354
|
+
sink: Sink,
|
|
355
|
+
// Records every event name dispatched through the facade, so a proof's
|
|
356
|
+
// requirement-declared event coverage can be verified after the body runs.
|
|
357
|
+
dispatched: Set<string>,
|
|
358
|
+
): Effect.fn.Return<
|
|
359
|
+
{ readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> },
|
|
360
|
+
never,
|
|
361
|
+
SlotChild
|
|
362
|
+
> {
|
|
363
|
+
const bindings = yield* collectBindings(root)
|
|
364
|
+
const settle = settleTree(root, bindings, sink)
|
|
365
|
+
const rerender = renderTree(root, bindings, sink)
|
|
312
366
|
|
|
313
367
|
const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
|
|
314
|
-
Option.match(Option.fromNullable(capturesFor(ref.name)[ref.index]?.events[event]), {
|
|
368
|
+
Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
|
|
315
369
|
onNone: () =>
|
|
316
370
|
Effect.dieMessage(
|
|
317
371
|
new UnknownAction({
|
|
318
372
|
composition: ref.name,
|
|
319
373
|
action: event,
|
|
320
|
-
rendered: capturesFor(ref.name).length,
|
|
374
|
+
rendered: capturesFor(sink, ref.name).length,
|
|
321
375
|
}).message,
|
|
322
376
|
),
|
|
323
377
|
onSome: Effect.succeed,
|
|
324
378
|
})
|
|
325
379
|
|
|
326
380
|
const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
|
|
327
|
-
Effect.map(
|
|
381
|
+
Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
|
|
328
382
|
|
|
329
383
|
const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
330
384
|
props: propsFor(ref),
|
|
@@ -347,7 +401,7 @@ export const makeFacade = (
|
|
|
347
401
|
actions: keyed(
|
|
348
402
|
(event): Action =>
|
|
349
403
|
(payload) =>
|
|
350
|
-
|
|
404
|
+
rerender.pipe(
|
|
351
405
|
Effect.flatMap(() => triggerOf(ref, event)),
|
|
352
406
|
Effect.flatMap((trigger) =>
|
|
353
407
|
Effect.sync(() => {
|
|
@@ -365,7 +419,7 @@ export const makeFacade = (
|
|
|
365
419
|
})
|
|
366
420
|
|
|
367
421
|
const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
|
|
368
|
-
const slotClass = parent
|
|
422
|
+
const slotClass = slotsOf(parent)[slotName]
|
|
369
423
|
const child = slotClass && bindings.get(slotClass)
|
|
370
424
|
if (child === undefined) {
|
|
371
425
|
// oxlint-disable-next-line reform-rules/no-throw -- proof navigated to an undeclared slot: programmer error surfaced as a defect during generator-time proxy access
|
|
@@ -373,24 +427,24 @@ export const makeFacade = (
|
|
|
373
427
|
}
|
|
374
428
|
const childName = uiNameOf(child)
|
|
375
429
|
const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
|
|
376
|
-
Effect.as(
|
|
430
|
+
Effect.as(rerender, nodeFacade({ name: childName, index }, child))
|
|
377
431
|
const first = nodeFacade({ name: childName, index: 0 }, child)
|
|
378
432
|
// The render index of the child whose structure-fill `key` matches (plan 06).
|
|
379
433
|
// Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
|
|
380
434
|
// selection is by stable identity, not render order — `none` when no fill carries it.
|
|
381
435
|
const indexOfKey = (key: string): Option.Option<number> =>
|
|
382
|
-
Arr.findFirstIndex(capturesFor(childName), (capture) => capture.key === key)
|
|
436
|
+
Arr.findFirstIndex(capturesFor(sink, childName), (capture) => capture.key === key)
|
|
383
437
|
return {
|
|
384
438
|
first: atIndex(0),
|
|
385
439
|
at: atIndex,
|
|
386
|
-
all: Effect.map(
|
|
387
|
-
capturesFor(childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
|
|
440
|
+
all: Effect.map(rerender, () =>
|
|
441
|
+
capturesFor(sink, childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
|
|
388
442
|
),
|
|
389
443
|
// Select the one child mounted under `key` (the `each` item key). Re-renders
|
|
390
444
|
// first so the fill keys reflect the latest frame, then resolves its facade;
|
|
391
445
|
// dies with `UnknownSlot` (key in the slot position) when no fill carries it.
|
|
392
446
|
byKey: (key) =>
|
|
393
|
-
Effect.flatMap(
|
|
447
|
+
Effect.flatMap(rerender, () =>
|
|
394
448
|
Option.match(indexOfKey(key), {
|
|
395
449
|
onNone: () =>
|
|
396
450
|
Effect.dieMessage(
|
|
@@ -402,13 +456,13 @@ export const makeFacade = (
|
|
|
402
456
|
// Every child whose computed props satisfy `predicate`. Re-renders, then maps
|
|
403
457
|
// the matching captures back to their facades by render index.
|
|
404
458
|
where: (predicate) =>
|
|
405
|
-
Effect.map(
|
|
406
|
-
capturesFor(childName).flatMap((capture, index) =>
|
|
459
|
+
Effect.map(rerender, () =>
|
|
460
|
+
capturesFor(sink, childName).flatMap((capture, index) =>
|
|
407
461
|
predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
|
|
408
462
|
),
|
|
409
463
|
),
|
|
410
464
|
// How many children this slot mounted this frame — the fill length.
|
|
411
|
-
count: Effect.map(
|
|
465
|
+
count: Effect.map(rerender, () => capturesFor(sink, childName).length),
|
|
412
466
|
// Used directly, a slot behaves as its first instance.
|
|
413
467
|
props: first.props,
|
|
414
468
|
expectProps: first.expectProps,
|
|
@@ -419,7 +473,15 @@ export const makeFacade = (
|
|
|
419
473
|
}
|
|
420
474
|
|
|
421
475
|
return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
|
|
422
|
-
}
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
export const makeFacade = (
|
|
479
|
+
runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
|
|
480
|
+
root: CompositionClass<unknown>,
|
|
481
|
+
sink: Sink,
|
|
482
|
+
dispatched: Set<string>,
|
|
483
|
+
): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } =>
|
|
484
|
+
runtime.runSync(makeFacadeEffect(root, sink, dispatched))
|
|
423
485
|
|
|
424
486
|
/** Declared-but-never-dispatched events for a proof's requirement (coverage). */
|
|
425
487
|
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
|
|
@@ -428,22 +490,21 @@ const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<s
|
|
|
428
490
|
onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
|
|
429
491
|
})
|
|
430
492
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
493
|
+
const bootScene = (scene: Scene): Effect.Effect<void, never, Bus> =>
|
|
494
|
+
Effect.forEach(
|
|
495
|
+
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
496
|
+
(event) => publish('High', event),
|
|
497
|
+
).pipe(Effect.asVoid)
|
|
498
|
+
|
|
499
|
+
const executeProofEffect = Effect.fn('executeProofEffect')(function* (
|
|
500
|
+
proof: Proof,
|
|
501
|
+
sink: Sink,
|
|
502
|
+
): Effect.fn.Return<ProofResult, never, RuntimeServices> {
|
|
439
503
|
const dispatched = new Set<string>()
|
|
440
504
|
const statement = proof.requirement.manifest.statement
|
|
441
|
-
|
|
442
|
-
const { facade, settle } =
|
|
443
|
-
yield*
|
|
444
|
-
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
445
|
-
(event) => publish('High', event),
|
|
446
|
-
)
|
|
505
|
+
return yield* Effect.gen(function* () {
|
|
506
|
+
const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
|
|
507
|
+
yield* bootScene(proof.scene)
|
|
447
508
|
yield* settle
|
|
448
509
|
yield* Effect.gen(() => proof.body(facade))
|
|
449
510
|
const missing = missingCoverage(proof, dispatched)
|
|
@@ -456,31 +517,38 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
|
456
517
|
Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
|
|
457
518
|
),
|
|
458
519
|
)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Run one proof against a fresh runtime, returning its pass/fail result. Each
|
|
524
|
+
* proof gets its own isolated environment, so nothing leaks between proofs. Boots
|
|
525
|
+
* the scene as the host would, settles, then runs the proof body to completion.
|
|
526
|
+
*/
|
|
527
|
+
export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
528
|
+
const sink = makeSink(sceneInstrumentation(proof.scene))
|
|
529
|
+
return Effect.runPromise(executeProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
|
|
462
530
|
}
|
|
463
531
|
|
|
464
532
|
/**
|
|
465
533
|
* Drive one proof a step at a time, recording a `StepFrame` after each yielded
|
|
466
|
-
* effect settles. Reuses the same
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
534
|
+
* effect settles. Reuses the same facade/settle engine as `executeProof`, but
|
|
535
|
+
* instead of handing the body to `Effect.gen` (which runs it to completion), it
|
|
536
|
+
* pumps the generator by hand — `gen.next(value)` yields the next effect, the
|
|
537
|
+
* active proof layer runs it, then the driver snapshots the sink and feeds the
|
|
470
538
|
* result back. So the editor gets the per-step timeline with no change to the
|
|
471
539
|
* proof authoring API.
|
|
472
540
|
*/
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
541
|
+
const driveProofEffect = Effect.fn('driveProofEffect')(function* (
|
|
542
|
+
proof: Proof,
|
|
543
|
+
sink: Sink,
|
|
544
|
+
): Effect.fn.Return<DriveResult, never, RuntimeServices> {
|
|
545
|
+
const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
|
|
477
546
|
const statement = proof.requirement.manifest.statement
|
|
478
547
|
// The driver's per-frame timeline does not enforce coverage; collect into a
|
|
479
|
-
// throwaway set so `
|
|
548
|
+
// throwaway set so `makeFacadeEffect`'s contract is satisfied.
|
|
480
549
|
const dispatched = new Set<string>()
|
|
481
|
-
const record = (index: number): void =>
|
|
482
|
-
|
|
483
|
-
}
|
|
550
|
+
const record = (index: number): Effect.Effect<void> =>
|
|
551
|
+
Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
|
|
484
552
|
// Pump the generator: run each yielded effect on the runtime, snapshot, recur.
|
|
485
553
|
const pump = Effect.fn('pump')(function* (
|
|
486
554
|
generator: ReturnType<Proof['body']>,
|
|
@@ -491,30 +559,31 @@ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
|
491
559
|
return
|
|
492
560
|
}
|
|
493
561
|
const output = yield* yieldWrapGet(next.value)
|
|
494
|
-
yield*
|
|
562
|
+
yield* record(step.index)
|
|
495
563
|
yield* pump(generator, { input: output, index: step.index + 1 })
|
|
496
564
|
})
|
|
497
|
-
|
|
498
|
-
const { facade, settle } =
|
|
499
|
-
yield*
|
|
500
|
-
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
501
|
-
(event) => publish('High', event),
|
|
502
|
-
)
|
|
565
|
+
return yield* Effect.gen(function* () {
|
|
566
|
+
const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
|
|
567
|
+
yield* bootScene(proof.scene)
|
|
503
568
|
yield* settle
|
|
504
|
-
yield*
|
|
569
|
+
yield* record(0)
|
|
505
570
|
yield* pump(proof.body(facade), { input: undefined, index: 1 })
|
|
506
|
-
return { requirement: statement, frames:
|
|
571
|
+
return { requirement: statement, frames: yield* Ref.get(frames), ok: true }
|
|
507
572
|
}).pipe(
|
|
508
573
|
Effect.catchAllCause((cause) =>
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
574
|
+
Ref.get(frames).pipe(
|
|
575
|
+
Effect.map((collected) => ({
|
|
576
|
+
requirement: statement,
|
|
577
|
+
frames: collected,
|
|
578
|
+
ok: false,
|
|
579
|
+
error: messageOf(Cause.squash(cause)),
|
|
580
|
+
})),
|
|
581
|
+
),
|
|
515
582
|
),
|
|
516
583
|
)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
587
|
+
const sink = makeSink(sceneInstrumentation(proof.scene))
|
|
588
|
+
return Effect.runPromise(driveProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
|
|
520
589
|
}
|