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