@playfast/reform-proof 1.1.1 → 1.2.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/README.md +12 -12
- package/package.json +18 -18
- package/src/{assertions.ts → assert.ts} +3 -3
- package/src/engine.ts +285 -8
- package/src/engineMounted.ts +271 -0
- package/src/engineMounts.ts +165 -0
- package/src/engineRender.ts +136 -0
- package/src/engineSink.ts +195 -0
- package/src/engineTree.ts +298 -0
- package/src/engineTreeDriver.ts +247 -0
- package/src/engineTreeFacade.ts +183 -0
- package/src/{runtimeTreeTypes.ts → engineTreeTypes.ts} +26 -22
- package/src/errors.ts +19 -0
- package/src/facade.ts +71 -251
- package/src/fingerprint.test.ts +24 -0
- package/src/fingerprint.ts +73 -0
- package/src/index.ts +165 -115
- package/src/nested-feature.test.ts +174 -0
- package/src/product.ts +50 -19
- package/src/proofCase.ts +46 -0
- package/src/runner.ts +5 -1
- package/src/structure-events.test.ts +9 -3
- package/src/structure-locators.test.ts +19 -9
- package/src/test-clock.test.ts +135 -0
- package/src/typed-seams.test.ts +6 -3
- package/src/driverTypes.ts +0 -19
- package/src/execution.ts +0 -100
- package/src/runtimeFacade.ts +0 -269
- package/src/runtimeTreeDispose.ts +0 -21
- package/src/runtimeTreeDriver.ts +0 -285
- package/src/runtimeTreeFacade.ts +0 -134
- package/src/runtimeTreeSettle.ts +0 -34
- package/src/sink.ts +0 -73
- package/src/treeBindings.ts +0 -121
package/src/runner.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from 'effect'
|
|
2
|
-
import { driveProof, executeProof } from './
|
|
2
|
+
import { driveProof, driveProofEffect, executeProof, executeProofEffect } from './engine'
|
|
3
3
|
import type { DriveResult, Proof, ProofResult } from './index'
|
|
4
4
|
|
|
5
5
|
export interface ProofRunnerApi {
|
|
6
|
+
readonly executeProofEffect: (proof: Proof) => Effect.Effect<ProofResult, never, never>
|
|
7
|
+
readonly driveProofEffect: (proof: Proof) => Effect.Effect<DriveResult, never, never>
|
|
6
8
|
readonly executeProof: (proof: Proof) => Promise<ProofResult>
|
|
7
9
|
readonly driveProof: (proof: Proof) => Promise<DriveResult>
|
|
8
10
|
}
|
|
@@ -13,6 +15,8 @@ const ProofRunnerBase: Context.TagClass<ProofRunner, 'reform-proof/ProofRunner',
|
|
|
13
15
|
export class ProofRunner extends ProofRunnerBase {}
|
|
14
16
|
|
|
15
17
|
export const proofRunnerLayer: Layer.Layer<ProofRunner> = Layer.succeed(ProofRunner, {
|
|
18
|
+
executeProofEffect,
|
|
19
|
+
driveProofEffect,
|
|
16
20
|
executeProof,
|
|
17
21
|
driveProof,
|
|
18
22
|
})
|
|
@@ -19,7 +19,10 @@ import { Product, Proof, ProductRequirement, expect as proofExpect } from './ind
|
|
|
19
19
|
class CountState extends State.make('scount', S.Number) {}
|
|
20
20
|
class MiniStates extends StateGroup.make(CountState) {}
|
|
21
21
|
class Bumped extends Event.make('SBumped', S.Struct({})) {}
|
|
22
|
-
class BumpReducer extends Reducer.make('SBumpReducer', {
|
|
22
|
+
class BumpReducer extends Reducer.make('SBumpReducer', {
|
|
23
|
+
states: [CountState],
|
|
24
|
+
events: [Bumped],
|
|
25
|
+
}) {}
|
|
23
26
|
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
24
27
|
|
|
25
28
|
class CounterUi extends ui('SCounter')<{
|
|
@@ -31,14 +34,17 @@ class Counter extends Composition.make('SCounter', {
|
|
|
31
34
|
states: [MiniStates],
|
|
32
35
|
events: [Bumped],
|
|
33
36
|
ui: CounterUi,
|
|
34
|
-
}) {}
|
|
37
|
+
})<Counter>() {}
|
|
35
38
|
const CounterLive = Composition.live(Counter, function* () {
|
|
36
39
|
const count = yield* StateGroup.select(MiniStates, 'scount')
|
|
37
40
|
const bump = yield* Event.trigger(Bumped)
|
|
38
41
|
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
39
42
|
})
|
|
40
43
|
|
|
41
|
-
const presentations = provide(
|
|
44
|
+
const presentations = provide(
|
|
45
|
+
CounterUi,
|
|
46
|
+
Ui.make(CounterUi, () => null),
|
|
47
|
+
)
|
|
42
48
|
const Views = CounterLive.pipe(Layer.provideMerge(presentations))
|
|
43
49
|
const Logic = BumpReducerLive.pipe(
|
|
44
50
|
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { scount: 5 }))),
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
Engine,
|
|
5
5
|
each,
|
|
6
6
|
mount,
|
|
7
|
-
Props,
|
|
8
7
|
provide,
|
|
9
8
|
scene,
|
|
10
9
|
slot,
|
|
@@ -22,13 +21,15 @@ class TodosState extends State.make(
|
|
|
22
21
|
) {}
|
|
23
22
|
class TodoStates extends StateGroup.make(TodosState) {}
|
|
24
23
|
|
|
25
|
-
class ItemUi extends ui('LocItem')<{
|
|
24
|
+
class ItemUi extends ui('LocItem')<{
|
|
25
|
+
props: { id: string; done: boolean }
|
|
26
|
+
}>() {}
|
|
26
27
|
class Item extends Composition.make('LocItem', {
|
|
27
28
|
title: 'LocItem',
|
|
28
29
|
props: S.Struct({ id: S.String, done: S.Boolean }),
|
|
29
30
|
ui: ItemUi,
|
|
30
|
-
}) {}
|
|
31
|
-
class ItemSlot extends slot('LocItem')<typeof Item>() {}
|
|
31
|
+
})<Item>() {}
|
|
32
|
+
class ItemSlot extends slot('LocItem')<ItemSlot, typeof Item>() {}
|
|
32
33
|
|
|
33
34
|
class ListUi extends ui('LocList')<{
|
|
34
35
|
props: { total: number }
|
|
@@ -39,25 +40,34 @@ class List extends Composition.make('LocList', {
|
|
|
39
40
|
states: [TodoStates],
|
|
40
41
|
slots: { Item: ItemSlot },
|
|
41
42
|
ui: ListUi,
|
|
42
|
-
}) {}
|
|
43
|
+
})<List>() {}
|
|
43
44
|
|
|
44
45
|
const ListLive = Composition.live(List, function* () {
|
|
45
46
|
const todos = yield* StateGroup.select(TodoStates, 'locTodos')
|
|
46
47
|
return mount({
|
|
47
48
|
props: { total: todos.length },
|
|
48
49
|
slots: {
|
|
49
|
-
Item: each(todos, {
|
|
50
|
+
Item: each(todos, {
|
|
51
|
+
key: (t) => t.id,
|
|
52
|
+
props: (t) => ({ id: t.id, done: t.done }),
|
|
53
|
+
}),
|
|
50
54
|
},
|
|
51
55
|
})
|
|
52
56
|
})
|
|
53
57
|
const ItemLive = Composition.live(Item, function* () {
|
|
54
|
-
const props
|
|
58
|
+
const props = yield* Item.props
|
|
55
59
|
return mount({ props, slots: {} })
|
|
56
60
|
})
|
|
57
61
|
|
|
58
62
|
const presentations = Layer.mergeAll(
|
|
59
|
-
provide(
|
|
60
|
-
|
|
63
|
+
provide(
|
|
64
|
+
ListUi,
|
|
65
|
+
Ui.make(ListUi, () => null),
|
|
66
|
+
),
|
|
67
|
+
provide(
|
|
68
|
+
ItemUi,
|
|
69
|
+
Ui.make(ItemUi, () => null),
|
|
70
|
+
),
|
|
61
71
|
)
|
|
62
72
|
const wiring = provide(ItemSlot, Item)
|
|
63
73
|
const Views = Layer.mergeAll(ListLive, ItemLive, wiring).pipe(Layer.provideMerge(presentations))
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Channel,
|
|
4
|
+
Composition,
|
|
5
|
+
Engine,
|
|
6
|
+
Event,
|
|
7
|
+
mount,
|
|
8
|
+
Procedure,
|
|
9
|
+
provide,
|
|
10
|
+
publish,
|
|
11
|
+
Reducer,
|
|
12
|
+
scene,
|
|
13
|
+
State,
|
|
14
|
+
StateGroup,
|
|
15
|
+
type Trigger,
|
|
16
|
+
Ui,
|
|
17
|
+
ui,
|
|
18
|
+
} from '@playfast/reform'
|
|
19
|
+
import { describe, expect, test } from 'vitest'
|
|
20
|
+
import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
21
|
+
|
|
22
|
+
// A scene's product timers live inside its procedures, on the real clock: a proof
|
|
23
|
+
// could only observe one by waiting it out, which spends real seconds AND races the
|
|
24
|
+
// runner (a fixed sleep past the timer assumes the woken fiber is scheduled before
|
|
25
|
+
// the next read). `Proof.withTestClock` puts a virtual clock behind the scene so
|
|
26
|
+
// `app.advance(...)` moves that time exactly, instantly, and only on demand.
|
|
27
|
+
|
|
28
|
+
const FLASH = Duration.millis(1500)
|
|
29
|
+
|
|
30
|
+
class FlashState extends State.make('flash', S.Boolean) {}
|
|
31
|
+
class FlashStates extends StateGroup.make(FlashState) {}
|
|
32
|
+
|
|
33
|
+
class Submitted extends Event.make('TCSubmitted', S.Struct({})) {}
|
|
34
|
+
class FlashEnded extends Event.make('TCFlashEnded', S.Struct({})) {}
|
|
35
|
+
|
|
36
|
+
class EndFlash extends Reducer.make('TCEndFlash', {
|
|
37
|
+
states: [FlashState],
|
|
38
|
+
events: [FlashEnded],
|
|
39
|
+
}) {}
|
|
40
|
+
const EndFlashLive = Reducer.live(EndFlash, () => false)
|
|
41
|
+
|
|
42
|
+
class FlashLane extends Channel.make('TCFlashLane', { policy: { _tag: 'latest' } }) {}
|
|
43
|
+
class FlashTimer extends Procedure.make('TCFlashTimer', {
|
|
44
|
+
events: [Submitted],
|
|
45
|
+
channel: FlashLane,
|
|
46
|
+
}) {}
|
|
47
|
+
// The product timer under test: a success flash that clears itself 1500ms later.
|
|
48
|
+
const FlashTimerLive = Procedure.live(FlashTimer, function* () {
|
|
49
|
+
yield* Effect.sleep(FLASH)
|
|
50
|
+
yield* publish('Normal', Event.construct(FlashEnded, {}))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
class FlashUi extends ui('TCFlash')<{
|
|
54
|
+
props: { flash: boolean }
|
|
55
|
+
events: { submit: Trigger<Record<string, never>> }
|
|
56
|
+
}>() {}
|
|
57
|
+
class Flash extends Composition.make('TCFlash', {
|
|
58
|
+
title: 'TCFlash',
|
|
59
|
+
states: [FlashStates],
|
|
60
|
+
events: [Submitted],
|
|
61
|
+
ui: FlashUi,
|
|
62
|
+
})<Flash>() {}
|
|
63
|
+
const FlashLive = Composition.live(Flash, function* () {
|
|
64
|
+
const flash = yield* StateGroup.select(FlashStates, 'flash')
|
|
65
|
+
const submit = yield* Event.trigger(Submitted)
|
|
66
|
+
return mount({ props: { flash }, slots: {}, events: { submit } })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const presentations = provide(FlashUi, Ui.make(FlashUi, () => null))
|
|
70
|
+
const Views = FlashLive.pipe(Layer.provideMerge(presentations))
|
|
71
|
+
const Logic = Layer.mergeAll(EndFlashLive, FlashTimerLive, Channel.live(FlashLane)).pipe(
|
|
72
|
+
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(FlashStates, { flash: true }))),
|
|
73
|
+
)
|
|
74
|
+
const App = Views.pipe(Layer.provideMerge(Logic))
|
|
75
|
+
|
|
76
|
+
const VirtualScene = scene(Flash, { provide: [Proof.withTestClock(App)] })
|
|
77
|
+
const RealScene = scene(Flash, { provide: [App] })
|
|
78
|
+
|
|
79
|
+
class ClearsFlash extends ProductRequirement.make(Flash, 'clears the flash after the window', {
|
|
80
|
+
events: ['submit'],
|
|
81
|
+
}) {}
|
|
82
|
+
class FlashProduct extends Product.make(Flash, { requirements: [ClearsFlash] }) {}
|
|
83
|
+
|
|
84
|
+
describe('Proof.withTestClock', () => {
|
|
85
|
+
test('app.advance elapses a product timer without spending real time', async () => {
|
|
86
|
+
const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
|
|
87
|
+
yield* proofExpect((yield* app.props).flash).toBe(true)
|
|
88
|
+
yield* app.actions.submit({})
|
|
89
|
+
// The timer is armed but no virtual time has passed, so the flash still shows.
|
|
90
|
+
// On the real clock this frame is a race; here it is a fact.
|
|
91
|
+
yield* proofExpect((yield* app.props).flash).toBe(true)
|
|
92
|
+
yield* app.advance(FLASH)
|
|
93
|
+
yield* proofExpect((yield* app.props).flash).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const startedAt = performance.now()
|
|
97
|
+
const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
|
|
98
|
+
const elapsed = performance.now() - startedAt
|
|
99
|
+
|
|
100
|
+
expect(result.results.map((entry) => entry.error)).toEqual([undefined])
|
|
101
|
+
expect(result.ok).toBe(true)
|
|
102
|
+
// The assertion that matters: 1500ms of product time cost a fraction of it in
|
|
103
|
+
// real time. Generous enough not to flake on a loaded runner, tight enough that
|
|
104
|
+
// a regression to the real clock (>= 1500ms) fails here.
|
|
105
|
+
expect(elapsed).toBeLessThan(750)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('the timer does not fire on its own — only advancing moves it', async () => {
|
|
109
|
+
// The complement of the test above: if the virtual clock were a no-op and the
|
|
110
|
+
// procedure were really sleeping on the wall clock, this proof would go green the
|
|
111
|
+
// moment the settle loop outlasted the window. It must stay red instead.
|
|
112
|
+
const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
|
|
113
|
+
yield* app.actions.submit({})
|
|
114
|
+
yield* app.frame
|
|
115
|
+
yield* app.frame
|
|
116
|
+
yield* proofExpect((yield* app.props).flash).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
|
|
120
|
+
|
|
121
|
+
expect(result.ok).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('app.advance against the real clock dies rather than silently no-opping', async () => {
|
|
125
|
+
const proof = Proof.implement(ClearsFlash, RealScene, function* (app) {
|
|
126
|
+
yield* app.actions.submit({})
|
|
127
|
+
yield* app.advance(FLASH)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
|
|
131
|
+
|
|
132
|
+
expect(result.ok).toBe(false)
|
|
133
|
+
expect(result.results[0]?.error).toContain('Proof.withTestClock')
|
|
134
|
+
})
|
|
135
|
+
})
|
package/src/typed-seams.test.ts
CHANGED
|
@@ -25,24 +25,27 @@ class BumpReducer extends Reducer.make('BumpReducer', { states: [CountState], ev
|
|
|
25
25
|
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
26
26
|
|
|
27
27
|
class LabelUi extends ui('Label')<{ props: { text: string } }>() {}
|
|
28
|
-
|
|
28
|
+
|
|
29
|
+
class Label extends Composition.make('Label', { title: 'Label', ui: LabelUi })<Label>() {}
|
|
29
30
|
const LabelLive = Composition.live(Label, function* () {
|
|
30
31
|
return mount({ props: { text: 'count' }, slots: {} })
|
|
31
32
|
})
|
|
32
|
-
|
|
33
|
+
|
|
34
|
+
class LabelSlot extends slot('Label')<LabelSlot, typeof Label>() {}
|
|
33
35
|
|
|
34
36
|
class CounterUi extends ui('Counter')<{
|
|
35
37
|
props: { count: number }
|
|
36
38
|
slots: { Label: LabelSlot }
|
|
37
39
|
events: { bump: Trigger<Record<string, never>> }
|
|
38
40
|
}>() {}
|
|
41
|
+
|
|
39
42
|
class Counter extends Composition.make('Counter', {
|
|
40
43
|
title: 'Counter',
|
|
41
44
|
states: [MiniStates],
|
|
42
45
|
events: [Bumped],
|
|
43
46
|
slots: { Label: LabelSlot },
|
|
44
47
|
ui: CounterUi,
|
|
45
|
-
}) {}
|
|
48
|
+
})<Counter>() {}
|
|
46
49
|
const CounterLive = Composition.live(Counter, function* () {
|
|
47
50
|
const count = yield* StateGroup.select(MiniStates, 'count')
|
|
48
51
|
const bump = yield* Event.trigger(Bumped)
|
package/src/driverTypes.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { UiCapture } from '@playfast/reform'
|
|
2
|
-
|
|
3
|
-
export interface StepFrame {
|
|
4
|
-
readonly index: number
|
|
5
|
-
readonly captures: ReadonlyArray<UiCapture>
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface DriveResult {
|
|
9
|
-
readonly requirement: string
|
|
10
|
-
readonly frames: ReadonlyArray<StepFrame>
|
|
11
|
-
readonly ok: boolean
|
|
12
|
-
// 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
|
|
13
|
-
readonly error?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface ProofDriver {
|
|
17
|
-
readonly requirement: string
|
|
18
|
-
readonly run: () => Promise<DriveResult>
|
|
19
|
-
}
|
package/src/execution.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { Cause, Effect, Option, Ref } from 'effect'
|
|
2
|
-
import { yieldWrapGet } from 'effect/Utils'
|
|
3
|
-
import {
|
|
4
|
-
Bus,
|
|
5
|
-
type CompositionService,
|
|
6
|
-
publish,
|
|
7
|
-
type Scene,
|
|
8
|
-
sceneInstrumentation,
|
|
9
|
-
} from '@playfast/reform'
|
|
10
|
-
import type { DriveResult, Proof, ProofResult, StepFrame } from './index'
|
|
11
|
-
import { makeFacadeEffect } from './facade'
|
|
12
|
-
import { makeSink, messageOf, proofLayer, type RuntimeServices, type Sink } from './sink'
|
|
13
|
-
import type { PumpStep } from './treeBindings'
|
|
14
|
-
|
|
15
|
-
const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
|
|
16
|
-
Option.match(proof.requirement.manifest.events, {
|
|
17
|
-
onNone: () => [],
|
|
18
|
-
onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
const bootScene = (scene: Scene): Effect.Effect<void, never, Bus> =>
|
|
22
|
-
Effect.forEach(
|
|
23
|
-
Option.getOrElse(Option.fromNullable(scene.boot), () => []),
|
|
24
|
-
(event) => publish('High', event),
|
|
25
|
-
).pipe(Effect.asVoid)
|
|
26
|
-
|
|
27
|
-
const executeProofEffect = Effect.fn('executeProofEffect')(function* (
|
|
28
|
-
proof: Proof,
|
|
29
|
-
sink: Sink,
|
|
30
|
-
): Effect.fn.Return<ProofResult, never, RuntimeServices> {
|
|
31
|
-
const dispatched = new Set<string>()
|
|
32
|
-
const statement = proof.requirement.manifest.statement
|
|
33
|
-
return yield* Effect.gen(function* () {
|
|
34
|
-
const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
|
|
35
|
-
yield* bootScene(proof.scene)
|
|
36
|
-
yield* settle
|
|
37
|
-
yield* Effect.gen(() => proof.body(facade))
|
|
38
|
-
const missing = missingCoverage(proof, dispatched)
|
|
39
|
-
const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
|
|
40
|
-
return missing.length > 0
|
|
41
|
-
? { requirement: statement, ok: false, error: coverageError }
|
|
42
|
-
: { requirement: statement, ok: true }
|
|
43
|
-
}).pipe(
|
|
44
|
-
Effect.catchAllCause((cause) =>
|
|
45
|
-
Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
|
|
46
|
-
),
|
|
47
|
-
)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
export const executeProof = async (proof: Proof): Promise<ProofResult> => {
|
|
51
|
-
const sink = makeSink(sceneInstrumentation(proof.scene))
|
|
52
|
-
return Effect.runPromise(executeProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const driveProofEffect = Effect.fn('driveProofEffect')(function* (
|
|
56
|
-
proof: Proof,
|
|
57
|
-
sink: Sink,
|
|
58
|
-
): Effect.fn.Return<DriveResult, never, RuntimeServices> {
|
|
59
|
-
const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
|
|
60
|
-
const statement = proof.requirement.manifest.statement
|
|
61
|
-
const dispatched = new Set<string>()
|
|
62
|
-
const record = (index: number): Effect.Effect<void> =>
|
|
63
|
-
Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
|
|
64
|
-
const pump = Effect.fn('pump')(function* (
|
|
65
|
-
generator: ReturnType<Proof['body']>,
|
|
66
|
-
step: PumpStep,
|
|
67
|
-
): Effect.fn.Return<void, unknown, CompositionService> {
|
|
68
|
-
const next = generator.next(step.input)
|
|
69
|
-
if (next.done === true) {
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
const output = yield* yieldWrapGet(next.value)
|
|
73
|
-
yield* record(step.index)
|
|
74
|
-
yield* pump(generator, { input: output, index: step.index + 1 })
|
|
75
|
-
})
|
|
76
|
-
return yield* Effect.gen(function* () {
|
|
77
|
-
const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
|
|
78
|
-
yield* bootScene(proof.scene)
|
|
79
|
-
yield* settle
|
|
80
|
-
yield* record(0)
|
|
81
|
-
yield* pump(proof.body(facade), { input: undefined, index: 1 })
|
|
82
|
-
return { requirement: statement, frames: yield* Ref.get(frames), ok: true }
|
|
83
|
-
}).pipe(
|
|
84
|
-
Effect.catchAllCause((cause) =>
|
|
85
|
-
Ref.get(frames).pipe(
|
|
86
|
-
Effect.map((collected) => ({
|
|
87
|
-
requirement: statement,
|
|
88
|
-
frames: collected,
|
|
89
|
-
ok: false,
|
|
90
|
-
error: messageOf(Cause.squash(cause)),
|
|
91
|
-
})),
|
|
92
|
-
),
|
|
93
|
-
),
|
|
94
|
-
)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
export const driveProof = async (proof: Proof): Promise<DriveResult> => {
|
|
98
|
-
const sink = makeSink(sceneInstrumentation(proof.scene))
|
|
99
|
-
return Effect.runPromise(driveProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
|
|
100
|
-
}
|
package/src/runtimeFacade.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
|
|
2
|
-
import {
|
|
3
|
-
Composition,
|
|
4
|
-
type CompositionClass,
|
|
5
|
-
isStructure,
|
|
6
|
-
type RenderEnv,
|
|
7
|
-
type RuntimeHandle,
|
|
8
|
-
type SlotClass,
|
|
9
|
-
type Trigger,
|
|
10
|
-
} from '@playfast/reform'
|
|
11
|
-
import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
|
|
12
|
-
import { matchProps } from './assertions'
|
|
13
|
-
import { capturesFor, fingerprint } from './facade'
|
|
14
|
-
import {
|
|
15
|
-
keyed,
|
|
16
|
-
SETTLE_MAX_RENDERS,
|
|
17
|
-
SETTLE_STEP,
|
|
18
|
-
settleDrain,
|
|
19
|
-
type Sink,
|
|
20
|
-
uiNameOf,
|
|
21
|
-
} from './sink'
|
|
22
|
-
import {
|
|
23
|
-
childComposition,
|
|
24
|
-
driveStructure,
|
|
25
|
-
type Mounted,
|
|
26
|
-
type NodeRef,
|
|
27
|
-
type SettleProgress,
|
|
28
|
-
slotsOf,
|
|
29
|
-
} from './treeBindings'
|
|
30
|
-
|
|
31
|
-
export interface MountedFacade {
|
|
32
|
-
readonly props: Effect.Effect<unknown, never, never>
|
|
33
|
-
readonly expectProps: (partial: unknown) => Effect.Effect<void, never, never>
|
|
34
|
-
readonly actions: Record<string, (payload: unknown) => Effect.Effect<unknown, never, never>>
|
|
35
|
-
readonly slots: Record<string, MountedSlotFacade>
|
|
36
|
-
readonly frame: Effect.Effect<void, never, never>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface MountedSlotFacade extends MountedFacade {
|
|
40
|
-
readonly first: Effect.Effect<MountedFacade, never, never>
|
|
41
|
-
readonly at: (index: number) => Effect.Effect<MountedFacade, never, never>
|
|
42
|
-
readonly all: Effect.Effect<ReadonlyArray<MountedFacade>, never, never>
|
|
43
|
-
readonly byKey: (key: string) => Effect.Effect<MountedFacade, never, never>
|
|
44
|
-
readonly where: (
|
|
45
|
-
predicate: (props: unknown) => boolean,
|
|
46
|
-
) => Effect.Effect<ReadonlyArray<MountedFacade>, never, never>
|
|
47
|
-
readonly count: Effect.Effect<number, never, never>
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const collectRuntimeBindings = (
|
|
51
|
-
root: CompositionClass<unknown>,
|
|
52
|
-
runtime: RuntimeHandle,
|
|
53
|
-
): Map<SlotClass, CompositionClass<unknown>> => {
|
|
54
|
-
const bindings = new Map<SlotClass, CompositionClass<unknown>>()
|
|
55
|
-
const walk = (comp: CompositionClass<unknown>): void => {
|
|
56
|
-
Rec.values(slotsOf(comp)).forEach((slotClass) => {
|
|
57
|
-
if (bindings.has(slotClass)) {
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
const child = childComposition(runtime.read(slotClass.tag))
|
|
61
|
-
bindings.set(slotClass, child)
|
|
62
|
-
walk(child)
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
walk(root)
|
|
66
|
-
return bindings
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const renderMountedRuntime = (
|
|
70
|
-
runtime: RuntimeHandle,
|
|
71
|
-
mounted: Mounted,
|
|
72
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
73
|
-
sink: Sink,
|
|
74
|
-
): ReadonlyArray<Mounted> => {
|
|
75
|
-
const service = runtime.read(mounted.comp.tag)
|
|
76
|
-
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
77
|
-
const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
|
|
78
|
-
const frame = Effect.runSync(Composition.render(service, env))
|
|
79
|
-
endRenderSpan()
|
|
80
|
-
if (!isStructure(frame)) {
|
|
81
|
-
return Effect.runSync(
|
|
82
|
-
Effect.dieMessage(
|
|
83
|
-
`reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
|
|
84
|
-
),
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
return driveStructure(mounted.comp, frame, mounted.key, sink, bindings)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const renderLevelRuntime = (
|
|
91
|
-
runtime: RuntimeHandle,
|
|
92
|
-
frontier: ReadonlyArray<Mounted>,
|
|
93
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
94
|
-
sink: Sink,
|
|
95
|
-
): void => {
|
|
96
|
-
if (frontier.length === 0) {
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
const levels = frontier.map((mounted) => renderMountedRuntime(runtime, mounted, bindings, sink))
|
|
100
|
-
renderLevelRuntime(runtime, levels.flat(), bindings, sink)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const renderTreeRuntime = (
|
|
104
|
-
runtime: RuntimeHandle,
|
|
105
|
-
root: CompositionClass<unknown>,
|
|
106
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
107
|
-
sink: Sink,
|
|
108
|
-
): Effect.Effect<void, never, never> =>
|
|
109
|
-
Effect.sync(() => {
|
|
110
|
-
sink.reset()
|
|
111
|
-
renderLevelRuntime(runtime, [{ comp: root, props: {}, key: Option.none() }], bindings, sink)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
const settleTreeRuntime = Effect.fn('settleTreeRuntime')(function* (
|
|
115
|
-
runtime: RuntimeHandle,
|
|
116
|
-
root: CompositionClass<unknown>,
|
|
117
|
-
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
118
|
-
sink: Sink,
|
|
119
|
-
): Effect.fn.Return<void, never, never> {
|
|
120
|
-
yield* settleDrain
|
|
121
|
-
yield* renderTreeRuntime(runtime, root, bindings, sink)
|
|
122
|
-
yield* Effect.iterate(
|
|
123
|
-
{
|
|
124
|
-
previous: fingerprint(sink),
|
|
125
|
-
remaining: SETTLE_MAX_RENDERS,
|
|
126
|
-
stable: false,
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
|
|
130
|
-
body: (progress) =>
|
|
131
|
-
Effect.gen(function* () {
|
|
132
|
-
yield* settleDrain
|
|
133
|
-
yield* Effect.sleep(SETTLE_STEP)
|
|
134
|
-
yield* renderTreeRuntime(runtime, root, bindings, sink)
|
|
135
|
-
const current = fingerprint(sink)
|
|
136
|
-
return {
|
|
137
|
-
previous: current,
|
|
138
|
-
remaining: progress.remaining - 1,
|
|
139
|
-
stable: current === progress.previous,
|
|
140
|
-
}
|
|
141
|
-
}),
|
|
142
|
-
},
|
|
143
|
-
)
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
const makeMountedFacade = (
|
|
147
|
-
runtime: RuntimeHandle,
|
|
148
|
-
root: CompositionClass<unknown>,
|
|
149
|
-
sink: Sink,
|
|
150
|
-
dispatched: Set<string>,
|
|
151
|
-
): {
|
|
152
|
-
readonly facade: MountedFacade
|
|
153
|
-
readonly settle: Effect.Effect<void, never, never>
|
|
154
|
-
} => {
|
|
155
|
-
const bindings = collectRuntimeBindings(root, runtime)
|
|
156
|
-
const settle = settleTreeRuntime(runtime, root, bindings, sink)
|
|
157
|
-
const rerender = renderTreeRuntime(runtime, root, bindings, sink)
|
|
158
|
-
|
|
159
|
-
const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>, never, never> =>
|
|
160
|
-
Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
|
|
161
|
-
onNone: () =>
|
|
162
|
-
Effect.dieMessage(
|
|
163
|
-
new UnknownAction({
|
|
164
|
-
composition: ref.name,
|
|
165
|
-
action: event,
|
|
166
|
-
rendered: capturesFor(sink, ref.name).length,
|
|
167
|
-
}).message,
|
|
168
|
-
),
|
|
169
|
-
onSome: Effect.succeed,
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, never> =>
|
|
173
|
-
Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
|
|
174
|
-
|
|
175
|
-
const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): MountedFacade => ({
|
|
176
|
-
props: propsFor(ref),
|
|
177
|
-
expectProps: (partial) =>
|
|
178
|
-
propsFor(ref).pipe(
|
|
179
|
-
Effect.flatMap((props) => {
|
|
180
|
-
const mismatch = matchProps({ actual: props, expected: partial })
|
|
181
|
-
return mismatch === undefined
|
|
182
|
-
? Effect.void
|
|
183
|
-
: Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
|
|
184
|
-
}),
|
|
185
|
-
),
|
|
186
|
-
actions: keyed(
|
|
187
|
-
(event) => (payload: unknown) =>
|
|
188
|
-
rerender.pipe(
|
|
189
|
-
Effect.flatMap(() => triggerOf(ref, event)),
|
|
190
|
-
Effect.flatMap((trigger) =>
|
|
191
|
-
Effect.sync(() => {
|
|
192
|
-
dispatched.add(event)
|
|
193
|
-
trigger(payload)
|
|
194
|
-
}),
|
|
195
|
-
),
|
|
196
|
-
Effect.flatMap(() => settle),
|
|
197
|
-
Effect.flatMap(() => propsFor(ref)),
|
|
198
|
-
),
|
|
199
|
-
),
|
|
200
|
-
slots: keyed((slotName) => slotFacade(comp, slotName)),
|
|
201
|
-
frame: settle,
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
const slotFacade = (parent: CompositionClass<unknown>, slotName: string): MountedSlotFacade => {
|
|
205
|
-
const slotClass = slotsOf(parent)[slotName]
|
|
206
|
-
const child = slotClass && bindings.get(slotClass)
|
|
207
|
-
if (child === undefined) {
|
|
208
|
-
return Effect.runSync(
|
|
209
|
-
Effect.dieMessage(new UnknownSlot({ parent: uiNameOf(parent), slot: slotName }).message),
|
|
210
|
-
)
|
|
211
|
-
}
|
|
212
|
-
const childName = uiNameOf(child)
|
|
213
|
-
const atIndex = (index: number): Effect.Effect<MountedFacade, never, never> =>
|
|
214
|
-
Effect.as(rerender, nodeFacade({ name: childName, index }, child))
|
|
215
|
-
const first = nodeFacade({ name: childName, index: 0 }, child)
|
|
216
|
-
const indexOfKey = (key: string): Option.Option<number> =>
|
|
217
|
-
Arr.findFirstIndex(capturesFor(sink, childName), (capture) => capture.key === key)
|
|
218
|
-
return {
|
|
219
|
-
first: atIndex(0),
|
|
220
|
-
at: atIndex,
|
|
221
|
-
all: Effect.map(rerender, () =>
|
|
222
|
-
capturesFor(sink, childName).map((_capture, index) =>
|
|
223
|
-
nodeFacade({ name: childName, index }, child),
|
|
224
|
-
),
|
|
225
|
-
),
|
|
226
|
-
byKey: (key) =>
|
|
227
|
-
Effect.flatMap(rerender, () =>
|
|
228
|
-
Option.match(indexOfKey(key), {
|
|
229
|
-
onNone: () =>
|
|
230
|
-
Effect.dieMessage(
|
|
231
|
-
new UnknownSlot({
|
|
232
|
-
parent: uiNameOf(parent),
|
|
233
|
-
slot: `${slotName}[key=${key}]`,
|
|
234
|
-
}).message,
|
|
235
|
-
),
|
|
236
|
-
onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
|
|
237
|
-
}),
|
|
238
|
-
),
|
|
239
|
-
where: (predicate) =>
|
|
240
|
-
Effect.map(rerender, () =>
|
|
241
|
-
capturesFor(sink, childName).flatMap((capture, index) =>
|
|
242
|
-
predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
|
|
243
|
-
),
|
|
244
|
-
),
|
|
245
|
-
count: Effect.map(rerender, () => capturesFor(sink, childName).length),
|
|
246
|
-
props: first.props,
|
|
247
|
-
expectProps: first.expectProps,
|
|
248
|
-
actions: first.actions,
|
|
249
|
-
slots: first.slots,
|
|
250
|
-
frame: first.frame,
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root),
|
|
256
|
-
settle,
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export const makeRuntimeHandleFacade = (
|
|
261
|
-
runtime: RuntimeHandle,
|
|
262
|
-
root: CompositionClass<unknown>,
|
|
263
|
-
sink: Sink,
|
|
264
|
-
dispatched: Set<string>,
|
|
265
|
-
): {
|
|
266
|
-
readonly facade: MountedFacade
|
|
267
|
-
readonly settle: Effect.Effect<void, never, never>
|
|
268
|
-
} => makeMountedFacade(runtime, root, sink, dispatched)
|
|
269
|
-
|