@playfast/reform 0.0.8 → 0.0.10

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.
@@ -1,11 +1,11 @@
1
1
  import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
2
2
  import { expect, test } from 'vitest'
3
3
  import {
4
- type CaptureSinkApi,
5
- CaptureSink,
6
4
  Composition,
7
5
  Engine,
8
6
  Event,
7
+ isStructure,
8
+ mount,
9
9
  provide,
10
10
  publish,
11
11
  Reducer,
@@ -18,13 +18,14 @@ import {
18
18
  Ui,
19
19
  ui,
20
20
  } from '../index'
21
+ import { CurrentSeedOverrides } from '../internal/seeds'
21
22
 
22
23
  // `seedScene` is the tooling seam over CLOSED scenes: the app bundle below seeds
23
24
  // `count: 1` itself (no open store), and the overlay must reach the nested
24
25
  // `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
25
26
  // pre-composed layers. The harness mirrors the dev-tool preview: merge the
26
27
  // scene's `provide`, mount it in a ManagedRuntime, and read the root
27
- // composition's rendered props through the headless CaptureSink.
28
+ // composition's rendered props through the headless Structure.
28
29
 
29
30
  class CountState extends State.make('count', S.Number) {}
30
31
  class MiniStates extends StateGroup.make(CountState) {}
@@ -46,8 +47,7 @@ class Counter extends Composition.make('Counter', {
46
47
  }) {}
47
48
  const CounterLive = Composition.live(Counter, function* () {
48
49
  const count = yield* StateGroup.select(MiniStates, 'count')
49
- const view = yield* CounterUi
50
- return view({ count })
50
+ return mount({ props: { count }, slots: {} })
51
51
  })
52
52
 
53
53
  // The closed app layer a scene provides: views + logic + Engine, with the state
@@ -61,37 +61,44 @@ const MiniApp = Views.pipe(Layer.provideMerge(Logic))
61
61
 
62
62
  const CounterScene = scene(Counter, { provide: [MiniApp] })
63
63
 
64
+ // The dev-tool inspector overlays raw, user-typed seeds (`Record<string, unknown>`)
65
+ // — the untyped seam that `seedScene` wraps once a composition's state tuple is
66
+ // known. These two cases (absent key / schema-invalid value) are compile errors
67
+ // through the now-typed `seedScene`, so they're exercised here at the raw overlay
68
+ // to prove `resolveSeed`'s runtime defensiveness still falls back to the seed.
69
+ const overlayRawSeeds = (base: Scene, seeds: Readonly<Record<string, unknown>>): Scene => ({
70
+ ...base,
71
+ provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
72
+ })
73
+
64
74
  const headlessEnv: RenderEnv = {
65
75
  props: {},
66
76
  tracker: { add: () => {} },
67
- slots: { slot: () => () => null },
68
77
  }
69
78
 
70
- // One ManagedRuntime per scene under test, with its own capture cell so two
71
- // runtimes in the same test (the no-leakage case) cannot bleed into each other.
72
- // Each `read` re-renders the root composition, repopulating the cell from the
73
- // stores that runtime actually built.
79
+ const isCountProps = (props: unknown): props is { readonly count: number } =>
80
+ typeof props === 'object' &&
81
+ props !== null &&
82
+ 'count' in props &&
83
+ typeof props.count === 'number'
84
+
85
+ // One ManagedRuntime per scene under test. Each `read` re-renders the root
86
+ // composition and reads the Structure built from the stores that runtime actually
87
+ // constructed.
74
88
  const makeHarness = (s: Scene) => {
75
- const captured: { count: number } = { count: -1 }
76
- const sink: CaptureSinkApi = {
77
- record: (capture) => {
78
- const props = capture.props
79
- if (
80
- typeof props === 'object' &&
81
- props !== null &&
82
- 'count' in props &&
83
- typeof props.count === 'number'
84
- ) {
85
- captured.count = props.count
86
- }
87
- },
88
- }
89
- const layer = s.provide
90
- .reduce((a, b) => Layer.merge(a, b))
91
- .pipe(Layer.provideMerge(Layer.succeed(CaptureSink, sink)))
89
+ const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
92
90
  const runtime = ManagedRuntime.make(layer)
93
91
  const read = Effect.flatMap(Counter.tag, (c) =>
94
- Composition.render(c, headlessEnv).pipe(Effect.map(() => captured.count)),
92
+ Composition.render(c, headlessEnv).pipe(
93
+ Effect.flatMap((frame) => {
94
+ if (!isStructure(frame)) {
95
+ return Effect.dieMessage('Counter must render a Structure')
96
+ }
97
+ return isCountProps(frame.props)
98
+ ? Effect.succeed(frame.props.count)
99
+ : Effect.dieMessage('Counter Structure props must include numeric count')
100
+ }),
101
+ ),
95
102
  )
96
103
  return { runtime, read }
97
104
  }
@@ -134,7 +141,7 @@ test('the live reducer drives the SAME overridden store', async () => {
134
141
  })
135
142
 
136
143
  test('an absent key leaves the authored seed in place', async () => {
137
- const { runtime, read } = makeHarness(seedScene(CounterScene, { other: 1 }))
144
+ const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { other: 1 }))
138
145
  try {
139
146
  expect(await runtime.runPromise(read)).toBe(1)
140
147
  } finally {
@@ -143,7 +150,7 @@ test('an absent key leaves the authored seed in place', async () => {
143
150
  })
144
151
 
145
152
  test('a schema-invalid override falls back to the authored seed', async () => {
146
- const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 'not-a-number' }))
153
+ const { runtime, read } = makeHarness(overlayRawSeeds(CounterScene, { count: 'not-a-number' }))
147
154
  try {
148
155
  expect(await runtime.runPromise(read)).toBe(1)
149
156
  } finally {
@@ -51,6 +51,26 @@ export type GroupSeeds<G extends AnyStateGroup> =
51
51
  ? { readonly [N in StateName<Members[number]>]: ValueForName<Members, N> }
52
52
  : never
53
53
 
54
+ // Collapse a union of records into their intersection — merges every group's
55
+ // seed record into one. (The classic contravariant-position trick.)
56
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
57
+ k: infer I,
58
+ ) => void
59
+ ? I
60
+ : never
61
+
62
+ /** The seed record for one tuple entry: a group's `GroupSeeds`, else nothing. */
63
+ type EntrySeeds<G> = G extends AnyStateGroup ? GroupSeeds<G> : never
64
+
65
+ /**
66
+ * The optional seed overrides for a composition's whole `states` tuple, keyed by
67
+ * member name and typed to each member's value. Every key is optional (seeds are
68
+ * overrides over the authored initial). Tuple entries that aren't state groups
69
+ * contribute no keys, so a partially-grouped `states` still types its groups.
70
+ * Powers `seedScene` — a typo'd key or mistyped value is a compile error.
71
+ */
72
+ export type SeedsOf<St extends ReadonlyArray<unknown>> = Partial<UnionToIntersection<EntrySeeds<St[number]>>>
73
+
54
74
  /** Compose atomic States into a group provided (and addressed) as a unit. */
55
75
  export const make = <const Members extends ReadonlyArray<AnyState>>(
56
76
  ...members: Members & NoDuplicateNames<Members>