@playfast/reform-proof 1.3.0 → 1.4.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/dist/engine.d.ts +2 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +9 -7
- package/dist/engine.js.map +1 -1
- package/dist/engineCoverage.d.ts +11 -0
- package/dist/engineCoverage.d.ts.map +1 -0
- package/dist/engineCoverage.js +48 -0
- package/dist/engineCoverage.js.map +1 -0
- package/dist/engineMounted.d.ts +4 -4
- package/dist/engineMounted.d.ts.map +1 -1
- package/dist/engineMounted.js +9 -4
- package/dist/engineMounted.js.map +1 -1
- package/dist/engineSink.d.ts +18 -1
- package/dist/engineSink.d.ts.map +1 -1
- package/dist/engineSink.js +22 -2
- package/dist/engineSink.js.map +1 -1
- package/dist/engineTreeFacade.d.ts +2 -2
- package/dist/engineTreeFacade.d.ts.map +1 -1
- package/dist/engineTreeFacade.js +42 -8
- package/dist/engineTreeFacade.js.map +1 -1
- package/dist/engineTreeTypes.d.ts +3 -1
- package/dist/engineTreeTypes.d.ts.map +1 -1
- package/dist/engineTreeTypes.js.map +1 -1
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +32 -9
- package/dist/fingerprint.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/coverage-host.test.ts +183 -0
- package/src/engine.ts +15 -22
- package/src/engineCoverage.ts +86 -0
- package/src/engineMounted.ts +16 -13
- package/src/engineSink.ts +70 -2
- package/src/engineTreeFacade.ts +65 -15
- package/src/engineTreeTypes.ts +3 -0
- package/src/fingerprint.ts +45 -10
- package/src/index.ts +1 -1
- package/src/proof-attribution.test.ts +210 -0
- package/src/test-clock.test.ts +16 -6
package/src/engineSink.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Clock,
|
|
3
|
+
type Context,
|
|
4
|
+
type Duration,
|
|
5
|
+
Effect,
|
|
6
|
+
MutableRef,
|
|
7
|
+
Option,
|
|
8
|
+
Record as Rec,
|
|
9
|
+
} from 'effect'
|
|
2
10
|
import { AssertionFailed } from './errors'
|
|
3
11
|
import {
|
|
4
12
|
Composition,
|
|
@@ -16,6 +24,7 @@ import {
|
|
|
16
24
|
type CaptureSinkApi,
|
|
17
25
|
type Instrumentation,
|
|
18
26
|
noopInstrumentation,
|
|
27
|
+
triggerEventTag,
|
|
19
28
|
} from '@playfast/reform/internal'
|
|
20
29
|
import { matchProps } from './assert'
|
|
21
30
|
import type { Facade, SlotFacade, SlotFacadesOf } from './facade'
|
|
@@ -91,7 +100,9 @@ export const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.re
|
|
|
91
100
|
// settle loop would never make progress. Pinning it to a real clock is what lets the
|
|
92
101
|
// two clocks coexist: the harness keeps advancing while the product's timers hold
|
|
93
102
|
// still until `app.advance(...)` moves them.
|
|
94
|
-
export const settleStep: Effect.Effect<void> = Effect.sleep(SETTLE_STEP).pipe(
|
|
103
|
+
export const settleStep: Effect.Effect<void> = Effect.sleep(SETTLE_STEP).pipe(
|
|
104
|
+
Effect.withClock(Clock.make()),
|
|
105
|
+
)
|
|
95
106
|
|
|
96
107
|
export const keyed = <V>(get: (key: string) => V): Record<string, V> => {
|
|
97
108
|
const target: Record<string, V> = Object.create(null)
|
|
@@ -101,6 +112,63 @@ export const keyed = <V>(get: (key: string) => V): Record<string, V> => {
|
|
|
101
112
|
export const messageOf = (error: unknown): string =>
|
|
102
113
|
error instanceof Error ? error.message : String(error)
|
|
103
114
|
|
|
115
|
+
// An action credited to the composition that actually raised it. A bare event name
|
|
116
|
+
// is not enough: two contracts in one tree can expose the same action name, and a
|
|
117
|
+
// requirement's coverage must be answered by *its* composition's action, not by a
|
|
118
|
+
// child that happens to spell its own action the same way. `published` is the domain
|
|
119
|
+
// Event the trigger reached — None for a trigger built outside `Event.trigger` — which
|
|
120
|
+
// is what tells a harness parent re-exposing the child's own action apart from an
|
|
121
|
+
// unrelated composition that merely spells one the same.
|
|
122
|
+
export interface DispatchedAction {
|
|
123
|
+
readonly identity: symbol
|
|
124
|
+
readonly composition: string
|
|
125
|
+
readonly event: string
|
|
126
|
+
readonly published: Option.Option<string>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// What each composition in the tree publishes under a given action name, so coverage
|
|
130
|
+
// can ask that question of a composition the proof body never addressed directly.
|
|
131
|
+
export interface EventPublications {
|
|
132
|
+
readonly observe: (root: TreeNodeShape) => void
|
|
133
|
+
readonly publishedBy: (identity: symbol, action: string) => Option.Option<string>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The slice of a rendered node the publication ledger reads. Declared here rather than
|
|
137
|
+
// imported from the tree types to keep engineSink free of that cycle.
|
|
138
|
+
export interface TreeNodeShape {
|
|
139
|
+
readonly comp: AnyComposition
|
|
140
|
+
readonly events: Record<string, Trigger<unknown>>
|
|
141
|
+
readonly slots: Readonly<Record<string, ReadonlyArray<TreeNodeShape>>>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const makeEventPublications = (): EventPublications => {
|
|
145
|
+
const byComposition = new Map<symbol, Map<string, string>>()
|
|
146
|
+
const record = (node: TreeNodeShape): void => {
|
|
147
|
+
const identity = compositionIdentityOf(node.comp)
|
|
148
|
+
const actions = byComposition.get(identity) ?? new Map<string, string>()
|
|
149
|
+
Rec.toEntries(node.events).forEach(([action, trigger]) =>
|
|
150
|
+
Option.match(triggerEventTag(trigger), {
|
|
151
|
+
onNone: () => {},
|
|
152
|
+
onSome: (tag) => {
|
|
153
|
+
actions.set(action, tag)
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
byComposition.set(identity, actions)
|
|
158
|
+
Rec.toEntries(node.slots).forEach(([_name, children]) => children.forEach(record))
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
observe: record,
|
|
162
|
+
publishedBy: (identity, action) =>
|
|
163
|
+
Option.fromNullable(byComposition.get(identity)).pipe(
|
|
164
|
+
Option.flatMap((actions) => Option.fromNullable(actions.get(action))),
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const compositionIdentityOf = (composition: AnyComposition): symbol =>
|
|
170
|
+
composition.capture<symbol>((exact) => exact.identity)
|
|
171
|
+
|
|
104
172
|
export const uiNameOf = (comp: AnyComposition): string => comp.manifest.ui.manifest.name
|
|
105
173
|
|
|
106
174
|
// UiContract erases event values to never, so this boundary needs no assertion.
|
package/src/engineTreeFacade.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { Array as Arr, type Duration, Effect, Option, TestClock } from 'effect'
|
|
1
|
+
import { Array as Arr, type Duration, Effect, MutableRef, Option, TestClock } from 'effect'
|
|
2
2
|
import { AssertionFailed, NoTestClock, UnknownAction, UnknownSlot } from './errors'
|
|
3
3
|
import { matchProps } from './assert'
|
|
4
4
|
import { type RuntimeHandle, type UiContract } from '@playfast/reform'
|
|
5
|
-
import type
|
|
5
|
+
import { type AnyComposition, triggerEventTag } from '@playfast/reform/internal'
|
|
6
6
|
import {
|
|
7
|
+
compositionIdentityOf,
|
|
8
|
+
type DispatchedAction,
|
|
7
9
|
type HostRuntime,
|
|
8
10
|
keyed,
|
|
11
|
+
makeEventPublications,
|
|
9
12
|
type MountedFacadeErasure,
|
|
10
13
|
type MountedSlotFacadeErasure,
|
|
11
14
|
type RuntimeRootComposition,
|
|
@@ -28,17 +31,25 @@ export function makeRuntimeTreeFacade<
|
|
|
28
31
|
root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
|
|
29
32
|
rootProps: P,
|
|
30
33
|
sink: Sink,
|
|
31
|
-
dispatched: Set<
|
|
34
|
+
dispatched: Set<DispatchedAction>,
|
|
32
35
|
): RuntimeTreeFacade<C>
|
|
33
36
|
export function makeRuntimeTreeFacade(
|
|
34
37
|
runtime: HostRuntime,
|
|
35
38
|
root: AnyComposition,
|
|
36
39
|
rootProps: unknown,
|
|
37
40
|
sink: Sink,
|
|
38
|
-
dispatched: Set<
|
|
41
|
+
dispatched: Set<DispatchedAction>,
|
|
39
42
|
): RuntimeTreeFacadeErasure {
|
|
40
43
|
const driver = makeTreeDriver(runtime, root, rootProps, sink)
|
|
41
|
-
const
|
|
44
|
+
const publications = makeEventPublications()
|
|
45
|
+
// Every resolver bottoms out here, so reading the tree is also when the ledger of
|
|
46
|
+
// "what does each composition publish under this action name" is refreshed. Coverage
|
|
47
|
+
// needs that for compositions the proof body never addressed directly.
|
|
48
|
+
const currentRoot = (): TreeNode => {
|
|
49
|
+
const node = driver.render().root
|
|
50
|
+
publications.observe(node)
|
|
51
|
+
return node
|
|
52
|
+
}
|
|
42
53
|
|
|
43
54
|
// The nested-Feature twin of the mounted facade's `advance`: same virtual clock
|
|
44
55
|
// off the same runtime, settled through this driver instead.
|
|
@@ -49,10 +60,12 @@ export function makeRuntimeTreeFacade(
|
|
|
49
60
|
})
|
|
50
61
|
|
|
51
62
|
// `peek` is the non-dying half of `resolve`: None once the node has left the
|
|
52
|
-
// tree, so an action that removed its own node can still report a result.
|
|
63
|
+
// tree, so an action that removed its own node can still report a result. It is
|
|
64
|
+
// given the node as it stood before the dispatch, because "is this still the node
|
|
65
|
+
// I acted on" cannot be answered from a bare index.
|
|
53
66
|
const nodeFacade = (
|
|
54
67
|
resolve: () => TreeNode,
|
|
55
|
-
peek: () => Option.Option<TreeNode>,
|
|
68
|
+
peek: (before: TreeNode) => Option.Option<TreeNode>,
|
|
56
69
|
): MountedFacadeErasure => ({
|
|
57
70
|
props: Effect.sync(() => resolve().props),
|
|
58
71
|
expectProps: (partial) =>
|
|
@@ -81,12 +94,17 @@ export function makeRuntimeTreeFacade(
|
|
|
81
94
|
}).message,
|
|
82
95
|
)
|
|
83
96
|
}
|
|
84
|
-
dispatched.add(
|
|
97
|
+
dispatched.add({
|
|
98
|
+
identity: compositionIdentityOf(node.comp),
|
|
99
|
+
composition: uiNameOf(node.comp),
|
|
100
|
+
event,
|
|
101
|
+
published: triggerEventTag(trigger),
|
|
102
|
+
})
|
|
85
103
|
trigger(payload)
|
|
86
104
|
yield* driver.settle
|
|
87
105
|
// A self-removing action leaves nothing to re-read; report what the
|
|
88
106
|
// node last rendered instead of dying on the vanished child.
|
|
89
|
-
return Option.match(peek(), {
|
|
107
|
+
return Option.match(peek(node), {
|
|
90
108
|
onNone: () => node.props,
|
|
91
109
|
onSome: (settled) => settled.props,
|
|
92
110
|
})
|
|
@@ -113,15 +131,46 @@ export function makeRuntimeTreeFacade(
|
|
|
113
131
|
}
|
|
114
132
|
return resolved
|
|
115
133
|
}
|
|
116
|
-
const peekAt = (index: number): Option.Option<TreeNode> =>
|
|
117
|
-
Option.fromNullable(children()[index])
|
|
118
134
|
const peekByKey = (key: string): Option.Option<TreeNode> =>
|
|
119
135
|
Arr.findFirst(children(), (candidate) => Option.contains(candidate.key, key))
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
|
|
137
|
+
// Which node an index still refers to, after the slot may have changed under it.
|
|
138
|
+
// Three outcomes, in order: the same node (it survived, wherever it moved to); a
|
|
139
|
+
// node that took its place (a slot of unchanged length swapped one child for
|
|
140
|
+
// another — a placeholder giving way to the real thing); or nothing, because the
|
|
141
|
+
// slot shrank and whatever sits at the index now is a *different row* that slid
|
|
142
|
+
// down. Reporting that neighbour's props is the bug this ordering exists to stop.
|
|
143
|
+
interface PeekAt {
|
|
144
|
+
readonly index: number
|
|
145
|
+
readonly before: TreeNode
|
|
146
|
+
readonly siblingsBefore: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const peekAt = ({ index, before, siblingsBefore }: PeekAt): Option.Option<TreeNode> => {
|
|
150
|
+
const now = children()
|
|
151
|
+
const survived = Arr.findFirst(now, (candidate) =>
|
|
152
|
+
Option.match(before.key, {
|
|
153
|
+
onNone: () => candidate.path === before.path,
|
|
154
|
+
onSome: (key) => Option.contains(candidate.key, key),
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
if (Option.isSome(survived)) {
|
|
158
|
+
return survived
|
|
159
|
+
}
|
|
160
|
+
return now.length === siblingsBefore ? Option.fromNullable(now[index]) : Option.none()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const childAt = (index: number): MountedFacadeErasure => {
|
|
164
|
+
const siblings = MutableRef.make(0)
|
|
165
|
+
return nodeFacade(
|
|
166
|
+
() => {
|
|
167
|
+
const now = children()
|
|
168
|
+
MutableRef.set(siblings, now.length)
|
|
169
|
+
return now[index] ?? missingChild(index)
|
|
170
|
+
},
|
|
171
|
+
(before) => peekAt({ index, before, siblingsBefore: MutableRef.get(siblings) }),
|
|
124
172
|
)
|
|
173
|
+
}
|
|
125
174
|
const childByKey = (key: string): MountedFacadeErasure =>
|
|
126
175
|
nodeFacade(
|
|
127
176
|
() => Option.getOrElse(peekByKey(key), () => missingKey(key)),
|
|
@@ -178,6 +227,7 @@ export function makeRuntimeTreeFacade(
|
|
|
178
227
|
return {
|
|
179
228
|
facade: nodeFacade(currentRoot, () => Option.some(currentRoot())),
|
|
180
229
|
settle: driver.settle,
|
|
230
|
+
publications,
|
|
181
231
|
dispose: driver.dispose,
|
|
182
232
|
}
|
|
183
233
|
}
|
package/src/engineTreeTypes.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Effect, Match, type Option, Record as Rec } from 'effect'
|
|
|
2
2
|
import type { FeatureLoadFailed, SlotFill, Trigger, UiContract } from '@playfast/reform'
|
|
3
3
|
import type { AnyComposition, AnyFeatureBinding } from '@playfast/reform/internal'
|
|
4
4
|
import {
|
|
5
|
+
type EventPublications,
|
|
5
6
|
type HostRuntime,
|
|
6
7
|
type MountedFacade,
|
|
7
8
|
type MountedFacadeHandleErasure,
|
|
@@ -42,10 +43,12 @@ export interface TreeRenderState {
|
|
|
42
43
|
export interface RuntimeTreeFacade<C extends UiContract> {
|
|
43
44
|
readonly facade: MountedFacade<C>
|
|
44
45
|
readonly settle: Effect.Effect<void, never, never>
|
|
46
|
+
readonly publications: EventPublications
|
|
45
47
|
dispose(): void
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export interface RuntimeTreeFacadeErasure extends MountedFacadeHandleErasure {
|
|
51
|
+
readonly publications: EventPublications
|
|
49
52
|
dispose(): void
|
|
50
53
|
}
|
|
51
54
|
|
package/src/fingerprint.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as Arr from 'effect/Array'
|
|
1
2
|
import * as Option from 'effect/Option'
|
|
3
|
+
import * as Order from 'effect/Order'
|
|
2
4
|
|
|
3
5
|
const readPropertyOption = Option.liftThrowable((target: object, key: PropertyKey): unknown =>
|
|
4
6
|
Reflect.get(target, key),
|
|
@@ -14,7 +16,15 @@ const propertyKeyFingerprint = (key: PropertyKey): string =>
|
|
|
14
16
|
? `number-key:${String(key)}`
|
|
15
17
|
: `key:${key.length}:${key}`
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
interface Walk {
|
|
20
|
+
readonly seen: WeakSet<object>
|
|
21
|
+
// Printed sub-fingerprints, so a shared reference is walked once. Unwinding `seen`
|
|
22
|
+
// made the walk follow every *path* rather than every node, which on a DAG that
|
|
23
|
+
// shares a child two ways doubles the work per level — a deep one hangs.
|
|
24
|
+
readonly memo: WeakMap<object, string>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const valueFingerprint = (candidate: unknown, walk: Walk): string => {
|
|
18
28
|
if (typeof candidate === 'bigint') {
|
|
19
29
|
return `bigint:${String(candidate)}`
|
|
20
30
|
}
|
|
@@ -39,12 +49,32 @@ const valueFingerprint = (candidate: unknown, seen: WeakSet<object>): string =>
|
|
|
39
49
|
if (candidate === null) {
|
|
40
50
|
return 'null'
|
|
41
51
|
}
|
|
42
|
-
if (seen.has(candidate)) {
|
|
52
|
+
if (walk.seen.has(candidate)) {
|
|
43
53
|
return '[Circular]'
|
|
44
54
|
}
|
|
45
|
-
|
|
55
|
+
const remembered = walk.memo.get(candidate)
|
|
56
|
+
if (remembered !== undefined) {
|
|
57
|
+
return remembered
|
|
58
|
+
}
|
|
59
|
+
// `seen` marks the path currently being walked, not everything ever visited: it
|
|
60
|
+
// is unwound below. Without that, a value that merely repeats a reference — the
|
|
61
|
+
// same config object under two keys, a DAG rather than a cycle — reads as
|
|
62
|
+
// `[Circular]` on its second appearance, so two equal values fingerprint
|
|
63
|
+
// differently and `toEqual` fails on them.
|
|
64
|
+
walk.seen.add(candidate)
|
|
65
|
+
const printed = structuralFingerprint(candidate, walk)
|
|
66
|
+
walk.seen.delete(candidate)
|
|
67
|
+
// Only a print that did not depend on where the walk had been is reusable: one
|
|
68
|
+
// containing `[Circular]` describes this path, not this value.
|
|
69
|
+
if (!printed.includes('[Circular]')) {
|
|
70
|
+
walk.memo.set(candidate, printed)
|
|
71
|
+
}
|
|
72
|
+
return printed
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const structuralFingerprint = (candidate: object, walk: Walk): string => {
|
|
46
76
|
if (Array.isArray(candidate)) {
|
|
47
|
-
return `[${candidate.map((arrayEntry) => valueFingerprint(arrayEntry,
|
|
77
|
+
return `[${candidate.map((arrayEntry) => valueFingerprint(arrayEntry, walk)).join(',')}]`
|
|
48
78
|
}
|
|
49
79
|
if (candidate instanceof Date) {
|
|
50
80
|
return `date:${String(candidate.getTime())}`
|
|
@@ -53,21 +83,26 @@ const valueFingerprint = (candidate: unknown, seen: WeakSet<object>): string =>
|
|
|
53
83
|
return `map:[${Array.from(
|
|
54
84
|
candidate,
|
|
55
85
|
([mapKey, mapEntry]) =>
|
|
56
|
-
`${valueFingerprint(mapKey,
|
|
86
|
+
`${valueFingerprint(mapKey, walk)}=>${valueFingerprint(mapEntry, walk)}`,
|
|
57
87
|
).join(',')}]`
|
|
58
88
|
}
|
|
59
89
|
if (candidate instanceof Set) {
|
|
60
|
-
return `set:[${Array.from(candidate, (setEntry) => valueFingerprint(setEntry,
|
|
90
|
+
return `set:[${Array.from(candidate, (setEntry) => valueFingerprint(setEntry, walk)).join(
|
|
61
91
|
',',
|
|
62
92
|
)}]`
|
|
63
93
|
}
|
|
64
|
-
|
|
94
|
+
// Sorted by key: an object is the same value however its keys were inserted, so
|
|
95
|
+
// reordering fields in a composition's source must not break a passing proof.
|
|
96
|
+
return `{${Arr.sortWith(
|
|
97
|
+
Reflect.ownKeys(candidate).map((key) => ({ key, printed: propertyKeyFingerprint(key) })),
|
|
98
|
+
(entry) => entry.printed,
|
|
99
|
+
Order.string,
|
|
100
|
+
)
|
|
65
101
|
.map(
|
|
66
|
-
(key)
|
|
67
|
-
`${propertyKeyFingerprint(key)}:${valueFingerprint(readProperty(candidate, key), seen)}`,
|
|
102
|
+
(entry) => `${entry.printed}:${valueFingerprint(readProperty(candidate, entry.key), walk)}`,
|
|
68
103
|
)
|
|
69
104
|
.join(',')}}`
|
|
70
105
|
}
|
|
71
106
|
|
|
72
107
|
export const formatFingerprint = (fingerprintInput: unknown): string =>
|
|
73
|
-
valueFingerprint(fingerprintInput, new WeakSet())
|
|
108
|
+
valueFingerprint(fingerprintInput, { seen: new WeakSet(), memo: new WeakMap() })
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
proofLayer,
|
|
30
30
|
} from './engine'
|
|
31
31
|
export type {
|
|
32
|
+
DispatchedAction,
|
|
32
33
|
MountedFacade,
|
|
33
34
|
MountedSlotFacade,
|
|
34
35
|
MountedSlotFacadesOf,
|
|
@@ -230,4 +231,3 @@ export const Proof: {
|
|
|
230
231
|
readonly driver: typeof driver
|
|
231
232
|
readonly withTestClock: typeof withTestClock
|
|
232
233
|
} = { implement, implementVia, suite, run, runEffect, driver, withTestClock }
|
|
233
|
-
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Layer, Ref, Schema as S } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
Composition,
|
|
5
|
+
each,
|
|
6
|
+
Engine,
|
|
7
|
+
Event,
|
|
8
|
+
mount,
|
|
9
|
+
provide,
|
|
10
|
+
Reducer,
|
|
11
|
+
scene,
|
|
12
|
+
slot,
|
|
13
|
+
State,
|
|
14
|
+
StateGroup,
|
|
15
|
+
type Trigger,
|
|
16
|
+
Ui,
|
|
17
|
+
ui,
|
|
18
|
+
} from '@playfast/reform'
|
|
19
|
+
import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
20
|
+
import type { Proof as ProofValue } from './index'
|
|
21
|
+
|
|
22
|
+
// A list of keyed children, each able to remove itself, over a root that owns its
|
|
23
|
+
// own `bump`. The child contract deliberately exposes an event with the SAME name
|
|
24
|
+
// (`bump`) backed by a DIFFERENT domain event, so "which node was this credited
|
|
25
|
+
// to?" is observable rather than a matter of taste.
|
|
26
|
+
|
|
27
|
+
const Todo = S.Struct({ id: S.String, label: S.String })
|
|
28
|
+
|
|
29
|
+
class HuntTodos extends State.make('huntTodos', S.Array(Todo)) {}
|
|
30
|
+
class HuntBumps extends State.make('huntBumps', S.Number) {}
|
|
31
|
+
class HuntStates extends StateGroup.make(HuntTodos, HuntBumps) {}
|
|
32
|
+
|
|
33
|
+
class HuntRemoved extends Event.make('HuntRemoved', S.Struct({ id: S.String })) {}
|
|
34
|
+
class HuntItemBumped extends Event.make('HuntItemBumped', S.Struct({ id: S.String })) {}
|
|
35
|
+
class HuntRootBumped extends Event.make('HuntRootBumped', S.Struct({})) {}
|
|
36
|
+
|
|
37
|
+
class HuntRemoveReducer extends Reducer.make('HuntRemoveReducer', {
|
|
38
|
+
states: [HuntTodos],
|
|
39
|
+
events: [HuntRemoved],
|
|
40
|
+
}) {}
|
|
41
|
+
const HuntRemoveReducerLive = Reducer.live(HuntRemoveReducer, (todos, event) =>
|
|
42
|
+
todos.filter((todo) => todo.id !== event.id),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
class HuntRootBumpReducer extends Reducer.make('HuntRootBumpReducer', {
|
|
46
|
+
states: [HuntBumps],
|
|
47
|
+
events: [HuntRootBumped],
|
|
48
|
+
}) {}
|
|
49
|
+
const HuntRootBumpReducerLive = Reducer.live(HuntRootBumpReducer, (bumps) => bumps + 1)
|
|
50
|
+
|
|
51
|
+
class HuntItemUi extends ui('HuntItem')<{
|
|
52
|
+
props: { id: string; label: string }
|
|
53
|
+
events: { remove: Trigger<{ id: string }>; bump: Trigger<{ id: string }> }
|
|
54
|
+
}>() {}
|
|
55
|
+
class HuntItem extends Composition.make('HuntItem', {
|
|
56
|
+
title: 'HuntItem',
|
|
57
|
+
props: S.Struct({ id: S.String, label: S.String }),
|
|
58
|
+
events: [HuntRemoved, HuntItemBumped],
|
|
59
|
+
ui: HuntItemUi,
|
|
60
|
+
})<HuntItem>() {}
|
|
61
|
+
const HuntItemLive = Composition.live(HuntItem, function* () {
|
|
62
|
+
const props = yield* HuntItem.props
|
|
63
|
+
const remove = yield* Event.trigger(HuntRemoved)
|
|
64
|
+
const bump = yield* Event.trigger(HuntItemBumped)
|
|
65
|
+
return mount({ props, slots: {}, events: { remove, bump } })
|
|
66
|
+
})
|
|
67
|
+
class HuntItemSlot extends slot('HuntItem')<HuntItemSlot, typeof HuntItem>() {}
|
|
68
|
+
|
|
69
|
+
class HuntListUi extends ui('HuntList')<{
|
|
70
|
+
props: { total: number; bumps: number }
|
|
71
|
+
slots: { Item: HuntItemSlot }
|
|
72
|
+
events: { bump: Trigger<Record<string, never>> }
|
|
73
|
+
}>() {}
|
|
74
|
+
class HuntList extends Composition.make('HuntList', {
|
|
75
|
+
title: 'HuntList',
|
|
76
|
+
states: [HuntStates],
|
|
77
|
+
events: [HuntRootBumped],
|
|
78
|
+
slots: { Item: HuntItemSlot },
|
|
79
|
+
ui: HuntListUi,
|
|
80
|
+
})<HuntList>() {}
|
|
81
|
+
const HuntListLive = Composition.live(HuntList, function* () {
|
|
82
|
+
const todos = yield* StateGroup.select(HuntStates, 'huntTodos')
|
|
83
|
+
const bumps = yield* StateGroup.select(HuntStates, 'huntBumps')
|
|
84
|
+
const bump = yield* Event.trigger(HuntRootBumped)
|
|
85
|
+
return mount({
|
|
86
|
+
props: { total: todos.length, bumps },
|
|
87
|
+
slots: {
|
|
88
|
+
Item: each(todos, {
|
|
89
|
+
key: (todo) => todo.id,
|
|
90
|
+
props: (todo) => ({ id: todo.id, label: todo.label }),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
events: { bump },
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const presentations = Layer.mergeAll(
|
|
98
|
+
provide(
|
|
99
|
+
HuntListUi,
|
|
100
|
+
Ui.make(HuntListUi, () => null),
|
|
101
|
+
),
|
|
102
|
+
provide(
|
|
103
|
+
HuntItemUi,
|
|
104
|
+
Ui.make(HuntItemUi, () => null),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
const Views = Layer.mergeAll(HuntListLive, HuntItemLive, provide(HuntItemSlot, HuntItem)).pipe(
|
|
108
|
+
Layer.provideMerge(presentations),
|
|
109
|
+
)
|
|
110
|
+
const Logic = Layer.mergeAll(HuntRemoveReducerLive, HuntRootBumpReducerLive).pipe(
|
|
111
|
+
Layer.provideMerge(
|
|
112
|
+
Layer.mergeAll(
|
|
113
|
+
Engine,
|
|
114
|
+
StateGroup.live(HuntStates, {
|
|
115
|
+
huntTodos: [
|
|
116
|
+
{ id: 'a', label: 'alpha' },
|
|
117
|
+
{ id: 'b', label: 'beta' },
|
|
118
|
+
{ id: 'c', label: 'gamma' },
|
|
119
|
+
],
|
|
120
|
+
huntBumps: 0,
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
const HuntScene = scene(HuntList, { provide: [Views.pipe(Layer.provideMerge(Logic))] })
|
|
126
|
+
|
|
127
|
+
class CoversListBump extends ProductRequirement.make(HuntList, 'bumps the list itself', {
|
|
128
|
+
events: ['bump'],
|
|
129
|
+
}) {}
|
|
130
|
+
class HuntProduct extends Product.make(HuntList, { requirements: [CoversListBump] }) {}
|
|
131
|
+
|
|
132
|
+
const runProof = (proof: ProofValue) =>
|
|
133
|
+
Effect.promise(() => Proof.run(Proof.suite(HuntProduct, { proofs: [proof] })))
|
|
134
|
+
|
|
135
|
+
// BUG 1 — engine.ts `missingCoverage` matches a requirement's declared events
|
|
136
|
+
// against a flat Set of event NAMES dispatched anywhere in the tree, so a
|
|
137
|
+
// same-named action on a child composition (a different domain Event entirely)
|
|
138
|
+
// silently satisfies coverage declared on the requirement's own composition.
|
|
139
|
+
it.live('requirement event coverage is not credited to the requirement’s composition', () =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
// Control A — a genuine dispatch of the list's own `bump` is covered.
|
|
142
|
+
const rootDispatch = Proof.implement(CoversListBump, HuntScene, function* (app) {
|
|
143
|
+
const after = yield* app.actions.bump({})
|
|
144
|
+
yield* proofExpect(after.bumps).toBe(1)
|
|
145
|
+
})
|
|
146
|
+
const rootResult = yield* runProof(rootDispatch)
|
|
147
|
+
expect(rootResult.results[0]?.error).toBeUndefined()
|
|
148
|
+
expect(rootResult.ok).toBe(true)
|
|
149
|
+
|
|
150
|
+
// Control B — dispatching nothing is reported as uncovered.
|
|
151
|
+
const noDispatch = Proof.implement(CoversListBump, HuntScene, function* (app) {
|
|
152
|
+
yield* app.expectProps({ bumps: 0 })
|
|
153
|
+
})
|
|
154
|
+
const noneResult = yield* runProof(noDispatch)
|
|
155
|
+
expect(noneResult.ok).toBe(false)
|
|
156
|
+
expect(noneResult.results[0]?.error).toContain('never dispatched')
|
|
157
|
+
|
|
158
|
+
// The defect — only the CHILD's `bump` (HuntItemBumped) is dispatched. The
|
|
159
|
+
// proof body itself proves the list's `bump` (HuntRootBumped) never fired,
|
|
160
|
+
// yet the run is reported green with its coverage requirement satisfied.
|
|
161
|
+
const childOnly = Proof.implement(CoversListBump, HuntScene, function* (app) {
|
|
162
|
+
const item = yield* app.slots.Item.first
|
|
163
|
+
yield* item.actions.bump({ id: 'a' })
|
|
164
|
+
yield* app.expectProps({ bumps: 0 })
|
|
165
|
+
})
|
|
166
|
+
const childResult = yield* runProof(childOnly)
|
|
167
|
+
expect(childResult.ok).toBe(false)
|
|
168
|
+
expect(childResult.results[0]?.error).toContain('never dispatched')
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
// BUG 2 — engineTreeFacade.ts resolves a slot child's "did this node survive the
|
|
173
|
+
// action?" peek by INDEX, so an action that removes a non-last child reports the
|
|
174
|
+
// successor that slid into that index as if those were the acting node's props.
|
|
175
|
+
it.live('an action on a slot child resolves to that child’s own props', () =>
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
const observed = yield* Ref.make<ReadonlyArray<string>>([])
|
|
178
|
+
const removals = Proof.implement(CoversListBump, HuntScene, function* (app) {
|
|
179
|
+
yield* app.actions.bump({})
|
|
180
|
+
|
|
181
|
+
// [a, b, c] — remove the HEAD via its index locator.
|
|
182
|
+
const head = yield* app.slots.Item.at(0)
|
|
183
|
+
const afterHead = yield* head.actions.remove({ id: 'a' })
|
|
184
|
+
yield* Ref.update(observed, (seen) => [...seen, afterHead.id])
|
|
185
|
+
|
|
186
|
+
// [b, c] — remove the TAIL via its index locator (index runs past the end).
|
|
187
|
+
const tail = yield* app.slots.Item.at(1)
|
|
188
|
+
const afterTail = yield* tail.actions.remove({ id: 'c' })
|
|
189
|
+
yield* Ref.update(observed, (seen) => [...seen, afterTail.id])
|
|
190
|
+
|
|
191
|
+
// [b] — remove via the key locator.
|
|
192
|
+
const keyed = yield* app.slots.Item.byKey('b')
|
|
193
|
+
const afterKeyed = yield* keyed.actions.remove({ id: 'b' })
|
|
194
|
+
yield* Ref.update(observed, (seen) => [...seen, afterKeyed.id])
|
|
195
|
+
})
|
|
196
|
+
const result = yield* runProof(removals)
|
|
197
|
+
expect(result.results[0]?.error).toBeUndefined()
|
|
198
|
+
|
|
199
|
+
const seen = yield* Ref.get(observed)
|
|
200
|
+
expect(seen.length).toBe(3)
|
|
201
|
+
// Control A — removing the LAST child leaves nothing at that index, so the
|
|
202
|
+
// harness correctly reports the acting node's last props.
|
|
203
|
+
expect(seen[1]).toBe('c')
|
|
204
|
+
// Control B — the key locator misses after removal, likewise correct.
|
|
205
|
+
expect(seen[2]).toBe('b')
|
|
206
|
+
// The defect — removing a NON-last child still finds "something" at index 0,
|
|
207
|
+
// so the action resolves to sibling `b`'s props instead of `a`'s.
|
|
208
|
+
expect(seen[0]).toBe('a')
|
|
209
|
+
}),
|
|
210
|
+
)
|
package/src/test-clock.test.ts
CHANGED
|
@@ -66,7 +66,10 @@ const FlashLive = Composition.live(Flash, function* () {
|
|
|
66
66
|
return mount({ props: { flash }, slots: {}, events: { submit } })
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
-
const presentations = provide(
|
|
69
|
+
const presentations = provide(
|
|
70
|
+
FlashUi,
|
|
71
|
+
Ui.make(FlashUi, () => null),
|
|
72
|
+
)
|
|
70
73
|
const Views = FlashLive.pipe(Layer.provideMerge(presentations))
|
|
71
74
|
const Logic = Layer.mergeAll(EndFlashLive, FlashTimerLive, Channel.live(FlashLane)).pipe(
|
|
72
75
|
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(FlashStates, { flash: true }))),
|
|
@@ -83,13 +86,20 @@ class FlashProduct extends Product.make(Flash, { requirements: [ClearsFlash] })
|
|
|
83
86
|
|
|
84
87
|
describe('Proof.withTestClock', () => {
|
|
85
88
|
test('app.advance elapses a product timer without spending real time', async () => {
|
|
89
|
+
// An hour of product time, not the 1500ms window. The ratio is what keeps this an
|
|
90
|
+
// assertion about the clock rather than about the runner: virtual time costs the
|
|
91
|
+
// same whether it is a second or an hour, so the budget below sits ~15x above what
|
|
92
|
+
// this harness really spends (measured 370-710ms) and ~360x below what the real
|
|
93
|
+
// clock would charge. The 750ms budget it replaces sat inside that measured range,
|
|
94
|
+
// which is why it failed on a loaded runner.
|
|
95
|
+
const LONG = Duration.hours(1)
|
|
86
96
|
const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
|
|
87
97
|
yield* proofExpect((yield* app.props).flash).toBe(true)
|
|
88
98
|
yield* app.actions.submit({})
|
|
89
99
|
// The timer is armed but no virtual time has passed, so the flash still shows.
|
|
90
100
|
// On the real clock this frame is a race; here it is a fact.
|
|
91
101
|
yield* proofExpect((yield* app.props).flash).toBe(true)
|
|
92
|
-
yield* app.advance(
|
|
102
|
+
yield* app.advance(LONG)
|
|
93
103
|
yield* proofExpect((yield* app.props).flash).toBe(false)
|
|
94
104
|
})
|
|
95
105
|
|
|
@@ -99,10 +109,10 @@ describe('Proof.withTestClock', () => {
|
|
|
99
109
|
|
|
100
110
|
expect(result.results.map((entry) => entry.error)).toEqual([undefined])
|
|
101
111
|
expect(result.ok).toBe(true)
|
|
102
|
-
// The assertion that matters:
|
|
103
|
-
// real time.
|
|
104
|
-
//
|
|
105
|
-
expect(elapsed).toBeLessThan(
|
|
112
|
+
// The assertion that matters: an hour of product time cost well under a second of
|
|
113
|
+
// real time. A regression to the real clock cannot squeeze past this — it would
|
|
114
|
+
// have to finish an hour of sleeping in ten.
|
|
115
|
+
expect(elapsed).toBeLessThan(10_000)
|
|
106
116
|
})
|
|
107
117
|
|
|
108
118
|
test('the timer does not fire on its own — only advancing moves it', async () => {
|