Skip to content

Commit e54803e

Browse files
Fix local compound targets and add machine diagrams (#132)
1 parent 6887b70 commit e54803e

12 files changed

Lines changed: 699 additions & 150 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Fix `to.local.with()` so schema-backed compound scopes can select and rebuild their local value from both direct handlers and nested invoke outcomes.
6+
7+
Expand the inspection examples with text and Mermaid state diagrams built from `Machine.stateNodes`, `Machine.initialDefinition`, `Machine.transitionDefinitions`, `Machine.activityDefinitions`, and live configuration. The examples render concrete conditional branches, reentry, choices, history and final states, activities, and safely escaped user-defined labels.

examples/platformer/src/machine.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Machine } from "@typeonce/effect-machine"
22
import { MachineTest } from "@typeonce/effect-machine/testing"
33
import { Effect } from "effect"
44
import { describe, expect, it } from "vitest"
5+
import { makeMermaidRenderer } from "../../../test/machine/visualization/mermaid.ts"
56
import { makeTextRenderer } from "../../../test/machine/visualization/text.ts"
67
import {
78
airJumpMode,
@@ -15,6 +16,7 @@ import {
1516
} from "./machine.ts"
1617

1718
const renderMachine = makeTextRenderer<typeof CharacterMachine, CharacterSnapshot>(Machine)
19+
const renderMermaid = makeMermaidRenderer<typeof CharacterMachine, CharacterSnapshot>(Machine)
1820

1921
const playingSnapshot = (snapshot: CharacterSnapshot) => {
2022
const locomotion = snapshot.states.locomotion.state
@@ -156,6 +158,7 @@ describe("platformer history integration", () => {
156158
const initial = yield* Machine.planInitial(CharacterMachine)
157159
const definitions = Machine.transitionDefinitions(CharacterMachine)
158160
const rendered = renderMachine(CharacterMachine, initial.state)
161+
const mermaid = renderMermaid(CharacterMachine, initial.state)
159162

160163
expect(definitions).toHaveLength(28)
161164
expect(definitions).toContainEqual({
@@ -173,11 +176,18 @@ describe("platformer history integration", () => {
173176
}]
174177
})
175178
expect(definitions.every(({ branches }) => branches.length > 0)).toBe(true)
176-
expect(rendered).toContain("◇ on: WallJump [reenter] → AirJumpWallLock")
179+
expect(rendered).toContain("◇ on: WallJump [reenter]")
180+
expect(rendered).toContain("└┄ → AirJumpWallLock")
181+
expect(rendered).not.toContain("[otherwise] → ∅")
177182
expect(rendered).toContain(
178183
"Candidate events: Move, DownPressed, JumpPressed, Pause, WallJump, WallContact, Reset"
179184
)
180185
expect(rendered).not.toContain("Observed event samples")
186+
expect(mermaid).toMatch(/^stateDiagram-v2\n direction LR/)
187+
expect(mermaid).toContain("state_13 --> state_15: WallJump [reenter]")
188+
expect(mermaid).toContain("state_23 --> state_25: WallContact [left wall]")
189+
expect(mermaid).toContain("state_23 --> state_24: WallContact [otherwise]")
190+
expect(mermaid).not.toContain("∅")
181191
}))
182192
})
183193

src/internal/machine/machine.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,13 @@ const makeTargetSelector = (
220220
branch[root.key] = makeSelectionNode(stateNodes, root.path, "branch")
221221
const local: Record<string, unknown> = {}
222222
const localScope = getLocalTargetScope(stateNodes, source)
223-
if (localScope !== undefined) addSelectionChildren(local, stateNodes, localScope, "local")
223+
if (localScope !== undefined) {
224+
const localScopeNode = getTargetBuilderNode(stateNodes, localScope)
225+
if (localScopeNode.schema !== undefined) {
226+
local.with = makeSelectionMethod("state", localScope, "local")
227+
}
228+
addSelectionChildren(local, stateNodes, localScope, "local")
229+
}
224230
return {
225231
none: makeSelectionMethod("none", undefined, "local"),
226232
local,
@@ -263,7 +269,14 @@ const getSelectionBuilder = (
263269
} else if (selection.scope === "local") {
264270
builder = target.local
265271
const scope = getLocalTargetScope(stateNodes, source)
266-
if (scope !== undefined) parts = selection.path!.slice(scope.length + 1).split(".")
272+
if (scope !== undefined) {
273+
if (selection.path === scope) {
274+
builder = builder.with
275+
parts = []
276+
} else {
277+
parts = selection.path!.slice(scope.length + 1).split(".")
278+
}
279+
}
267280
} else if (selection.scope === "branch") {
268281
builder = target.branch
269282
} else {

test/internal/machine/activities.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Duration, Effect, Schema } from "effect"
33
import { FastCheck } from "effect/testing"
44
import { Machine } from "../../../src/index.js"
55
import { activityDefinitions } from "../../../src/internal/machine/activities.js"
6+
import { makeMermaidRenderer } from "../../machine/visualization/mermaid.js"
67
import { makeTextRenderer } from "../../machine/visualization/text.js"
78

89
class Loading extends Schema.TaggedClass<Loading>("Loading")("Loading", {}) {}
@@ -82,6 +83,10 @@ const renderActivityMachine = makeTextRenderer<
8283
typeof activityMachine,
8384
Machine.Machine.Snapshot<typeof activityStates.states>
8485
>(Machine)
86+
const renderMermaidActivityMachine = makeMermaidRenderer<
87+
typeof activityMachine,
88+
Machine.Machine.Snapshot<typeof activityStates.states>
89+
>(Machine)
8590

8691
const machine = {
8792
stateNodes: {
@@ -282,12 +287,9 @@ describe("machine activity metadata", () => {
282287
renderActivityMachine(activityMachine, { path: "Loading" as const, value: new Loading({}) }),
283288
[
284289
"activity-inspection",
285-
"● active ○ inactive ◇ transition (→ target, ∅ none) ◆ activity",
290+
"● active ○ inactive ◇ transition ┄ branch → target ◆ activity",
286291
"",
287292
"├─ ● Loading",
288-
"│ ├─ ◇ invoke load-document done → ∅",
289-
"│ ├─ ◇ invoke load-document failure → ∅",
290-
"│ ├─ ◇ invoke load-timeout done → ∅",
291293
"│ ├─ ◆ process: poll-server",
292294
"│ ├─ ◆ effect: load-document [success: dynamic, failure: dynamic]",
293295
"│ ├─ ◆ timer: load-timeout [10s]",
@@ -299,4 +301,18 @@ describe("machine activity metadata", () => {
299301
].join("\n")
300302
)
301303
})
304+
305+
it("renders state-owned activities inside their Mermaid state", () => {
306+
const rendered = renderMermaidActivityMachine(activityMachine, {
307+
path: "Loading" as const,
308+
value: new Loading({})
309+
})
310+
311+
assert.include(
312+
rendered,
313+
"state_0: process / poll-server · effect / load-document · timer / load-timeout (10s) · machine / child → document-worker"
314+
)
315+
assert.notInclude(rendered, "success: dynamic")
316+
assert.notInclude(rendered, "note right of")
317+
})
302318
})

test/machine/AnnotationsVisualization.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assert, describe, it } from "@effect/vitest"
22
import { Schema } from "effect"
33
import { Machine } from "../../src/index.js"
4+
import { makeMermaidRenderer } from "./visualization/mermaid.js"
45
import { makeTextRenderer } from "./visualization/text.js"
56

67
class Workflow extends Schema.TaggedClass<Workflow>("Workflow")("Workflow", {}) {}
@@ -43,14 +44,18 @@ const renderMachine = makeTextRenderer<
4344
typeof machine,
4445
Machine.Machine.Snapshot<typeof States.states>
4546
>(Machine)
47+
const renderMermaid = makeMermaidRenderer<
48+
typeof machine,
49+
Machine.Machine.Snapshot<typeof States.states>
50+
>(Machine)
4651

4752
describe("Machine annotation visualization", () => {
4853
it("uses titles for active, choice, and history display while preserving structural keys", () => {
4954
assert.strictEqual(
5055
renderMachine(machine),
5156
[
5257
"Machine",
53-
"● active ○ inactive ◇ transition (→ target, ∅ none)",
58+
"● active ○ inactive ◇ transition ┄ branch → target",
5459
"",
5560
"└─ ○ Document workflow (Workflow) [compound, initial: Idle]",
5661
" ├─ ○ Waiting for edits (Idle)",
@@ -60,4 +65,14 @@ describe("Machine annotation visualization", () => {
6065
].join("\n")
6166
)
6267
})
68+
69+
it("renders titled choice, history, and final states as Mermaid", () => {
70+
const rendered = renderMermaid(machine)
71+
72+
assert.include(rendered, "state \"○ Document workflow (Workflow)\" as state_0")
73+
assert.include(rendered, "state \"○ Select persistence route (Routing)\" as state_2")
74+
assert.include(rendered, "state state_2 <<choice>>")
75+
assert.include(rendered, "state \"○ Previous workflow state (Recent) [history: deep]\" as state_3")
76+
assert.include(rendered, "state \"○ Done\" as state_4")
77+
})
6378
})
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { assert, describe, it } from "@effect/vitest"
2+
import { Effect, Schema } from "effect"
3+
import { Machine } from "../../src/index.js"
4+
5+
describe("local compound target selection", () => {
6+
it.effect("captures and plans local.with from the compound scope", () =>
7+
Effect.gen(function*() {
8+
const State = Schema.TaggedUnion({
9+
Search: { query: Schema.String },
10+
Idle: {},
11+
Updated: {}
12+
})
13+
const Events = Schema.TaggedUnion({
14+
UpdateQuery: { query: Schema.String },
15+
Reset: {}
16+
})
17+
const states = Machine.defineStates({
18+
search: {
19+
schema: State.cases.Search,
20+
initial: "Idle",
21+
states: {
22+
Idle: {},
23+
Updated: {}
24+
}
25+
}
26+
})
27+
const machine = Machine.make({
28+
states: states.states,
29+
events: Machine.events(Events),
30+
initial: {
31+
target: (to) => to.search.initial(),
32+
resolve: ({ target }) => target.from({ query: "" }, (search) => search.Idle.from())
33+
}
34+
}).handle({
35+
search: {
36+
on: {
37+
UpdateQuery: Machine.transition({
38+
target: (to) => to.local.with(),
39+
resolve: ({ event, target }) => target.from({ query: event.query }, (search) => search.Updated.from()),
40+
reenter: true
41+
})
42+
},
43+
states: {
44+
Idle: {},
45+
Updated: {
46+
on: {
47+
Reset: Machine.transition({
48+
target: (to) => to.local.Idle(),
49+
resolve: ({ target }) => target.from()
50+
})
51+
}
52+
}
53+
}
54+
}
55+
})
56+
57+
assert.deepStrictEqual(Machine.transitionDefinitions(machine), [{
58+
source: "search",
59+
trigger: { type: "event", event: "UpdateQuery" },
60+
reenter: true,
61+
branches: [{
62+
type: "direct",
63+
target: "search",
64+
selection: { path: "search", kind: "state", scope: "local" }
65+
}]
66+
}, {
67+
source: "search.Updated",
68+
trigger: { type: "event", event: "Reset" },
69+
reenter: false,
70+
branches: [{
71+
type: "direct",
72+
target: "search.Idle",
73+
selection: { path: "search.Idle", kind: "state", scope: "local" }
74+
}]
75+
}])
76+
77+
const initial = yield* Machine.planInitial(machine)
78+
const updated = yield* Machine.plan(machine, initial.state, Events.cases.UpdateQuery.make({ query: "next" }))
79+
80+
assert.deepStrictEqual(updated.next, {
81+
path: "search",
82+
value: State.cases.Search.make({ query: "next" }),
83+
state: {
84+
path: "search.Updated",
85+
value: undefined
86+
}
87+
})
88+
89+
const reset = yield* Machine.plan(machine, updated.next, Events.cases.Reset.make({}))
90+
assert.deepStrictEqual(reset.next, {
91+
path: "search",
92+
value: State.cases.Search.make({ query: "next" }),
93+
state: {
94+
path: "search.Idle",
95+
value: undefined
96+
}
97+
})
98+
}))
99+
100+
it.effect("resolves local.with from a descendant invoke source", () =>
101+
Effect.gen(function*() {
102+
const State = Schema.TaggedUnion({
103+
Search: { query: Schema.String },
104+
Searching: {},
105+
Updated: {}
106+
})
107+
const states = Machine.defineStates({
108+
search: {
109+
schema: State.cases.Search,
110+
initial: "Searching",
111+
states: {
112+
Searching: {},
113+
Updated: {}
114+
}
115+
}
116+
})
117+
const machine = Machine.make({
118+
states: states.states,
119+
events: Machine.events(),
120+
initial: {
121+
target: (to) => to.search.initial(),
122+
resolve: ({ target }) => target.from({ query: "pending" }, (search) => search.Searching.from())
123+
}
124+
}).handle({
125+
search: {
126+
states: {
127+
Searching: {
128+
invoke: Machine.invoke({
129+
id: "search",
130+
effect: () => Effect.succeed("resolved"),
131+
onDone: Machine.transition({
132+
target: (to) => to.local.with(),
133+
resolve: ({ output, target }) => target.from({ query: output }, (search) => search.Updated.from())
134+
})
135+
})
136+
},
137+
Updated: {}
138+
}
139+
}
140+
})
141+
142+
assert.deepStrictEqual(Machine.transitionDefinitions(machine), [{
143+
source: "search.Searching",
144+
trigger: { type: "invoke", id: "search", outcome: "done" },
145+
reenter: false,
146+
branches: [{
147+
type: "direct",
148+
target: "search",
149+
selection: { path: "search", kind: "state", scope: "local" }
150+
}]
151+
}])
152+
153+
const ref = yield* Machine.start(machine)
154+
for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow
155+
156+
assert.deepStrictEqual(yield* ref.state, {
157+
path: "search",
158+
value: State.cases.Search.make({ query: "resolved" }),
159+
state: {
160+
path: "search.Updated",
161+
value: undefined
162+
}
163+
})
164+
}))
165+
166+
it("does not install local.with for a schema-less compound scope", () => {
167+
const Event = Schema.TaggedUnion({ Advance: {} })
168+
const states = Machine.defineStates({
169+
flow: {
170+
initial: "Idle",
171+
states: {
172+
Idle: {},
173+
Updated: {}
174+
}
175+
}
176+
})
177+
178+
Machine.make({
179+
states: states.states,
180+
events: Machine.events(Event),
181+
initial: {
182+
target: (to) => to.flow.initial(),
183+
resolve: ({ target }) => target.from((flow) => flow.Idle.from())
184+
}
185+
}).handle({
186+
flow: {
187+
states: {
188+
Idle: {
189+
on: {
190+
Advance: Machine.transition({
191+
target: (to) => {
192+
assert.notProperty(to.local, "with")
193+
return to.local.Updated()
194+
},
195+
resolve: ({ target }) => target.from()
196+
})
197+
}
198+
},
199+
Updated: {}
200+
}
201+
}
202+
})
203+
})
204+
})

0 commit comments

Comments
 (0)