From 26d2b5f2b6f764fb1701653eb7e9e9d3347fa9f9 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Thu, 8 Oct 2026 02:45:49 -0600 Subject: [PATCH] Tests the three flow patterns composed One new test module and one fixture compose the flow-patterns guide's three documents in one: a core.parallel of optional arms, each lane 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, with handlers on its rail) in the other. The world is the guide's library hold. The test asserts Publish.findings/3 reports no error, the compile succeeds with no warning, each pattern's timer and interrupt lines are the guide's own lines byte for byte inside that pattern's group and lane, the chart runs with the bound failing open inside its lane, and Describe and the Map name every arm. Dropping the deadline handler raises no Publish finding and no compile warning: the compiled-chart test, the two confirm-lane run tests and the Describe test go red instead, as the note above the test records. No file under lib changes. Tests take no changelog fragment. Gate: full mix quality green on this tree (3,960 of 3,960 tests, 95.3% coverage, dialyzer clean, ADR cites green). Refs: sb-2qag --- .../documents/flow_patterns/composed.json | 62 ++++ .../flow_patterns_composed_test.exs | 301 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 test/fixtures/documents/flow_patterns/composed.json create mode 100644 test/statifier_blocks/flow_patterns_composed_test.exs 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