@playfast/reform-proof 0.0.10 → 0.1.0
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/README.md +6 -6
- package/package.json +1 -1
- package/src/engine.ts +236 -170
- package/src/index.ts +108 -63
package/README.md
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# `@playfast/proof`
|
|
3
|
+
# `@playfast/reform-proof`
|
|
4
4
|
|
|
5
5
|
**A headless testing toolkit for [reform](https://www.npmjs.com/package/@playfast/reform).**
|
|
6
6
|
Drive a scene's compositions and assert on what they compute — deterministically, with no DOM, no timers, and no text matching.
|
|
7
7
|
|
|
8
|
-
[](https://www.npmjs.com/package/@playfast/proof)
|
|
9
|
-
[](#license)
|
|
8
|
+
[](https://www.npmjs.com/package/@playfast/reform-proof)
|
|
9
|
+
[](#license)
|
|
10
10
|
[](https://effect.website)
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
A reform *scene* is renderer-neutral, so it can be exercised without a host. `@playfast/proof` runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the **props a composition computed** — pure data, never rendered markup. The scene you prove is the exact scene `@playfast/react`
|
|
16
|
+
A reform *scene* is renderer-neutral, so it can be exercised without a host. `@playfast/reform-proof` runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the **props a composition computed** — pure data, never rendered markup. The scene you prove is the exact scene `@playfast/reform-react` renders, so there is no seam between a passing test and production behavior.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
bun add -d @playfast/proof @playfast/reform effect react
|
|
21
|
+
bun add -d @playfast/reform-proof @playfast/reform effect react
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Peers: `effect`, `react` (`^19`), and `@playfast/reform`.
|
|
@@ -80,7 +80,7 @@ There is no real clock. `settle` cooperatively drains forked fibers and loops un
|
|
|
80
80
|
|
|
81
81
|
## The reform family
|
|
82
82
|
|
|
83
|
-
Core [`@playfast/reform`](https://www.npmjs.com/package/@playfast/reform) · hosts [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) / [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native) · forms [`@playfast/forms`](https://www.npmjs.com/package/@playfast/forms) + [`@playfast/forms-react`](https://www.npmjs.com/package/@playfast/forms-react)
|
|
83
|
+
Core [`@playfast/reform`](https://www.npmjs.com/package/@playfast/reform) · hosts [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) / [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native) · forms [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms) + [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react)
|
|
84
84
|
|
|
85
85
|
## License
|
|
86
86
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-proof",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
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,4 @@
|
|
|
1
|
-
import { Effect, Layer, Match, ManagedRuntime } from 'effect'
|
|
1
|
+
import { Array as Arr, Cause, Effect, Layer, Match, ManagedRuntime, Option, Record as Rec } from 'effect'
|
|
2
2
|
import { yieldWrapGet } from 'effect/Utils'
|
|
3
3
|
import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
|
|
4
4
|
import {
|
|
@@ -54,9 +54,13 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
|
54
54
|
// A keyed view backed by a getter — the only place dynamic keys are needed.
|
|
55
55
|
const keyed = <V>(get: (key: string) => V): Record<string, V> => {
|
|
56
56
|
const target: Record<string, V> = Object.create(null)
|
|
57
|
-
return new Proxy(target, { get: (
|
|
57
|
+
return new Proxy(target, { get: (_target, key) => get(String(key)) })
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Surface an unknown failure/defect as the message string a result DTO carries. */
|
|
61
|
+
const messageOf = (error: unknown): string =>
|
|
62
|
+
error instanceof Error ? error.message : String(error)
|
|
63
|
+
|
|
60
64
|
const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
|
|
61
65
|
|
|
62
66
|
// The event triggers a `Structure` frame carries (plan 03b). At the erased
|
|
@@ -64,8 +68,9 @@ const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.m
|
|
|
64
68
|
// each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
|
|
65
69
|
// omitted `events` defaults to the empty map. This is the structure-path analog of
|
|
66
70
|
// the view path's `capture.events`.
|
|
67
|
-
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
68
|
-
|
|
71
|
+
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
|
|
72
|
+
...structure.events,
|
|
73
|
+
})
|
|
69
74
|
|
|
70
75
|
// Engine SPI (Sink/makeSink/RuntimeServices/proofLayer/makeFacade) — exported for
|
|
71
76
|
// @playfast/reform-drive, which builds the no-ceremony scene driver on the same core.
|
|
@@ -79,7 +84,11 @@ export const makeSink = (): Sink => {
|
|
|
79
84
|
// A const holder whose array we swap on reset (no reassigned binding).
|
|
80
85
|
const state: { captures: UiCapture[] } = { captures: [] }
|
|
81
86
|
return {
|
|
82
|
-
api: {
|
|
87
|
+
api: {
|
|
88
|
+
record: (capture) => {
|
|
89
|
+
state.captures = [...state.captures, capture]
|
|
90
|
+
},
|
|
91
|
+
},
|
|
83
92
|
get captures() {
|
|
84
93
|
return state.captures
|
|
85
94
|
},
|
|
@@ -102,9 +111,12 @@ export type RuntimeServices = CompositionService | SlotChild | Bus
|
|
|
102
111
|
* a slot tag with the requirement erased.
|
|
103
112
|
*/
|
|
104
113
|
export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- the one erasure boundary: the scene's host-read `MountedServices` layer widens to the broader `RuntimeServices` a proof resolves
|
|
115
|
+
const sceneLayer = scene.provide.reduce((merged, layer) => Layer.merge(merged, layer)) as unknown as Layer.Layer<
|
|
116
|
+
RuntimeServices,
|
|
117
|
+
never,
|
|
118
|
+
never
|
|
119
|
+
>
|
|
108
120
|
return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -114,7 +126,25 @@ export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeService
|
|
|
114
126
|
interface Mounted {
|
|
115
127
|
readonly comp: CompositionClass<unknown>
|
|
116
128
|
readonly props: unknown
|
|
117
|
-
readonly key
|
|
129
|
+
readonly key: Option.Option<string>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A rendered node addressed by its composition ui-name and render index. */
|
|
133
|
+
interface NodeRef {
|
|
134
|
+
readonly name: string
|
|
135
|
+
readonly index: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Settle progress: the previous fingerprint and how many renders remain. */
|
|
139
|
+
interface SettleProgress {
|
|
140
|
+
readonly previous: string
|
|
141
|
+
readonly remaining: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One step of the driver's generator pump: the value to feed in and the frame index. */
|
|
145
|
+
interface PumpStep {
|
|
146
|
+
readonly input: unknown
|
|
147
|
+
readonly index: number
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
export const makeFacade = (
|
|
@@ -137,43 +167,59 @@ export const makeFacade = (
|
|
|
137
167
|
// features as `default`, the eagerly-resolvable form.)
|
|
138
168
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
139
169
|
isFeatureBinding(child) ? child.composition : child
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
const slotClassesOf = (comp: CompositionClass<unknown>): ReadonlyArray<SlotClass> =>
|
|
171
|
+
Option.fromNullable(comp.manifest.slots).pipe(
|
|
172
|
+
Option.map((slots) => Object.values(slots)),
|
|
173
|
+
Option.getOrElse(() => []),
|
|
174
|
+
)
|
|
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
|
+
})
|
|
182
|
+
const collect: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> =
|
|
183
|
+
Effect.fn('collect')(function* (
|
|
184
|
+
comp: CompositionClass<unknown>,
|
|
185
|
+
): Effect.fn.Return<void, never, SlotChild> {
|
|
186
|
+
yield* Effect.forEach(slotClassesOf(comp), (slotClass) =>
|
|
187
|
+
bindings.has(slotClass) ? Effect.void : bindChild(slotClass),
|
|
188
|
+
)
|
|
148
189
|
})
|
|
149
190
|
runtime.runSync(collect(root))
|
|
150
191
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
// Render one mounted composition, returning the children its fills enqueue. Every
|
|
193
|
+
// composition returns a `Structure` (no view is ever executed — the proof is
|
|
194
|
+
// view-free / React-free): read its computed props straight off the value, record
|
|
195
|
+
// them + the events it carries into the sink (so the facade, `triggerOf`, and
|
|
196
|
+
// navigation work uniformly), and walk its slot FILLS to enqueue children.
|
|
197
|
+
const renderMounted = Effect.fn('renderMounted')(function* (
|
|
198
|
+
mounted: Mounted,
|
|
199
|
+
): Effect.fn.Return<ReadonlyArray<Mounted>, never, CompositionService> {
|
|
200
|
+
const service = yield* mounted.comp.tag
|
|
201
|
+
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
202
|
+
const frame = yield* Composition.render(service, env)
|
|
203
|
+
if (!isStructure(frame)) {
|
|
204
|
+
return yield* Effect.dieMessage(
|
|
205
|
+
`reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
return driveStructure(mounted.comp, frame, mounted.key)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// One breadth-first render of a whole level. Each mounted node reports its
|
|
212
|
+
// `(props, events)` to the sink and yields the next level of children, so a
|
|
213
|
+
// single recursive sweep renders everything.
|
|
214
|
+
const renderLevel: (frontier: ReadonlyArray<Mounted>) => Effect.Effect<void, never, CompositionService> =
|
|
215
|
+
Effect.fn('renderLevel')(function* (
|
|
216
|
+
frontier: ReadonlyArray<Mounted>,
|
|
217
|
+
): Effect.fn.Return<void, never, CompositionService> {
|
|
218
|
+
if (frontier.length === 0) {
|
|
219
|
+
return
|
|
175
220
|
}
|
|
176
|
-
yield*
|
|
221
|
+
const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted))
|
|
222
|
+
yield* renderLevel(levels.flat())
|
|
177
223
|
})
|
|
178
224
|
|
|
179
225
|
// Consume a `Structure` frame: record the node's computed props (the structure
|
|
@@ -186,22 +232,20 @@ export const makeFacade = (
|
|
|
186
232
|
const driveStructure = (
|
|
187
233
|
comp: CompositionClass<unknown>,
|
|
188
234
|
structure: Structure<UiContract>,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
): void => {
|
|
235
|
+
key: Option.Option<string>,
|
|
236
|
+
): ReadonlyArray<Mounted> => {
|
|
192
237
|
sink.api.record({
|
|
193
238
|
name: uiNameOf(comp),
|
|
194
239
|
props: structure.props,
|
|
195
240
|
events: eventsOf(structure),
|
|
196
|
-
...(key
|
|
241
|
+
...(Option.isSome(key) ? { key: key.value } : {}),
|
|
197
242
|
})
|
|
198
243
|
const fills: Record<string, SlotFill<unknown>> = structure.slots
|
|
199
|
-
|
|
244
|
+
return Rec.toEntries(fills).flatMap(([slotName, fill]) => {
|
|
200
245
|
const slotClass = comp.manifest.slots?.[slotName]
|
|
201
246
|
const child = slotClass && bindings.get(slotClass)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
247
|
+
return child === undefined ? [] : enqueueFill(slotName, fill, child)
|
|
248
|
+
})
|
|
205
249
|
}
|
|
206
250
|
|
|
207
251
|
// Expand one slot fill into the children to render next. `Each` mounts one
|
|
@@ -213,23 +257,25 @@ export const makeFacade = (
|
|
|
213
257
|
slotName: string,
|
|
214
258
|
fill: SlotFill<unknown>,
|
|
215
259
|
child: CompositionClass<unknown>,
|
|
216
|
-
|
|
217
|
-
): void => {
|
|
260
|
+
): ReadonlyArray<Mounted> =>
|
|
218
261
|
Match.value(fill).pipe(
|
|
219
|
-
Match.when({ _tag: 'Each' }, (each) =>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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' }, () => []),
|
|
226
273
|
Match.exhaustive,
|
|
227
274
|
)
|
|
228
|
-
}
|
|
229
275
|
|
|
230
276
|
const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
231
277
|
sink.reset()
|
|
232
|
-
yield* renderLevel([{ comp: root, props: {} }])
|
|
278
|
+
yield* renderLevel([{ comp: root, props: {}, key: Option.none() }])
|
|
233
279
|
})
|
|
234
280
|
|
|
235
281
|
const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
|
|
@@ -238,73 +284,82 @@ export const makeFacade = (
|
|
|
238
284
|
// A cheap fingerprint of the rendered tree; when two successive renders match,
|
|
239
285
|
// the engine has stopped producing new state and the read is safe.
|
|
240
286
|
const fingerprint = (): string =>
|
|
287
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- structural fixpoint fingerprint of the captured tree; not Schema-typed data
|
|
241
288
|
JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
|
|
242
289
|
|
|
243
290
|
// Re-render until the tree is stable for two consecutive renders, or give up.
|
|
244
|
-
const settleFrom =
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
291
|
+
const settleFrom: (progress: SettleProgress) => Effect.Effect<void, never, CompositionService> =
|
|
292
|
+
Effect.fn('settleFrom')(function* (
|
|
293
|
+
progress: SettleProgress,
|
|
294
|
+
): Effect.fn.Return<void, never, CompositionService> {
|
|
295
|
+
if (progress.remaining === 0) {
|
|
296
|
+
return
|
|
297
|
+
}
|
|
250
298
|
yield* settleDrain
|
|
251
299
|
yield* Effect.sleep(SETTLE_STEP)
|
|
252
300
|
yield* renderTree
|
|
253
301
|
const current = fingerprint()
|
|
254
|
-
if (current === previous)
|
|
255
|
-
|
|
302
|
+
if (current === progress.previous) {
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
yield* settleFrom({ previous: current, remaining: progress.remaining - 1 })
|
|
256
306
|
})
|
|
257
307
|
const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
|
|
258
308
|
yield* settleDrain
|
|
259
309
|
yield* renderTree
|
|
260
|
-
yield* settleFrom(fingerprint(), SETTLE_MAX_RENDERS)
|
|
310
|
+
yield* settleFrom({ previous: fingerprint(), remaining: SETTLE_MAX_RENDERS })
|
|
261
311
|
})
|
|
262
312
|
|
|
263
|
-
const triggerOf = (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
313
|
+
const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
|
|
314
|
+
Option.match(Option.fromNullable(capturesFor(ref.name)[ref.index]?.events[event]), {
|
|
315
|
+
onNone: () =>
|
|
316
|
+
Effect.dieMessage(
|
|
317
|
+
new UnknownAction({
|
|
318
|
+
composition: ref.name,
|
|
319
|
+
action: event,
|
|
320
|
+
rendered: capturesFor(ref.name).length,
|
|
321
|
+
}).message,
|
|
322
|
+
),
|
|
323
|
+
onSome: Effect.succeed,
|
|
324
|
+
})
|
|
270
325
|
|
|
271
|
-
const propsFor = (
|
|
272
|
-
Effect.map(renderTree, () => capturesFor(name)[index]?.props)
|
|
326
|
+
const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
|
|
327
|
+
Effect.map(renderTree, () => capturesFor(ref.name)[ref.index]?.props)
|
|
273
328
|
|
|
274
|
-
const nodeFacade = (
|
|
275
|
-
props: propsFor(
|
|
329
|
+
const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
|
|
330
|
+
props: propsFor(ref),
|
|
276
331
|
// Assert the computed props match a typed subset. Reuses the same partial-match
|
|
277
|
-
// logic as `expect(...).toMatchObject`;
|
|
332
|
+
// logic as `expect(...).toMatchObject`; dies with `AssertionFailed` on mismatch.
|
|
278
333
|
expectProps: (partial) =>
|
|
279
|
-
propsFor(
|
|
334
|
+
propsFor(ref).pipe(
|
|
280
335
|
Effect.flatMap((props) => {
|
|
281
|
-
const mismatch = matchProps(props, partial)
|
|
336
|
+
const mismatch = matchProps({ actual: props, expected: partial })
|
|
282
337
|
return mismatch === undefined
|
|
283
338
|
? Effect.void
|
|
284
|
-
: Effect.
|
|
285
|
-
throw new AssertionFailed({ detail: mismatch })
|
|
286
|
-
})
|
|
339
|
+
: Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
|
|
287
340
|
}),
|
|
288
341
|
),
|
|
289
|
-
// `keyed` resolves event/slot names at runtime;
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
342
|
+
// `keyed` resolves event/slot names at runtime; the string-keyed proxy is the one
|
|
343
|
+
// reflection boundary — every name it serves is a real contract member, so reading
|
|
344
|
+
// it as the contract-typed surface is sound. The action resolves to the props this
|
|
345
|
+
// node computed once the dispatch settled (the typed "result" of the event).
|
|
346
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract event names
|
|
294
347
|
actions: keyed(
|
|
295
348
|
(event): Action =>
|
|
296
349
|
(payload) =>
|
|
297
350
|
renderTree.pipe(
|
|
298
|
-
Effect.flatMap(() =>
|
|
351
|
+
Effect.flatMap(() => triggerOf(ref, event)),
|
|
352
|
+
Effect.flatMap((trigger) =>
|
|
299
353
|
Effect.sync(() => {
|
|
300
354
|
dispatched.add(event)
|
|
301
|
-
|
|
355
|
+
trigger(payload)
|
|
302
356
|
}),
|
|
303
357
|
),
|
|
304
358
|
Effect.flatMap(() => settle),
|
|
305
|
-
Effect.flatMap(() => propsFor(
|
|
359
|
+
Effect.flatMap(() => propsFor(ref)),
|
|
306
360
|
),
|
|
307
361
|
) as AnyFacade['actions'],
|
|
362
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract slot names
|
|
308
363
|
slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
|
|
309
364
|
frame: settle,
|
|
310
365
|
})
|
|
@@ -313,38 +368,43 @@ export const makeFacade = (
|
|
|
313
368
|
const slotClass = parent.manifest.slots?.[slotName]
|
|
314
369
|
const child = slotClass && bindings.get(slotClass)
|
|
315
370
|
if (child === undefined) {
|
|
371
|
+
// 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
|
|
316
372
|
throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
|
|
317
373
|
}
|
|
318
374
|
const childName = uiNameOf(child)
|
|
319
|
-
const
|
|
320
|
-
Effect.as(renderTree, nodeFacade(childName, index, child))
|
|
321
|
-
const first = nodeFacade(childName, 0, child)
|
|
375
|
+
const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
|
|
376
|
+
Effect.as(renderTree, nodeFacade({ name: childName, index }, child))
|
|
377
|
+
const first = nodeFacade({ name: childName, index: 0 }, child)
|
|
322
378
|
// The render index of the child whose structure-fill `key` matches (plan 06).
|
|
323
379
|
// Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
|
|
324
|
-
// selection is by stable identity, not render order
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
}
|
|
380
|
+
// selection is by stable identity, not render order — `none` when no fill carries it.
|
|
381
|
+
const indexOfKey = (key: string): Option.Option<number> =>
|
|
382
|
+
Arr.findFirstIndex(capturesFor(childName), (capture) => capture.key === key)
|
|
333
383
|
return {
|
|
334
|
-
first:
|
|
335
|
-
at,
|
|
384
|
+
first: atIndex(0),
|
|
385
|
+
at: atIndex,
|
|
336
386
|
all: Effect.map(renderTree, () =>
|
|
337
|
-
capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
|
|
387
|
+
capturesFor(childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
|
|
338
388
|
),
|
|
339
389
|
// 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
|
-
|
|
390
|
+
// first so the fill keys reflect the latest frame, then resolves its facade;
|
|
391
|
+
// dies with `UnknownSlot` (key in the slot position) when no fill carries it.
|
|
392
|
+
byKey: (key) =>
|
|
393
|
+
Effect.flatMap(renderTree, () =>
|
|
394
|
+
Option.match(indexOfKey(key), {
|
|
395
|
+
onNone: () =>
|
|
396
|
+
Effect.dieMessage(
|
|
397
|
+
new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` }).message,
|
|
398
|
+
),
|
|
399
|
+
onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
|
|
400
|
+
}),
|
|
401
|
+
),
|
|
342
402
|
// Every child whose computed props satisfy `predicate`. Re-renders, then maps
|
|
343
403
|
// the matching captures back to their facades by render index.
|
|
344
404
|
where: (predicate) =>
|
|
345
405
|
Effect.map(renderTree, () =>
|
|
346
406
|
capturesFor(childName).flatMap((capture, index) =>
|
|
347
|
-
predicate(capture.props) ? [nodeFacade(childName, index, child)] : [],
|
|
407
|
+
predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
|
|
348
408
|
),
|
|
349
409
|
),
|
|
350
410
|
// How many children this slot mounted this frame — the fill length.
|
|
@@ -358,15 +418,15 @@ export const makeFacade = (
|
|
|
358
418
|
}
|
|
359
419
|
}
|
|
360
420
|
|
|
361
|
-
return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
|
|
421
|
+
return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
|
|
362
422
|
}
|
|
363
423
|
|
|
364
424
|
/** Declared-but-never-dispatched events for a proof's requirement (coverage). */
|
|
365
|
-
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
425
|
+
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
|
|
426
|
+
Option.match(proof.requirement.manifest.events, {
|
|
427
|
+
onNone: () => [],
|
|
428
|
+
onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
|
|
429
|
+
})
|
|
370
430
|
|
|
371
431
|
/**
|
|
372
432
|
* Run one proof against a fresh runtime, returning its pass/fail result. Each
|
|
@@ -377,32 +437,28 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
|
377
437
|
const sink = makeSink()
|
|
378
438
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
379
439
|
const dispatched = new Set<string>()
|
|
380
|
-
|
|
440
|
+
const statement = proof.requirement.manifest.statement
|
|
441
|
+
const program = Effect.gen(function* () {
|
|
381
442
|
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
),
|
|
443
|
+
yield* Effect.forEach(
|
|
444
|
+
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
445
|
+
(event) => publish('High', event),
|
|
386
446
|
)
|
|
387
|
-
|
|
447
|
+
yield* settle
|
|
448
|
+
yield* Effect.gen(() => proof.body(facade))
|
|
388
449
|
const missing = missingCoverage(proof, dispatched)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
error: error instanceof Error ? error.message : String(error),
|
|
402
|
-
}
|
|
403
|
-
} finally {
|
|
404
|
-
await runtime.dispose()
|
|
405
|
-
}
|
|
450
|
+
const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
|
|
451
|
+
return missing.length > 0
|
|
452
|
+
? { requirement: statement, ok: false, error: coverageError }
|
|
453
|
+
: { requirement: statement, ok: true }
|
|
454
|
+
}).pipe(
|
|
455
|
+
Effect.catchAllCause((cause) =>
|
|
456
|
+
Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
const report = await runtime.runPromise(program)
|
|
460
|
+
await runtime.dispose()
|
|
461
|
+
return report
|
|
406
462
|
}
|
|
407
463
|
|
|
408
464
|
/**
|
|
@@ -417,38 +473,48 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
|
417
473
|
export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
418
474
|
const sink = makeSink()
|
|
419
475
|
const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
|
|
420
|
-
const frames:
|
|
476
|
+
const collected: { frames: ReadonlyArray<StepFrame> } = { frames: [] }
|
|
421
477
|
const statement = proof.requirement.manifest.statement
|
|
422
478
|
// The driver's per-frame timeline does not enforce coverage; collect into a
|
|
423
479
|
// throwaway set so `makeFacade`'s contract is satisfied.
|
|
424
480
|
const dispatched = new Set<string>()
|
|
425
|
-
|
|
481
|
+
const record = (index: number): void => {
|
|
482
|
+
collected.frames = [...collected.frames, { index, captures: [...sink.captures] }]
|
|
483
|
+
}
|
|
484
|
+
// Pump the generator: run each yielded effect on the runtime, snapshot, recur.
|
|
485
|
+
const pump = Effect.fn('pump')(function* (
|
|
486
|
+
generator: ReturnType<Proof['body']>,
|
|
487
|
+
step: PumpStep,
|
|
488
|
+
): Effect.fn.Return<void, unknown, CompositionService> {
|
|
489
|
+
const next = generator.next(step.input)
|
|
490
|
+
if (next.done === true) {
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
const output = yield* yieldWrapGet(next.value)
|
|
494
|
+
yield* Effect.sync(() => record(step.index))
|
|
495
|
+
yield* pump(generator, { input: output, index: step.index + 1 })
|
|
496
|
+
})
|
|
497
|
+
const program = Effect.gen(function* () {
|
|
426
498
|
const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
),
|
|
499
|
+
yield* Effect.forEach(
|
|
500
|
+
Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
|
|
501
|
+
(event) => publish('High', event),
|
|
431
502
|
)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
error: error instanceof Error ? error.message : String(error),
|
|
450
|
-
}
|
|
451
|
-
} finally {
|
|
452
|
-
await runtime.dispose()
|
|
453
|
-
}
|
|
503
|
+
yield* settle
|
|
504
|
+
yield* Effect.sync(() => record(0))
|
|
505
|
+
yield* pump(proof.body(facade), { input: undefined, index: 1 })
|
|
506
|
+
return { requirement: statement, frames: collected.frames, ok: true }
|
|
507
|
+
}).pipe(
|
|
508
|
+
Effect.catchAllCause((cause) =>
|
|
509
|
+
Effect.succeed({
|
|
510
|
+
requirement: statement,
|
|
511
|
+
frames: collected.frames,
|
|
512
|
+
ok: false,
|
|
513
|
+
error: messageOf(Cause.squash(cause)),
|
|
514
|
+
}),
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
const timeline = await runtime.runPromise(program)
|
|
518
|
+
await runtime.dispose()
|
|
519
|
+
return timeline
|
|
454
520
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from 'effect'
|
|
1
|
+
import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
|
|
2
2
|
import type { YieldWrap } from 'effect/Utils'
|
|
3
3
|
import { AssertionFailed } from './errors'
|
|
4
4
|
import { ProofRunner, type ProofRunnerApi, proofRunnerLayer, withProofRunner } from './runner'
|
|
@@ -52,7 +52,7 @@ export interface RequirementManifest<Comp extends AnyComposition, Statement exte
|
|
|
52
52
|
* dispatches a declared event fails, so the requirement and its proof can't
|
|
53
53
|
* silently drift (the definition→implementation link past the statement string).
|
|
54
54
|
*/
|
|
55
|
-
readonly events
|
|
55
|
+
readonly events: Option.Option<ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>>
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
@@ -77,20 +77,27 @@ export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
|
77
77
|
* specifies. The composition types the proof facade; the statement's literal
|
|
78
78
|
* type is re-stated (and enforced) at `Proof.implement`.
|
|
79
79
|
*/
|
|
80
|
+
/** Author-facing config for `ProductRequirement.make` — plain JSON the proof author passes. */
|
|
81
|
+
interface MakeRequirementOptionsExternalApi<Comp extends AnyComposition> {
|
|
82
|
+
readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
81
86
|
composition: Comp,
|
|
82
87
|
statement: Statement,
|
|
83
|
-
options?:
|
|
84
|
-
): RequirementClass<Comp, Statement> =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
options?: MakeRequirementOptionsExternalApi<Comp>,
|
|
89
|
+
): RequirementClass<Comp, Statement> => {
|
|
90
|
+
const manifest: RequirementManifest<Comp, Statement> = {
|
|
91
|
+
kind: 'ProductRequirement',
|
|
92
|
+
name: statement,
|
|
93
|
+
statement,
|
|
94
|
+
composition,
|
|
95
|
+
events: Option.fromNullable(options?.events),
|
|
96
|
+
}
|
|
97
|
+
return class {
|
|
98
|
+
static readonly manifest = manifest
|
|
99
|
+
}
|
|
100
|
+
}
|
|
94
101
|
|
|
95
102
|
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
96
103
|
make: makeRequirement,
|
|
@@ -100,18 +107,24 @@ export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
|
100
107
|
* Group a composition with the full list of requirements that specify it. The
|
|
101
108
|
* shared `Comp` type-checks that every requirement targets this composition.
|
|
102
109
|
*/
|
|
110
|
+
interface MakeProductConfig<Comp extends AnyComposition> {
|
|
111
|
+
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
const makeProduct = <Comp extends AnyComposition>(
|
|
104
115
|
composition: Comp,
|
|
105
|
-
config:
|
|
106
|
-
): ProductClass<Comp> =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
config: MakeProductConfig<Comp>,
|
|
117
|
+
): ProductClass<Comp> => {
|
|
118
|
+
const manifest: ProductManifest<Comp> = {
|
|
119
|
+
kind: 'Product',
|
|
120
|
+
name: composition.manifest.name,
|
|
121
|
+
composition,
|
|
122
|
+
requirements: config.requirements,
|
|
123
|
+
}
|
|
124
|
+
return class {
|
|
125
|
+
static readonly manifest = manifest
|
|
126
|
+
}
|
|
127
|
+
}
|
|
115
128
|
|
|
116
129
|
export const Product: { readonly make: typeof makeProduct } = { make: makeProduct }
|
|
117
130
|
|
|
@@ -119,59 +132,79 @@ export const Product: { readonly make: typeof makeProduct } = { make: makeProduc
|
|
|
119
132
|
// Assertions — Effect-returning matchers; a failed match fails the proof.
|
|
120
133
|
// ---------------------------------------------------------------------------
|
|
121
134
|
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
/** Two values to deep-compare — one options object, not positional primitives. */
|
|
136
|
+
interface ComparePair {
|
|
137
|
+
readonly left: unknown
|
|
138
|
+
readonly right: unknown
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Display + structural-compare seam: assertion messages and deep-equality operate on
|
|
142
|
+
// arbitrary `unknown` values, which Schema cannot encode — JSON is the right tool here.
|
|
143
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- arbitrary unknown assertion values, not Schema-typed data
|
|
144
|
+
const show = (subject: unknown): string => JSON.stringify(subject)
|
|
145
|
+
|
|
146
|
+
const deepEqual = ({ left, right }: ComparePair): boolean =>
|
|
147
|
+
Object.is(left, right) || show(left) === show(right)
|
|
124
148
|
|
|
125
149
|
const fail = (message: string): Effect.Effect<never> =>
|
|
126
|
-
Effect.
|
|
127
|
-
throw new AssertionFailed({ detail: message })
|
|
128
|
-
})
|
|
150
|
+
Effect.dieMessage(new AssertionFailed({ detail: message }).message)
|
|
129
151
|
|
|
130
152
|
export const expect = <A>(actual: A) => ({
|
|
131
153
|
toBe: (expected: A): Effect.Effect<void> =>
|
|
132
154
|
Object.is(actual, expected)
|
|
133
155
|
? Effect.void
|
|
134
|
-
: fail(`expected ${
|
|
156
|
+
: fail(`expected ${show(actual)} to be ${show(expected)}`),
|
|
135
157
|
toEqual: (expected: A): Effect.Effect<void> =>
|
|
136
|
-
deepEqual(actual, expected)
|
|
158
|
+
deepEqual({ left: actual, right: expected })
|
|
137
159
|
? Effect.void
|
|
138
|
-
: fail(`expected ${
|
|
160
|
+
: fail(`expected ${show(actual)} to equal ${show(expected)}`),
|
|
139
161
|
toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
|
|
140
|
-
Array.isArray(actual) && actual.some((
|
|
162
|
+
Array.isArray(actual) && actual.some((element) => deepEqual({ left: element, right: expected }))
|
|
141
163
|
? Effect.void
|
|
142
|
-
: fail(`expected ${
|
|
164
|
+
: fail(`expected ${show(actual)} to contain ${show(expected)}`),
|
|
143
165
|
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
144
|
-
const mismatch = matchPartial(actual, expected)
|
|
166
|
+
const mismatch = matchPartial({ actual, expected })
|
|
145
167
|
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
146
168
|
},
|
|
147
169
|
})
|
|
148
170
|
|
|
171
|
+
/** A non-null object as an indexable record — the narrowing `unknown` doesn't give. */
|
|
172
|
+
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
173
|
+
typeof candidate === 'object' && candidate !== null
|
|
174
|
+
|
|
175
|
+
/** The pair `matchPartial`/`matchProps` deep-matches: `actual` against the `expected` subset. */
|
|
176
|
+
interface MatchInput {
|
|
177
|
+
readonly actual: unknown
|
|
178
|
+
readonly expected: unknown
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* Deep-match every key of `expected` against `actual`; returns an error message
|
|
151
183
|
* naming the first failing key, or `undefined` on a full match. Shared by
|
|
152
184
|
* `expect(...).toMatchObject` and the facade's `expectProps`.
|
|
153
185
|
*/
|
|
154
|
-
const matchPartial = (actual
|
|
155
|
-
if (
|
|
156
|
-
return deepEqual(actual, expected)
|
|
186
|
+
const matchPartial = ({ actual, expected }: MatchInput): string | undefined => {
|
|
187
|
+
if (!isRecord(expected)) {
|
|
188
|
+
return deepEqual({ left: actual, right: expected })
|
|
157
189
|
? undefined
|
|
158
|
-
: `expected ${
|
|
190
|
+
: `expected ${show(actual)} to match ${show(expected)}`
|
|
159
191
|
}
|
|
160
|
-
if (
|
|
161
|
-
return `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
|
-
}
|
|
192
|
+
if (!isRecord(actual)) {
|
|
193
|
+
return `expected ${show(actual)} to be an object matching ${show(expected)}`
|
|
169
194
|
}
|
|
170
|
-
|
|
195
|
+
const mismatch = Arr.findFirst(
|
|
196
|
+
Rec.keys(expected),
|
|
197
|
+
(key) => !deepEqual({ left: actual[key], right: expected[key] }),
|
|
198
|
+
)
|
|
199
|
+
return Option.match(mismatch, {
|
|
200
|
+
onNone: () => undefined,
|
|
201
|
+
onSome: (key) =>
|
|
202
|
+
`expected key ${show(key)} to match ${show(expected[key])}, got ${show(actual[key])}`,
|
|
203
|
+
})
|
|
171
204
|
}
|
|
172
205
|
|
|
173
206
|
/** Internal: the facade's `expectProps` reuses the same partial-match logic. */
|
|
174
|
-
export const matchProps: (
|
|
207
|
+
export const matchProps: (input: MatchInput) => string | undefined = matchPartial
|
|
175
208
|
|
|
176
209
|
// ---------------------------------------------------------------------------
|
|
177
210
|
// Facade — a headless view over the live composition tree
|
|
@@ -289,6 +322,7 @@ export interface ProofSuite {
|
|
|
289
322
|
export interface ProofResult {
|
|
290
323
|
readonly requirement: string
|
|
291
324
|
readonly ok: boolean
|
|
325
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the vitest adapter (external); optional kept for wire compat
|
|
292
326
|
readonly error?: string
|
|
293
327
|
}
|
|
294
328
|
|
|
@@ -310,20 +344,27 @@ const implement = <Comp extends AnyComposition>(
|
|
|
310
344
|
requirement: RequirementClass<Comp, string>,
|
|
311
345
|
scene: Scene<ContractOf<Comp>>,
|
|
312
346
|
body: (app: Facade<ContractOf<Comp>>) => ProofGenerator,
|
|
313
|
-
): Proof =>
|
|
347
|
+
): Proof => {
|
|
348
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- contract erasure seam: Facade<C> → AnyFacade is contravariant, sound by construction
|
|
349
|
+
const erased = body as ProofBody
|
|
350
|
+
return { requirement, scene, body: erased }
|
|
351
|
+
}
|
|
314
352
|
|
|
315
|
-
/**
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
): ProofSuite => ({ kind: 'ProofSuite', product, proofs: config.proofs })
|
|
353
|
+
/** The proofs a `Proof.suite` binds to its product. */
|
|
354
|
+
interface MakeSuiteConfig {
|
|
355
|
+
readonly proofs: ReadonlyArray<Proof>
|
|
356
|
+
}
|
|
320
357
|
|
|
321
|
-
|
|
322
|
-
|
|
358
|
+
/** Compose proofs with the product whose requirements they prove. */
|
|
359
|
+
const suite = (product: ProductClass, config: MakeSuiteConfig): ProofSuite => ({
|
|
360
|
+
kind: 'ProofSuite',
|
|
361
|
+
product,
|
|
362
|
+
proofs: config.proofs,
|
|
363
|
+
})
|
|
323
364
|
|
|
324
365
|
/** Duck-type a `ProofSuite` among arbitrary module exports (the adapter's seam). */
|
|
325
|
-
export const isProofSuite = (
|
|
326
|
-
isRecord(
|
|
366
|
+
export const isProofSuite = (candidate: unknown): candidate is ProofSuite =>
|
|
367
|
+
isRecord(candidate) && candidate['kind'] === 'ProofSuite' && Array.isArray(candidate['proofs'])
|
|
327
368
|
|
|
328
369
|
/**
|
|
329
370
|
* Run every proof against a fresh runtime, returning per-requirement results.
|
|
@@ -332,11 +373,14 @@ export const isProofSuite = (value: unknown): value is ProofSuite =>
|
|
|
332
373
|
*/
|
|
333
374
|
const run = (proofSuite: ProofSuite): Promise<SuiteResult> =>
|
|
334
375
|
withProofRunner(async (runner: ProofRunnerApi) => {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
376
|
+
const reports = await Effect.runPromise(
|
|
377
|
+
Effect.forEach(proofSuite.proofs, (proof) => Effect.promise(() => runner.executeProof(proof))),
|
|
378
|
+
)
|
|
379
|
+
return {
|
|
380
|
+
product: proofSuite.product.manifest.name,
|
|
381
|
+
results: reports,
|
|
382
|
+
ok: reports.every((report) => report.ok),
|
|
338
383
|
}
|
|
339
|
-
return { product: proofSuite.product.manifest.name, results, ok: results.every((r) => r.ok) }
|
|
340
384
|
})
|
|
341
385
|
|
|
342
386
|
// ---------------------------------------------------------------------------
|
|
@@ -354,6 +398,7 @@ export interface DriveResult {
|
|
|
354
398
|
readonly requirement: string
|
|
355
399
|
readonly frames: ReadonlyArray<StepFrame>
|
|
356
400
|
readonly ok: boolean
|
|
401
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the editor timeline (external); optional kept for wire compat
|
|
357
402
|
readonly error?: string
|
|
358
403
|
}
|
|
359
404
|
|