diff --git a/test/fixtures/documents/flow_patterns/composed.json b/test/fixtures/documents/flow_patterns/composed.json
new file mode 100644
index 00000000..09d67a2e
--- /dev/null
+++ b/test/fixtures/documents/flow_patterns/composed.json
@@ -0,0 +1,62 @@
+{
+ "schema_version": 1,
+ "id": "bdoc_01JHOLDPICKUP",
+ "revision": 1,
+ "metadata": {"name": "Hold pickup, confirmed and shelved"},
+ "accepts": ["hold.confirmed", "hold.collected", "hold.cancelled"],
+ "datamodel": [
+ {"id": "wants_confirmation", "description": "The desk confirms the hold."},
+ {"id": "held_on_shelf", "description": "The item waits on the hold shelf."}
+ ],
+ "root": {
+ "id": "blk_ROOT", "type": "core.sequence", "type_version": 1,
+ "slots": {"body": [
+ {"id": "blk_PLACE", "type": "core.send", "type_version": 2,
+ "config": {"event": "hold.placed"}},
+ {"id": "blk_PICKUP", "type": "core.parallel", "type_version": 1,
+ "config": {"lanes": ["confirm", "shelf"], "complete": "all"},
+ "slots": {
+ "lane_confirm": [
+ {"id": "blk_IF_CONFIRM", "type": "core.branch", "type_version": 1,
+ "config": {"arms": [{"slot": "arm_asked", "cond": "wants_confirmation"}]},
+ "slots": {"arm_asked": [
+ {"id": "blk_CONFIRM", "type": "core.group", "type_version": 1,
+ "slots": {
+ "body": [
+ {"id": "blk_TIMER", "type": "core.send", "type_version": 2,
+ "config": {"event": "deadline.hold_confirm", "delay": "1d"}},
+ {"id": "blk_WAIT", "type": "core.await", "type_version": 1,
+ "config": {"event": "hold.confirmed"}}
+ ],
+ "interrupts": [
+ {"id": "blk_EXPIRE", "type": "core.on_event", "type_version": 1,
+ "config": {"event": "deadline.hold_confirm", "outcome": "abandon"}}
+ ]
+ }}
+ ]}}
+ ],
+ "lane_shelf": [
+ {"id": "blk_IF_SHELF", "type": "core.branch", "type_version": 1,
+ "config": {"arms": [{"slot": "arm_asked", "cond": "held_on_shelf"}]},
+ "slots": {"arm_asked": [
+ {"id": "blk_SHELF", "type": "core.group", "type_version": 1,
+ "slots": {
+ "body": [
+ {"id": "blk_SHELVED", "type": "core.wait", "type_version": 2,
+ "config": {"duration": "7d"}}
+ ],
+ "interrupts": [
+ {"id": "blk_COLLECTED", "type": "core.on_event", "type_version": 1,
+ "config": {"event": "hold.collected", "outcome": "abandon"}},
+ {"id": "blk_CANCELLED", "type": "core.on_event", "type_version": 1,
+ "config": {"event": "hold.cancelled", "outcome": "abandon"}}
+ ]
+ }}
+ ]}}
+ ]
+ }},
+ {"id": "blk_CLOSE", "type": "core.send", "type_version": 2,
+ "config": {"event": "hold.closed"}}
+ ]}
+ }
+}
diff --git a/test/statifier_blocks/flow_patterns_composed_test.exs b/test/statifier_blocks/flow_patterns_composed_test.exs
new file mode 100644
index 00000000..14412c39
--- /dev/null
+++ b/test/statifier_blocks/flow_patterns_composed_test.exs
@@ -0,0 +1,301 @@
+defmodule StatifierBlocks.FlowPatternsComposedTest do
+ @moduledoc """
+ The three patterns `docs/guides/flow-patterns.md` shows one by one,
+ composed in one document: optional arms (a `core.parallel` whose lanes
+ are each guarded by a one-arm `core.branch`), with the bounded step (a
+ `core.group` carrying the deadline recipe around a `core.await`) in one
+ lane, and the park (a `core.group` whose body is a `core.wait` and whose
+ rail carries handlers) in the other.
+
+ The document is `test/fixtures/documents/flow_patterns/composed.json`, in
+ the guide's library world: a hold is placed, the desk may be asked to
+ confirm it within a day, the item may wait on the hold shelf for seven
+ days, and the hold is closed when both lanes are done. The compiled lines
+ each pattern carries are the guide's own lines, byte for byte, which is
+ what shows that nesting a pattern inside another changes nothing about it.
+
+ A pure test. Nothing here names LiveView, so it compiles and runs headless.
+ """
+
+ use ExUnit.Case, async: true
+
+ alias StatifierBlocks.{Compiler, Describe, Document, Finding, Palette, Publish, ViewModel}
+ alias StatifierBlocks.Map, as: BlockMap
+
+ @page "docs/guides/flow-patterns.md"
+ @composed "test/fixtures/documents/flow_patterns/composed.json"
+
+ # One day and seven days, as the host is asked to schedule the two
+ # delayed sends.
+ @one_day_ms 24 * 60 * 60 * 1000
+ @seven_days_ms 7 * @one_day_ms
+
+ describe "the composed document: publish and compile" do
+ # Sabotage: in the fixture, the parallel's `complete` set to `"every"`
+ # - red: Publish reports an error finding on `complete` (verified).
+ test "Publish reports no error, and it compiles under the core palette with no warning" do
+ document = composed()
+
+ assert for(
+ %Finding{severity: :error} = finding <- Publish.findings(document, palette(), %{}),
+ do: finding
+ ) == []
+
+ assert {:ok, compiled} = Compiler.compile(document, palette())
+ assert compiled.warnings == []
+ assert {:ok, _machine} = Statifier.compile(compiled.scxml)
+ end
+ end
+
+ describe "the composed document: the compiled chart" do
+ # Sabotage: in the fixture, the deadline handler (`blk_EXPIRE`) dropped
+ # from the group's rail - red here on the deadline's interrupt
+ # transition, and red on the two run tests that ask for the confirm
+ # lane and on the Describe test below; no Publish finding and no
+ # compile warning appear, so the first test stays green (verified).
+ # Sabotage: in the fixture, the deadline's send moved out of the group
+ # to just ahead of it in the branch arm - red here: the send is no
+ # longer inside the group (verified).
+ test "each pattern's timers and interrupt transitions are the guide's own lines" do
+ assert {:ok, compiled} = Compiler.compile(composed(), palette())
+
+ # The bounded step, inside the confirm lane's group: the guide's
+ # first pattern. The hold shelf, inside the shelf lane's group: the
+ # guide's third pattern.
+ for {group, lines} <- [
+ {"s_blk_CONFIRM",
+ [
+ ~s(),
+ ~s() <>
+ ~s(),
+ ~s(),
+ ~s()
+ ]},
+ {"s_blk_SHELF",
+ [
+ ~s(),
+ ~s() <>
+ ~s(),
+ ~s(),
+ ~s(),
+ ~s()
+ ]}
+ ],
+ line <- lines do
+ assert within(compiled.scxml, group, group <> "__o_done") =~ line
+ assert page() =~ line
+ end
+
+ for {lane, group} <- [{"confirm", "s_blk_CONFIRM"}, {"shelf", "s_blk_SHELF"}] do
+ lane_xml =
+ within(
+ compiled.scxml,
+ "s_blk_PICKUP__lane_" <> lane,
+ "s_blk_PICKUP__done_lane_" <> lane
+ )
+
+ assert lane_xml =~ within(compiled.scxml, group, group <> "__o_done")
+ end
+
+ # The optional arms around them: the guide's second pattern, with
+ # this document's lanes and guards.
+ for line <- [
+ ~s() <>
+ ~s(),
+ ~s() <>
+ ~s(),
+ ~s(),
+ ~s(),
+ ~s(),
+ ~s(),
+ ~s(),
+ ~s()
+ ] do
+ assert compiled.scxml =~ line
+ end
+ end
+ end
+
+ describe "the composed document: the chart, run" do
+ setup do
+ {:ok, compiled} = Compiler.compile(composed(), palette())
+ {:ok, machine} = Statifier.compile(compiled.scxml)
+
+ %{machine: machine}
+ end
+
+ # Sabotage: in `Cancels.onexit/2`, emitted no `` element - red:
+ # the bound abandons the group and leaves its day-long timer behind
+ # (verified).
+ test "both asked: each lane arms its own deadline, and the bound fails open inside its lane",
+ ctx do
+ {machine_state, effects} = start(ctx.machine, true, true)
+
+ assert sent(effects) == ["hold.placed"]
+
+ assert delayed(effects) == [
+ {"deadline.hold_confirm", @one_day_ms},
+ {"statifier_blocks.wait.blk_SHELVED", @seven_days_ms}
+ ]
+
+ assert Statifier.active_leaf_states(machine_state) ==
+ MapSet.new([
+ "s_blk_WAIT__waiting",
+ "s_blk_EXPIRE__armed",
+ "s_blk_SHELVED__waiting",
+ "s_blk_COLLECTED__armed",
+ "s_blk_CANCELLED__armed"
+ ])
+
+ {:ok, machine_state, effects} =
+ Statifier.send_event(machine_state, "deadline.hold_confirm")
+
+ assert sent(effects) == []
+ assert cancelled(effects) == ["s_blk_TIMER__send"]
+
+ assert Statifier.active_leaf_states(machine_state) ==
+ MapSet.new([
+ "s_blk_PICKUP__done_lane_confirm",
+ "s_blk_SHELVED__waiting",
+ "s_blk_COLLECTED__armed",
+ "s_blk_CANCELLED__armed"
+ ])
+
+ {:ok, machine_state, effects} = Statifier.send_event(machine_state, "hold.collected")
+
+ assert Statifier.active_leaf_states(machine_state) == MapSet.new(["s_blk_ROOT__o_done"])
+ assert sent(effects) == ["hold.closed"]
+ assert cancelled(effects) == ["s_blk_SHELVED__send"]
+ end
+
+ # Sabotage: in the fixture, the two branches' conditions swapped - red:
+ # the shelf lane parks and the confirm lane arms no deadline (verified).
+ test "only the confirm lane asked: the bound alone ends the pickup", ctx do
+ {machine_state, effects} = start(ctx.machine, true, false)
+
+ assert delayed(effects) == [{"deadline.hold_confirm", @one_day_ms}]
+
+ assert Statifier.active_leaf_states(machine_state) ==
+ MapSet.new([
+ "s_blk_WAIT__waiting",
+ "s_blk_EXPIRE__armed",
+ "s_blk_PICKUP__done_lane_shelf"
+ ])
+
+ {:ok, machine_state, effects} =
+ Statifier.send_event(machine_state, "deadline.hold_confirm")
+
+ assert Statifier.active_leaf_states(machine_state) == MapSet.new(["s_blk_ROOT__o_done"])
+ assert sent(effects) == ["hold.closed"]
+ assert cancelled(effects) == ["s_blk_TIMER__send"]
+ end
+
+ # Sabotage: in the fixture, the shelf branch's condition set to `true`
+ # - red: the chart parks on the hold shelf for a hold that asked for
+ # neither lane (verified).
+ test "neither asked: every lane finishes at once, with no timer, and the hold is closed",
+ ctx do
+ {machine_state, effects} = start(ctx.machine, false, false)
+
+ assert Statifier.active_leaf_states(machine_state) == MapSet.new(["s_blk_ROOT__o_done"])
+ assert sent(effects) == ["hold.placed", "hold.closed"]
+ assert delayed(effects) == []
+ end
+ end
+
+ describe "the composed document: what Describe and the Map show" do
+ # Sabotage: in the fixture, the lane `shelf` renamed `rack` (in
+ # `lanes` and its slot key) - red: the Map's second lane is titled
+ # `rack`, not `shelf` (verified).
+ test "Describe names every arm, and the Map draws both lanes and every edge" do
+ document = composed()
+ lines = Describe.render(Describe.outline(document, palette(), []), [])
+
+ for line <- [
+ "Run 2 lanes at the same time (all of)",
+ "After Run 2 lanes at the same time (done), Send hold.closed",
+ "The branch: when wants_confirmation, Run interruptible steps",
+ "The branch: when held_on_shelf, Run interruptible steps",
+ "In 1 day, send deadline.hold_confirm",
+ "When deadline.hold_confirm, abandon",
+ "On deadline.hold_confirm, When deadline.hold_confirm, abandon abandons the group",
+ "In 1 day, deadline.hold_confirm reaches When deadline.hold_confirm, abandon",
+ "Wait 7d",
+ "Wait 7d (done) ends the group",
+ "On hold.collected, When hold.collected, abandon abandons the group",
+ "On hold.cancelled, When hold.cancelled, abandon abandons the group"
+ ] do
+ assert line in lines
+ end
+
+ assert Enum.count(lines, &(&1 == ~s[Decide: When "asked", otherwise (one of)])) == 2
+ assert Enum.count(lines, &(&1 == "The branch: otherwise, the end of the branch")) == 2
+
+ graph = document |> ViewModel.build(palette(), []) |> BlockMap.graph()
+ parallel = find_node(graph, "blk_PICKUP")
+
+ assert for(lane <- parallel["children"], do: {lane["id"], lane["title"]}) == [
+ {"blk_PICKUP/lane_confirm", "confirm"},
+ {"blk_PICKUP/lane_shelf", "shelf"}
+ ]
+
+ assert BlockMap.interrupts(graph) == [
+ %{"from" => "blk_EXPIRE", "group" => "blk_CONFIRM", "to" => "exit"},
+ %{"from" => "blk_COLLECTED", "group" => "blk_SHELF", "to" => "exit"},
+ %{"from" => "blk_CANCELLED", "group" => "blk_SHELF", "to" => "exit"}
+ ]
+
+ assert BlockMap.timers(graph) == [
+ %{
+ "from" => "blk_TIMER",
+ "to" => "blk_EXPIRE",
+ "event" => "deadline.hold_confirm",
+ "delay" => "1d"
+ }
+ ]
+ end
+ end
+
+ defp page, do: File.read!(@page)
+
+ defp palette, do: Palette.core()
+
+ defp composed do
+ {:ok, document} = Document.from_json(File.read!(@composed))
+ document
+ end
+
+ # Starts the chart with the hold's two answers in the datamodel, as a host
+ # supplies them.
+ defp start(machine, wants_confirmation, held_on_shelf) do
+ Statifier.initialize(machine,
+ datamodel: %{
+ "wants_confirmation" => wants_confirmation,
+ "held_on_shelf" => held_on_shelf
+ }
+ )
+ end
+
+ # The compiled chart's state `id`, from its opening tag to the final
+ # `final` it closes with: everything the state holds that this test
+ # looks for.
+ defp within(scxml, id, final) do
+ [_before, rest] = String.split(scxml, ~s( id} = node, id), do: node
+
+ defp find_node(%{"children" => children}, id),
+ do: Enum.find_value(children, &find_node(&1, id))
+
+ defp find_node(_node, _id), do: nil
+end