diff --git a/changelog.d/sui-t36.6.md b/changelog.d/sui-t36.6.md new file mode 100644 index 0000000..c923472 --- /dev/null +++ b/changelog.d/sui-t36.6.md @@ -0,0 +1,9 @@ +### Added + +- `StatifierUI.EventInjection.build/1` turns an `ADR-0003` fixture bundle + (or `nil`) into the event-injection pane model: a sorted palette of + editable event buttons via `StatifierUI.EventInjection.Palette`, a + `free_form_only?` flag for the fixture-less degraded mode, and + `send/2`/`send_draft/3` to deliver a `StatifierUI.EventInjection.Draft` + through `Statifier.Session.send_event/2` - the ordinary recordable input + path, per statifier ADR-0029. diff --git a/docs/plans/260822-sui-t36.6-event-injection-pane.md b/docs/plans/260822-sui-t36.6-event-injection-pane.md new file mode 100644 index 0000000..96a446a --- /dev/null +++ b/docs/plans/260822-sui-t36.6-event-injection-pane.md @@ -0,0 +1,662 @@ +--- +date: 2026-08-22 +issue: sui-t36.6 +title: Event injection pane +status: draft +tags: [plan, kino, fixtures, events, session] +--- + +# Event injection pane implementation plan + +## Overview + +The model behind the inspector's event-firing form: an event palette +populated from an ADR-0003 fixture bundle's `events` map (event name plus its +sample `_event.data` payload, rendered as editable JSON text), a free-form +name-and-payload escape hatch that needs no fixtures at all, and one send +path that goes through `Statifier.Session.send_event/2` with a +`Statifier.Event.external/2` value - the ordinary recordable input path, per +statifier ADR-0029, never a side door. + +Three pure modules land under `lib/statifier_ui/event_injection/` plus one +top-level `StatifierUI.EventInjection`. **No `Kino` reference in code**; the +form widget, its controls, and the composition with the other panes belong to +`sui-t36.8`. This plan follows the convention `sui-t36.4` (`StatifierUI.Diagram`) +and `sui-t36.5` (`StatifierUI.EventLog`) already set: pure data in, +`{:ok, struct} | {:error, reason}` out, `build/1`-style constructors that +refuse invalid input rather than guessing, and `Kino` named only in moduledoc +prose. + +Beads issue: `sui-t36.6` (parent `sui-t36`, the Livebook inspector; +depends on `sui-t36.2` fixtures core, closed; blocks `sui-t36.8`, the widget +assembly that composes the panes). + +## Current State Analysis + +What exists in this worktree (cut from `origin/main` at `767d0f6`): + +- `lib/statifier_ui/fixtures.ex` - `%StatifierUI.Fixtures{scenarios:, events:, + diagnostics:}` with `event/2` (`{:ok, payload} | :error`), `event_names/1` + (sorted, ADR-0005 canonical order), `new/1`, and `from_source/1`. Event + payloads are **preserved verbatim and are not validated**: `validate_events/1` + checks only that every key is a string + (`lib/statifier_ui/fixtures.ex`, `validate_events/1`). Scenario datamodels are validated + deeply; event payloads are not. +- `lib/statifier_ui/value.ex` - the ADR-0005 codec. `encode/1` maps a + predicator value to a JSON-ready term (`:undefined` -> `%{"$undefined" => + true}`, `Date`/`DateTime`/durations to their `$`-tagged shapes) and returns + `{:error, {:unsupported_value, term}}` for anything outside predicator's + closed value domain; `decode/1` is the inverse and rejects unknown + `$`-prefixed one-key tags. +- `lib/statifier_ui/trace/json.ex` - `StatifierUI.Trace.Json.encode_to_string/1`, + the canonical encoder (object keys lexicographic at every level). This is the + repo's convention for turning a JSON-ready term into text; it is built on the + stdlib `JSON` module, which is also what `Fixtures.Sidecar` reads with + (`lib/statifier_ui/fixtures/sidecar.ex:125`, `JSON.decode/1`). +- `lib/statifier_ui/trace/` - the wire format producer. **This plan touches + none of it.** An injected event is an *input*, not a trace message; ADR-0005 + explicitly does not give inputs a wire shape. +- `test/support/trace/session_case.ex` - compiles a heredoc chart and starts a + real `Statifier.Session` with a pinned `session_id` + (`start_early!/3`, `start_late!/3`, `wait_for_seq/3`). This is the live-session + harness the send-path test reuses. + +There is no event-injection code of any kind, and no module in `lib/` sends +anything to a session today. + +### The engine surface, verified + +- `Statifier.Event.external/2` (`deps/statifier/lib/statifier/event.ex:95-106`) + builds `%Statifier.Event{name:, type: :external, data:, cause: nil, ...}`. + `data` defaults to `:undefined` - "no data" - and the moduledoc is explicit + that `:undefined`, `nil` ("data, present, and null") and `%{}` ("data, + empty") are three distinct states that must not collapse. +- `Statifier.Session.send_event/2` + (`deps/statifier/lib/statifier/session.ex:530-535`) is a `GenServer.cast` + returning `:ok` before the event is necessarily processed. The string clause + is a convenience for a no-data event; "a caller that needs event data builds + the `%Statifier.Event{}` directly." +- `Statifier.Session.send_invoked_event/3` and `interpret/2` exist and are + **not** used here: the first is the child-to-parent direction, the second is + the embedder seam whose recording obligation statifier ADR-0029 widened. + `send_event/2` is the plain recordable path this bead is required to use. + +### What is already decided and must be honored + +- **ADR-0003** - the fixtures contract. `events` is *one sample payload per + event name*; multiple samples "is a possible later extension, not part of + this contract". The palette reads that map and adds nothing to it. +- **ADR-0005** - the value encoding. Payload text is ADR-0005 JSON in both + directions, resolved through `StatifierUI.Value`. There is no second + spelling for a predicator value in this repo and this plan introduces none. +- **ADR-0006** - datasets and expressions are explicitly out of `sui-t36` + scope ("its palette, explorer, and event log draw nothing from datasets or + expressions"). The palette reads `events` only. +- **ADR-0004** - `kino` and `phoenix_live_view` stay optional, enforced at + compile time. Nothing here references either. +- **statifier ADR-0029** - replay records four inputs, of which the external + event log is one. Every send in this plan is a `send_event/2` with an + `Event.external/2` value, so a recorded session stays replayable. +- **ADR-0002 / CLAUDE.md** - the engine is read-only from here. Any engine gap + found is an `st-` bead. + +### Key Discoveries + +- `Fixtures.event/2` returning `:error` is distinct from an event whose + payload is `nil` or `:undefined` (`lib/statifier_ui/fixtures.ex`, `event/2`). + The palette must preserve that three-way distinction all the way into the + text field, or a fixture that means "this event carries no data" will be + sent as one that carries `null`. +- Because event payloads are unvalidated at load time + (`lib/statifier_ui/fixtures.ex`, `validate_events/1`), a bundle can legitimately hold a + payload outside predicator's value domain (a tuple, a pid, an arbitrary + struct). `Value.encode/1` returns `{:error, {:unsupported_value, term}}` for + those. Refusing the whole palette over one bad sample would take the pane + down for a bundle that is otherwise fine, so those entries are **skipped and + recorded as diagnostics** - the same severity logic ADR-0003 applies to its + fixture lint ("absence of a fixture entry is weaker evidence than presence + of a contradiction"), and the same `StatifierUI.Fixtures.diagnostic()` shape + the bundle itself already carries. +- `Trace.Json.encode_to_string/1` is typed against + `StatifierUI.Trace.Message.json()`, which is the structural "JSON-ready + term" type (`lib/statifier_ui/trace/message.ex`, the `json()` typedoc), not something + trace-specific. A `Value.encode/1` result is exactly that type, so reusing + the canonical encoder here is a type-clean reuse and gives the palette + deterministic, byte-stable prefill text (sorted keys at every level). +- The stdlib `JSON` module has no pretty-printer. Payload prefill text is + therefore canonical single-line JSON, which is also what makes a palette + entry's text field byte-comparable in tests. + +## Desired End State + +`StatifierUI.EventInjection.build/1` takes a `StatifierUI.Fixtures.t()` or +`nil` and returns a pane model: a sorted list of palette entries, each with an +event name, its decoded sample payload, and the ADR-0005 JSON text a form +prefills its payload field with; plus the diagnostics for any fixture event +that could not be encoded. With `nil` (or a bundle whose `events` map is +empty) the model is `free_form_only?: true` with no entries - the degraded +mode the bead requires. + +`StatifierUI.EventInjection.Draft.build/2` takes whatever the form's two +fields hold - an event name string and a payload text string - and returns +`{:ok, %Statifier.Event{}}` or `{:error, reason}`, having round-tripped the +text through `JSON.decode/1` and `StatifierUI.Value.decode/1`. Blank payload +text means `data: :undefined`. + +`StatifierUI.EventInjection.send/2` delivers a built draft through +`Statifier.Session.send_event/2` and nothing else. + +Verified by: `mix quality` green; a live-session test that fires a palette +entry at a real `Statifier.Session` and observes the chart transition on it; +and a round-trip test proving `Fixtures` payload -> entry text -> `Draft` -> +`%Statifier.Event{}.data` reproduces the fixture payload (durations +canonically, per `Value`'s documented exception). + +## What We're NOT Doing + +- **No `Kino`.** No control, no frame, no `Kino.Control.form/2`, no + subscription to form events. `sui-t36.8` owns every line of that, and this + plan's modules are the data it drives. +- **No change to the trace wire format.** An injected event is an input. + ADR-0005 states outright that it "does not give replay recordings a wire + shape"; nothing here adds a message type, a field, or a `docs/wire-format.md` + edit. +- **No change to the fixtures contract.** No second sample per event, no new + sidecar key, no `datasets`/`expressions` consumption (ADR-0006 puts those + outside `sui-t36`). +- **No engine change.** `send_event/2` and `external/2` are used exactly as + they are. See Open Questions for the one thing a future pane might want from + the engine, recorded rather than built. +- **No validation of an event name against the chart.** An SCXML event name + that matches no transition is legal and firing one is a legitimate debugging + act; the pane must not refuse it. Name validation is syntactic only (see + Phase 1). +- **No scenario/datamodel seeding.** Choosing a scenario to start a session + with belongs to `sui-t36.7`/`sui-t36.8`, not to the injection pane. +- **No send-result correlation or acknowledgement UI.** `send_event/2` is a + cast; see Open Questions. +- **No `Kino`-free "renderer" module.** `sui-t36.5` shipped an `EventLog.Markdown` + because a log has a textual rendering; a form does not. If `sui-t36.8` wants a + palette listing in Markdown it is three lines over `entries/1` and belongs in + that bead. (Recorded here because a reviewer may expect the sibling's + three-group shape; the asymmetry is deliberate.) + +## Implementation Approach + +Three phases, split on module boundaries, each independently committable and +each leaving the gate green on its own: + +1. **Draft** - the input side. Text in, `%Statifier.Event{}` out. Depends on + nothing but `Value`, the stdlib `JSON`, and `Statifier.Event`. Fully + unit-testable with no fixtures and no session. +2. **Palette** - the fixtures side. `Fixtures.t()` in, entries plus + diagnostics out. Depends on `Value` and `Trace.Json`. Fully unit-testable + with in-memory bundles and the existing `test/support/fixtures/` sources. +3. **Pane model and send path** - the composition: `EventInjection.build/1` + over the palette, `send/2` over the draft, the live-session test, the + moduledoc that states the ADR-0029 rule, and the changelog fragment. + +Phase 1 and Phase 2 do not depend on each other and could be done in either +order; Phase 3 depends on both. Each phase ships its own tests, so no phase +leaves a structure nothing exercises. + +--- + +## Phase 1: Draft - form input to a `%Statifier.Event{}` + +### Overview + +The escape hatch, and also the machinery a palette entry's edited payload goes +through before it is sent. This phase makes the free-form field work end to +end without any fixtures existing. + +### Changes Required: + +#### 1. The draft module + +**File**: `lib/statifier_ui/event_injection/draft.ex` +**Changes**: New module `StatifierUI.EventInjection.Draft`. + +``` +@spec build(String.t(), String.t() | nil) :: + {:ok, Statifier.Event.t()} | {:error, reason()} +def build(name, payload_text \\ nil) +``` + +Behavior, in order: + +1. **Name.** Trim leading/trailing whitespace. Reject `""` with + `{:error, :blank_event_name}`. Reject a name containing whitespace or a + control character with `{:error, {:invalid_event_name, name}}` - SCXML + event names are dot-delimited tokens, and a name with a space in it can + never match a transition's `event` attribute, so it is a typo rather than a + debugging choice. Reject a non-binary with + `{:error, {:invalid_event_name, other}}`. No other name rule: an unmatched + but well-formed name is legal and must go through. +2. **Payload.** `nil` or a string that is blank after trimming means **no + data**: `data: :undefined`. Otherwise `JSON.decode/1`, mapping a decode + failure to `{:error, {:invalid_json, reason}}`, then + `StatifierUI.Value.decode/1`, mapping its failure to + `{:error, {:invalid_payload, reason}}` - which is where an unknown + `$`-prefixed tag or a malformed `$date` surfaces. +3. **Construct** with `Statifier.Event.external(name, data: value)` and return + `{:ok, event}`. No other `external/2` option is set: `invokeid`, `origin`, + `origintype` and `sendid` all belong to delivery paths this pane is not. + +A `@typedoc`'d `reason/0` union enumerates the four error shapes, so a caller +(and `sui-t36.8`'s form) can pattern-match instead of string-matching. + +The moduledoc states the three-way distinction explicitly and how the single +text field spells each one: **blank** is `:undefined` (no data), `null` is +`nil` (present and null), `{}` is the empty map. This is the one place in the +pane where a user can express all three, and getting it wrong is the failure +mode `Statifier.Event`'s own moduledoc warns about. + +#### 2. Tests + +**File**: `test/statifier_ui/event_injection/draft_test.exs` +**Changes**: New test module. Cases: a bare name with no payload +(`data == :undefined`); `"null"` (`data == nil`); `"{}"` (`data == %{}`); an +object payload with nested values; `{"$undefined": true}` decoding to the +sentinel; `{"$date": "2026-08-22"}` decoding to a `Date`; malformed JSON; +an unknown `$tag`; a blank name; a whitespace-bearing name; a non-binary +name; a name with surrounding whitespace trimmed. Assert on the whole +`%Statifier.Event{}` struct where practical, per the repo's +"structs and pattern matching over multiple asserts" convention, and assert +`type: :external` and `cause: nil` at least once. + +### Success Criteria: + +#### Automated Verification: +- [x] Full quality gate passes (`mix quality`, no `○` line beyond the two + permanent ones named in `CLAUDE.md`) +- [x] `lib/statifier_ui/event_injection/draft.ex` and + `test/statifier_ui/event_injection/draft_test.exs` exist +- [x] Every public function in the new module carries a `@spec` (enforced by + the gate's credo/doctor stages) +- [x] `grep -r Kino lib/statifier_ui/event_injection/` matches nothing + +#### Manual Verification: +- [ ] In `iex -S mix`, `Draft.build("payment.success", ~s({"amount": 1999}))` + returns an event whose `data` is `%{"amount" => 1999}`, and + `Draft.build("payment.success", "")` returns one whose `data` is + `:undefined` +- [ ] The error reasons read well enough that a form could show them to a user + verbatim +- [ ] No regressions in related features + +**Implementation Note**: Use `mix quality --profile loop` between edits; run +the full `mix quality` as the phase gate. In interactive execution, pause here +for the human to confirm the manual testing before moving to the next phase. +In looped (`--loop`) execution, this phase's Automated Verification gates +advancement automatically (via `/wurk:commit --auto`), and Manual Verification +items are deferred and surfaced once at the end instead of blocking here. + +--- + +## Phase 2: Palette - fixture events to editable entries + +### Overview + +The fixtures side: turn a bundle's `events` map into a sorted list of entries +a form can render as buttons, each carrying the prefill text for the payload +field. + +### Changes Required: + +#### 1. The entry struct + +**File**: `lib/statifier_ui/event_injection/entry.ex` +**Changes**: New module `StatifierUI.EventInjection.Entry`. + +``` +@type t :: %__MODULE__{ + name: String.t(), + payload: term(), + payload_text: String.t() + } +defstruct [:name, :payload, :payload_text] +``` + +`payload` is the fixture value verbatim (a predicator value, possibly +`:undefined`); `payload_text` is its ADR-0005 JSON, canonically encoded. A +`payload` of `:undefined` yields `payload_text: ""` - the blank field +`Draft.build/2` reads back as "no data" - rather than +`{"$undefined": true}`, so the palette's round trip through the form is the +identity on the common case. Every other value, `nil` included, gets its JSON +text (`"null"` for `nil`). This asymmetry is the single place the pane +translates between the encoding and the form affordance, and the moduledoc +says so. + +#### 2. The palette builder + +**File**: `lib/statifier_ui/event_injection/palette.ex` +**Changes**: New module `StatifierUI.EventInjection.Palette`. + +``` +@spec build(StatifierUI.Fixtures.t() | nil) :: {:ok, t()} | {:error, term()} +@spec entry(t(), String.t()) :: {:ok, Entry.t()} | :error +@spec names(t()) :: [String.t()] +``` + +`t()` is `%Palette{entries: [Entry.t()], diagnostics: [Fixtures.diagnostic()]}`. + +- `build(nil)` returns an empty palette with no diagnostics. +- `build(%Fixtures{} = bundle)` walks `Fixtures.event_names/1` (already sorted, + ADR-0005 canonical order), fetching each payload with `Fixtures.event/2` and + encoding it with `StatifierUI.Value.encode/1`, then + `StatifierUI.Trace.Json.encode_to_string/1`. +- An encode failure **does not fail the build**: the entry is omitted and a + diagnostic is appended with `kind: :unencodable_event_payload`, a message + naming the event and the `Value` reason, `path: ["events", name]`, and + `source: nil`. Diagnostics come back in event-name order, like the entries. +- **Atom map keys do not round-trip, by design.** `Fixtures` validates + scenario keys deeply but leaves event payloads verbatim, so a behaviour + source can hand over an atom-keyed payload map. ADR-0005 says atom keys + "serializ[e] as their names", which is what + `Trace.Json.encode_to_string/1` produces, and `Value.decode/1` reads them + back as strings. The entry's `payload` field therefore keeps the atoms and + its `payload_text` does not; a sent event carries the string-keyed form. + This is the correct outcome - the engine's datamodel is string-keyed and a + JSON sidecar could never have expressed the atoms - and the round-trip test + asserts the string-keyed result rather than identity for this one case. +- Anything that is neither a `%Fixtures{}` nor `nil` returns + `{:error, {:invalid_fixtures, other}}` - the "refuse invalid input rather + than guess" rule the sibling modules follow. +- `entry/2` mirrors `Fixtures.event/2`'s `{:ok, _} | :error` shape rather than + inventing a third convention. + +#### 3. Tests + +**File**: `test/statifier_ui/event_injection/palette_test.exs` +**Changes**: New test module. Cases: `build(nil)`; a bundle with no events; a +bundle built with `Fixtures.new/1` covering a map payload, `nil`, +`:undefined`, a `Date`, and a duration; entry order is sorted; `payload_text` +is canonical (object keys sorted - assert the exact string for a two-key +payload built in reverse key order, which is the assertion that would catch a +regression to plain `JSON.encode!/1`); an event whose payload is a tuple +produces a diagnostic and no entry while its siblings still appear; +`{:error, {:invalid_fixtures, _}}` for a non-bundle. Also build from the +existing `test/support/fixtures/payment_source.ex` through +`Fixtures.from_source/1`, so the palette is exercised against the same +fixtures the rest of the suite uses. + +### Success Criteria: + +#### Automated Verification: +- [x] Full quality gate passes +- [x] `lib/statifier_ui/event_injection/entry.ex`, + `lib/statifier_ui/event_injection/palette.ex`, and + `test/statifier_ui/event_injection/palette_test.exs` exist +- [x] A test asserts the exact canonical `payload_text` string for a + multi-key payload (keys sorted at every level) +- [x] A test asserts a bundle with one unencodable payload still yields + entries for the others plus exactly one diagnostic +- [x] `grep -r Kino lib/statifier_ui/event_injection/` matches nothing + +#### Manual Verification: +- [ ] The prefill text for a real fixture reads like something a person would + be willing to edit in a one-line text field +- [ ] A diagnostic message names the offending event clearly enough to fix the + bundle from it alone +- [ ] No regressions in related features + +**Implementation Note**: Use `mix quality --profile loop` between edits; run +the full `mix quality` as the phase gate. In interactive execution, pause here +for the human to confirm the manual testing before moving to the next phase. +In looped (`--loop`) execution, this phase's Automated Verification gates +advancement automatically (via `/wurk:commit --auto`), and Manual Verification +items are deferred and surfaced once at the end instead of blocking here. + +--- + +## Phase 3: Pane model and the send path + +### Overview + +The one module `sui-t36.8` will actually hold: the pane model over the +palette, the degraded-mode flag, and the single function that puts an event +into a session. + +### Changes Required: + +#### 1. The pane module + +**File**: `lib/statifier_ui/event_injection.ex` +**Changes**: New module `StatifierUI.EventInjection`. + +``` +@type t :: %__MODULE__{ + palette: Palette.t(), + free_form_only?: boolean() + } + +@spec build(StatifierUI.Fixtures.t() | nil) :: {:ok, t()} | {:error, term()} +@spec entries(t()) :: [Entry.t()] +@spec diagnostics(t()) :: [StatifierUI.Fixtures.diagnostic()] +@spec send(Statifier.Session.server(), Statifier.Event.t()) :: :ok +@spec send_draft(Statifier.Session.server(), String.t(), String.t() | nil) :: + :ok | {:error, Draft.reason()} +``` + +- `build/1` delegates to `Palette.build/1` and sets + `free_form_only?: entries == []`. That flag is the bead's "without fixtures + the palette degrades to the free-form field alone", made a property of the + model rather than a rule `sui-t36.8` has to remember: it is true for `nil`, + for an empty `events` map, and for a bundle whose every event payload was + unencodable (in which case the diagnostics say why). +- `send/2` is `Statifier.Session.send_event(server, event)` and nothing else. + It exists so that there is exactly one line in this repository that puts an + event into a session, and so that its `@doc` can carry the ADR-0029 rule + where a reader of the sending code will see it. +- `send_draft/3` is `Draft.build/2` followed by `send/2` - the whole form + submission, one call, errors as values. + +The moduledoc states the rule in full: every send goes through +`Statifier.Session.send_event/2` with a `Statifier.Event.external/2` value, +because that is the recordable input path (statifier ADR-0029's external event +log); `Statifier.Session.interpret/2` and `send_invoked_event/3` are not this +pane's doors, and a recorded session stays replayable only because of that. +It also notes that `send_event/2` is a cast: `:ok` means enqueued, not +processed, and the effect of an injected event is observed on the trace +stream (`sui-t36.3`/`sui-t36.5`), not from this return value. + +#### 2. Unit tests + +**File**: `test/statifier_ui/event_injection_test.exs` +**Changes**: New test module covering `build/1` with `nil`, an empty bundle, +and a populated bundle (`free_form_only?` true, true, false respectively); +`entries/1` and `diagnostics/1` pass-through; `send_draft/3` returning +`{:error, :blank_event_name}` without touching any session (pass a pid that +would crash if cast to - or simply assert the error is returned before any +message is sent, using a `spawn`ed collector process that receives nothing). + +#### 3. Live-session round trip + +**File**: `test/statifier_ui/event_injection/session_test.exs` +**Changes**: New test using `StatifierUI.Test.Support.Trace.SessionCase`. + +Compile a small chart (triple-quoted heredoc, 4-space base indent, per +`CLAUDE.md`) with a transition on a fixture event name, start a real session +with a subscriber via `start_early!/3`, build a palette from a bundle holding +that event, take its entry, run `entry.payload_text` back through +`Draft.build/2`, `send/2` it, wait with `wait_for_seq/3`, and assert the +resulting trace shows the event dequeued with the fixture's payload and the +target state entered. This is the test that proves the whole loop - +fixture payload -> entry text -> draft -> event -> session -> trace - closes, +and that the payload survived the ADR-0005 round trip. + +Also assert the same chart transitions on a free-form send with an edited +payload, so the escape hatch is covered against a live session too. + +#### 4. Changelog fragment + +**File**: `changelog.d/sui-t36.6.md` +**Changes**: One fragment for the whole bead (public API addition), following +`changelog.d/README.md` and the `sui-t36.5` fragment's shape. + +### Success Criteria: + +#### Automated Verification: +- [x] Full quality gate passes +- [x] `lib/statifier_ui/event_injection.ex`, + `test/statifier_ui/event_injection_test.exs`, + `test/statifier_ui/event_injection/session_test.exs`, and + `changelog.d/sui-t36.6.md` exist +- [x] The live-session test asserts on the trace, not on `send/2`'s `:ok` +- [x] No side door: `grep -rn "\.interpret(\|send_invoked_event(" lib/` + matches nothing. **Call sites, not prose** - the moduledoc above names + both functions in order to say they are not used, so a bare + word-grep for `interpret` would fail on the very sentence that states + the rule. The parenthesis is what makes this a call-site check. +- [x] `grep -rn "Kino" lib/` matches only moduledoc prose +- [x] `grep -rn "Session.send_event(" lib/` matches exactly one line + +#### Manual Verification: +- [ ] In `iex -S mix`, building a pane from a real fixture bundle and firing an + entry at a running session moves the chart, and firing an unmatched + event name is accepted and simply changes nothing +- [ ] The moduledoc reads as an instruction `sui-t36.8` can follow without + re-deriving the ADR-0029 rule +- [ ] The changelog fragment describes the addition in terms a library user + cares about +- [ ] No regressions in related features + +**Implementation Note**: Use `mix quality --profile loop` between edits; run +the full `mix quality` as the phase gate. In interactive execution, pause here +for the human to confirm the manual testing. This is the last phase, so the +full gate here is also the bead's gate. + +--- + +## Testing Strategy + +### Unit Tests: +- `Draft.build/2`: the three-way `:undefined` / `nil` / `%{}` payload spelling; + every `$`-tagged ADR-0005 shape decoding correctly; malformed JSON and + unknown tags as error values; the four name-rejection cases; whitespace + trimming. +- `Palette.build/1`: `nil` and empty bundles; sort order; canonical + `payload_text` bytes; `:undefined` yielding `""`; `nil` yielding `"null"`; + an unencodable payload becoming a diagnostic without taking the palette + down; `{:error, {:invalid_fixtures, _}}`. +- `EventInjection.build/1`: `free_form_only?` in all three situations; + `send_draft/3` failing before it sends. +- Round trip: for every payload kind, `Fixtures` value -> `Entry.payload_text` + -> `Draft.build/2` -> `%Statifier.Event{}.data` equals the original, with + durations equal canonically (all eight units, per `Value`'s documented + exception) rather than identically, and an atom-keyed payload map equal in + its string-keyed form (Phase 2's note). + +### Manual Testing Steps: +1. `iex -S mix`; build a bundle from `StatifierUI.Test.Support.Fixtures.PaymentSource` + via `Fixtures.from_source/1`, then `EventInjection.build/1` on it; read the + entries and confirm the prefill text is editable-looking. +2. Compile a chart with `Statifier.compile/1`, start a session with + `trace: true`, and `EventInjection.send_draft/3` a palette entry's name and + text at it; confirm the configuration moved. +3. Repeat with a name that matches no transition; confirm `:ok` and no change. +4. Repeat with deliberately broken payload text; confirm the error value comes + back and nothing was sent. + +## Open Questions + +Recorded rather than resolved, per this bead's constraints - neither is +blocking, and each would be someone else's decision to make. + +1. **No send-time correlation.** `Statifier.Session.send_event/2` is a cast + returning a bare `:ok`, and nothing on the resulting + `trace.event_dequeued` message ties back to a particular injection - two + identical events fired in a row are indistinguishable on the stream. A pane + that wants to say "your event landed, here is its round" therefore has to + correlate heuristically (name plus the next dequeue after the send). Giving + an injected event a caller-supplied correlation id would be an **engine** + change and so an `st-` bead, not a patch from here; it is not needed for + this slice, and `sui-t36.8` can display "sent" without it. Raise it if the + heuristic proves unusable in the widget. +2. **One sample payload per event.** ADR-0003 fixes `events` at one sample per + name and names multiple samples as "the natural first extension if it + bites". A palette is exactly the consumer that would feel it - an event + whose payload genuinely varies has one arm in the button. This plan does + not extend the contract; if the inspector demo makes the limit obvious, it + is an ADR amendment plus a `sui-` bead, not a quiet loader change. + +## References + +- Bead: `sui-t36.6` (parent `sui-t36`; blocks `sui-t36.8`) +- ADRs: `docs/adr/0003-fixtures-as-the-example-data-contract.md`, + `docs/adr/0005-language-neutral-trace-wire-format.md`, + `docs/adr/0006-datasets-and-expression-fixtures.md`, + `docs/adr/0004-one-package-with-optional-integrations.md`, + `docs/adr/0002-adopt-upstream-decisions-by-reference.md` +- Upstream: statifier `docs/adr/0029-session-interpret-stays-public.md` + (the four recorded inputs), `deps/statifier/lib/statifier/event.ex:95-106`, + `deps/statifier/lib/statifier/session.ex:530-535` +- Reused modules: `lib/statifier_ui/fixtures.ex` (`event/2`, `event_names/1`, `diagnostic()`), + `lib/statifier_ui/value.ex`, `lib/statifier_ui/trace/json.ex` (`encode_to_string/1`) +- Sibling precedent (pure module, no `Kino`, `build/1`): + `git show origin/sui-t36.5-event-log-pane:lib/statifier_ui/event_log.ex`, + and its plan + `git show origin/sui-t36.5-event-log-pane:docs/plans/260822-sui-t36.5-event-log-pane.md` +- Test harness: `test/support/trace/session_case.ex`, + `test/support/fixtures/payment_source.ex` + +## Deferred Manual Verification + +Manual verification items are deferred during looped (--loop) execution and +surfaced here once, rather than blocking after each phase. Confirm these +before considering the plan fully landed. + +### Phase 1 + +- [ ] In `iex -S mix`, `Draft.build("payment.success", ~s({"amount": 1999}))` + returns an event whose `data` is `%{"amount" => 1999}`, and + `Draft.build("payment.success", "")` returns one whose `data` is + `:undefined` +- [ ] The error reasons read well enough that a form could show them to a user + verbatim +- [ ] No regressions in related features + +**Implementation Note**: Use `mix quality --profile loop` between edits; run +the full `mix quality` as the phase gate. In interactive execution, pause here +for the human to confirm the manual testing before moving to the next phase. +In looped (`--loop`) execution, this phase's Automated Verification gates +advancement automatically (via `/wurk:commit --auto`), and Manual Verification +items are deferred and surfaced once at the end instead of blocking here. + +--- + +### Phase 2 + +- [ ] The prefill text for a real fixture reads like something a person would + be willing to edit in a one-line text field +- [ ] A diagnostic message names the offending event clearly enough to fix the + bundle from it alone +- [ ] No regressions in related features + +**Implementation Note**: Use `mix quality --profile loop` between edits; run +the full `mix quality` as the phase gate. In interactive execution, pause here +for the human to confirm the manual testing before moving to the next phase. +In looped (`--loop`) execution, this phase's Automated Verification gates +advancement automatically (via `/wurk:commit --auto`), and Manual Verification +items are deferred and surfaced once at the end instead of blocking here. + +--- + +### Phase 3 + +- [ ] In `iex -S mix`, building a pane from a real fixture bundle and firing an + entry at a running session moves the chart, and firing an unmatched + event name is accepted and simply changes nothing +- [ ] The moduledoc reads as an instruction `sui-t36.8` can follow without + re-deriving the ADR-0029 rule +- [ ] The changelog fragment describes the addition in terms a library user + cares about +- [ ] No regressions in related features + +**Implementation Note**: Use `mix quality --profile loop` between edits; run +the full `mix quality` as the phase gate. In interactive execution, pause here +for the human to confirm the manual testing. This is the last phase, so the +full gate here is also the bead's gate. + +--- diff --git a/lib/statifier_ui/event_injection.ex b/lib/statifier_ui/event_injection.ex new file mode 100644 index 0000000..31b1779 --- /dev/null +++ b/lib/statifier_ui/event_injection.ex @@ -0,0 +1,101 @@ +defmodule StatifierUI.EventInjection do + @moduledoc """ + The pane model behind the inspector's event-firing form: a palette built + from a fixture bundle's `events` map (`StatifierUI.EventInjection.Palette`), + a `free_form_only?` flag for the degraded mode the bead requires when there + are no fixtures to draw buttons from, and the one send path every submitted + form goes through. + + ## The one door in + + Every send in this pane goes through `Statifier.Session.send_event/2` with + a `Statifier.Event.external/2` value - the ordinary recordable input path. + Per statifier ADR-0029, replay records the external event log as one of + its four recorded inputs; `send_event/2` is what keeps a recorded session + replayable, because it is the path the recording actually watches. + `Statifier.Session.interpret/2` and `send_invoked_event/3` are **not** + this pane's doors: `interpret/2` is the embedder seam ADR-0029 widened the + recording obligation for, and `send_invoked_event/3` is the child-to-parent + invoke direction, not an operator firing an event by hand. Neither is + called from anywhere in this module, or anywhere else in this pane. + + `send_event/2` is a `GenServer.cast` - it returns `:ok` once the event is + enqueued, not once it has been processed. The effect of an injected event + (the dequeue, the transitions it triggers, the states it enters) shows up + on the trace stream (`StatifierUI.Trace.Subscriber`, `StatifierUI.EventLog`), + never on this module's return value. + + ## Degraded mode + + `build/1` sets `free_form_only?: true` whenever the palette has no + entries: for `nil`, for a bundle whose `events` map is empty, and for a + bundle whose every event payload turned out to be unencodable (in which + case `diagnostics/1` says why). The free-form escape hatch + (`send_draft/3` with a name and payload text typed by hand) needs no + fixtures at all and always works, degraded mode or not. + """ + + import Kernel, except: [send: 2] + + alias Statifier.Event + alias Statifier.Session + alias StatifierUI.EventInjection.Draft + alias StatifierUI.EventInjection.Entry + alias StatifierUI.EventInjection.Palette + alias StatifierUI.Fixtures + + @type t :: %__MODULE__{ + palette: Palette.t(), + free_form_only?: boolean() + } + + @enforce_keys [:palette, :free_form_only?] + defstruct [:palette, :free_form_only?] + + @doc """ + Builds a pane model from a fixture bundle, or from `nil` for the + fixture-less degraded mode. + + Delegates to `StatifierUI.EventInjection.Palette.build/1` and returns + `{:error, {:invalid_fixtures, other}}` for the same inputs that function + rejects. `free_form_only?` is `true` exactly when the resulting palette + has no entries. + """ + @spec build(Fixtures.t() | nil) :: {:ok, t()} | {:error, term()} + def build(fixtures) do + with {:ok, palette} <- Palette.build(fixtures) do + {:ok, %__MODULE__{palette: palette, free_form_only?: palette.entries == []}} + end + end + + @doc "The pane's palette entries, in event-name order." + @spec entries(t()) :: [Entry.t()] + def entries(%__MODULE__{palette: palette}), do: palette.entries + + @doc "The pane's diagnostics: one per fixture event that could not be encoded." + @spec diagnostics(t()) :: [Fixtures.diagnostic()] + def diagnostics(%__MODULE__{palette: palette}), do: palette.diagnostics + + @doc """ + Delivers `event` to `server` through `Statifier.Session.send_event/2` and + nothing else - the only line in this pane, and in this repository's + `lib/`, that puts an event into a session. + + `:ok` means enqueued, not processed; see the moduledoc. + """ + @spec send(Session.server(), Event.t()) :: :ok + def send(server, %Event{} = event), do: Session.send_event(server, event) + + @doc """ + Builds a draft from a form's name and payload text + (`StatifierUI.EventInjection.Draft.build/2`) and, on success, sends it + (`send/2`). A build failure is returned without touching `server` at all. + """ + @spec send_draft(Session.server(), String.t(), String.t() | nil) :: + :ok | {:error, Draft.reason()} + def send_draft(server, name, payload_text \\ nil) do + with {:ok, event} <- Draft.build(name, payload_text) do + send(server, event) + end + end +end diff --git a/lib/statifier_ui/event_injection/draft.ex b/lib/statifier_ui/event_injection/draft.ex new file mode 100644 index 0000000..5569e88 --- /dev/null +++ b/lib/statifier_ui/event_injection/draft.ex @@ -0,0 +1,138 @@ +defmodule StatifierUI.EventInjection.Draft do + @moduledoc """ + Turns a form's two free-form fields - an event name and a payload text - + into a `Statifier.Event.t()`, the ordinary recordable input + (`Statifier.Event.external/2`) rather than a side door. + + ## The three-way payload distinction + + `Statifier.Event`'s own moduledoc is explicit that `data` has three + states that must not collapse into each other: **no data** (`:undefined`), + **data, present, and null** (`nil`), and **data, present, and empty** + (`%{}`). One text field has to spell all three, and `build/2` resolves + them this way: + + * **blank** (`nil`, or a string that is empty after trimming) means + `data: :undefined` - "no data" - the same default `Event.external/2` + itself uses when the caller passes no `:data` option at all. + * `"null"` decodes through `JSON.decode/1` to `nil`, which + `StatifierUI.Value.decode/1` passes through unchanged - "data, + present, and null". + * `"{}"` decodes to `%{}` the same way - "data, present, and empty". + + Any other payload text is decoded as ADR-0005 JSON via `JSON.decode/1` + and then `StatifierUI.Value.decode/1`, so a `$`-tagged shape + (`$undefined`, `$date`, `$datetime`, `$duration`) resolves to the + predicator value it encodes. + """ + + alias Statifier.Event + alias StatifierUI.Value + + @typedoc """ + Why `build/2` refused to produce an event. + + * `:blank_event_name` - the name was empty, or all whitespace. + * `{:invalid_event_name, name}` - `name` was not a binary, or contained + whitespace or a control character (SCXML event names are + dot-delimited tokens; one with a space in it can never match a + transition's `event` attribute, so it is a typo rather than a + debugging choice). + * `{:invalid_json, reason}` - the payload text was not valid JSON; + `reason` is `JSON.decode/1`'s own error value. + * `{:invalid_payload, reason}` - the payload decoded as JSON but not as + an ADR-0005 value (an unknown `$`-prefixed tag, a malformed `$date`); + `reason` is `StatifierUI.Value.decode/1`'s own error value. + """ + @type reason :: + :blank_event_name + | {:invalid_event_name, term()} + | {:invalid_json, term()} + | {:invalid_payload, term()} + + @doc """ + Builds a `Statifier.Event.t()` from a form's name and payload text. + + `name` is trimmed of leading/trailing whitespace before validation. An + unmatched but well-formed name is legal and is not rejected here - only + syntax is checked; whether the name matches any transition is the + chart's business, not this function's. + + `payload_text` defaults to `nil`. `nil`, or a string that is blank after + trimming, means no data (`data: :undefined`); see the moduledoc for the + full three-way spelling. + + No option other than `:data` is passed to `Statifier.Event.external/2`: + `invokeid`, `origin`, `origintype`, and `sendid` all belong to delivery + paths this function is not. + + ## Examples + + iex> {:ok, event} = StatifierUI.EventInjection.Draft.build("payment.success", ~s({"amount":1999})) + iex> event.data + %{"amount" => 1999} + + iex> {:ok, event} = StatifierUI.EventInjection.Draft.build("payment.success", "") + iex> event.data + :undefined + + iex> StatifierUI.EventInjection.Draft.build("") + {:error, :blank_event_name} + + """ + @spec build(String.t(), String.t() | nil) :: {:ok, Event.t()} | {:error, reason()} + def build(name, payload_text \\ nil) + + def build(name, payload_text) when is_binary(name) do + trimmed_name = String.trim(name) + + with :ok <- validate_name(trimmed_name), + {:ok, data} <- decode_payload(payload_text) do + {:ok, Event.external(trimmed_name, data: data)} + end + end + + def build(other, _payload_text), do: {:error, {:invalid_event_name, other}} + + @spec validate_name(String.t()) :: :ok | {:error, reason()} + defp validate_name(""), do: {:error, :blank_event_name} + + defp validate_name(name) do + if String.match?(name, ~r/[\s\p{Cc}]/u) do + {:error, {:invalid_event_name, name}} + else + :ok + end + end + + @spec decode_payload(String.t() | nil) :: {:ok, term()} | {:error, reason()} + defp decode_payload(nil), do: {:ok, :undefined} + + defp decode_payload(text) when is_binary(text) do + case String.trim(text) do + "" -> + {:ok, :undefined} + + trimmed -> + with {:ok, decoded} <- json_decode(trimmed) do + value_decode(decoded) + end + end + end + + @spec json_decode(String.t()) :: {:ok, term()} | {:error, reason()} + defp json_decode(text) do + case JSON.decode(text) do + {:ok, decoded} -> {:ok, decoded} + {:error, reason} -> {:error, {:invalid_json, reason}} + end + end + + @spec value_decode(term()) :: {:ok, term()} | {:error, reason()} + defp value_decode(decoded) do + case Value.decode(decoded) do + {:ok, value} -> {:ok, value} + {:error, reason} -> {:error, {:invalid_payload, reason}} + end + end +end diff --git a/lib/statifier_ui/event_injection/entry.ex b/lib/statifier_ui/event_injection/entry.ex new file mode 100644 index 0000000..90bc50e --- /dev/null +++ b/lib/statifier_ui/event_injection/entry.ex @@ -0,0 +1,34 @@ +defmodule StatifierUI.EventInjection.Entry do + @moduledoc """ + One fixture event, rendered as a palette button: an event name, its sample + payload verbatim, and the ADR-0005 JSON text a form prefills the payload + field with. + + ## The `:undefined` asymmetry + + `payload` is the fixture value exactly as `StatifierUI.Fixtures.event/2` + returned it - a predicator value, possibly the `:undefined` sentinel that + means "no data". `payload_text` is that value's canonical ADR-0005 JSON, + with one deliberate exception: a `payload` of `:undefined` produces + `payload_text: ""` rather than `~s({"$undefined":true})`. + + The reason is the round trip. `StatifierUI.EventInjection.Draft.build/2` + reads a blank payload field as "no data" - the same spelling + `Statifier.Event.external/2` itself defaults to. Emitting the tagged JSON + text for `:undefined` would prefill a form field with text that, if sent + back unedited, still means "no data" but no longer looks blank to a person + editing it; emitting the blank string instead makes the palette's round + trip through the form the identity on the common case. Every other value, + `nil` included, gets its ordinary JSON text (`"null"` for `nil`, `"{}"` for + an empty map). This is the one place in the pane where the encoding and the + form affordance diverge, and it diverges on purpose. + """ + + @type t :: %__MODULE__{ + name: String.t(), + payload: term(), + payload_text: String.t() + } + + defstruct [:name, :payload, :payload_text] +end diff --git a/lib/statifier_ui/event_injection/palette.ex b/lib/statifier_ui/event_injection/palette.ex new file mode 100644 index 0000000..d13d80c --- /dev/null +++ b/lib/statifier_ui/event_injection/palette.ex @@ -0,0 +1,127 @@ +defmodule StatifierUI.EventInjection.Palette do + @moduledoc """ + A fixture bundle's `events` map (ADR-0003), turned into a sorted list of + `StatifierUI.EventInjection.Entry.t()` a form can render as buttons. + + `build/1` accepts a `StatifierUI.Fixtures.t()` or `nil`. `nil` (or a bundle + with no events at all) yields an empty palette with no diagnostics - the + degraded mode `StatifierUI.EventInjection` turns into its + `free_form_only?` flag. + + Every entry's payload is encoded through `StatifierUI.Value.encode/1` and + then `StatifierUI.Trace.Json.encode_to_string/1` - the same canonical, + byte-stable JSON encoder the trace wire format uses, reused here because a + `Value.encode/1` result is exactly the "JSON-ready term" shape that encoder + is typed against. Nothing about the trace wire format itself is touched; + an injected event is an input, not a trace message. + + A fixture event whose payload falls outside predicator's value domain (a + tuple, a pid, a struct other than `Date`/`DateTime`) does not fail the + whole build: the entry is omitted and a diagnostic is appended, in the same + `StatifierUI.Fixtures.diagnostic()` shape a bundle itself already carries. + One bad sample takes down one button, not the pane. + + ## Atom-keyed payloads do not round-trip + + `StatifierUI.Fixtures` validates scenario datamodels deeply but leaves + event payloads verbatim, so a behaviour source can hand over an + atom-keyed payload map. ADR-0005 says atom keys "serializ[e] as their + names", which is exactly what `StatifierUI.Trace.Json.encode_to_string/1` + produces and what `StatifierUI.Value.decode/1` reads back as strings. An + entry's `payload` field keeps the atoms; its `payload_text`, and therefore + any event actually sent from it, carries the string-keyed form. This is + correct rather than lossy: the engine's datamodel is string-keyed, and a + JSON sidecar could never have expressed the atoms in the first place. + """ + + alias StatifierUI.EventInjection.Entry + alias StatifierUI.Fixtures + alias StatifierUI.Trace.Json + alias StatifierUI.Value + + @type t :: %__MODULE__{ + entries: [Entry.t()], + diagnostics: [Fixtures.diagnostic()] + } + + defstruct entries: [], diagnostics: [] + + @doc """ + Builds a palette from a fixture bundle's `events` map, or an empty palette + from `nil`. + + Walks `StatifierUI.Fixtures.event_names/1` (already sorted, ADR-0005's + canonical order), so entries and diagnostics both come back in event-name + order. Returns `{:error, {:invalid_fixtures, other}}` for anything that is + neither a `StatifierUI.Fixtures.t()` nor `nil`. + """ + @spec build(Fixtures.t() | nil) :: {:ok, t()} | {:error, term()} + def build(nil), do: {:ok, %__MODULE__{}} + + def build(%Fixtures{} = fixtures) do + {entries, diagnostics} = + fixtures + |> Fixtures.event_names() + |> Enum.map(&build_entry(fixtures, &1)) + |> Enum.split_with(&match?({:entry, _}, &1)) + + {:ok, + %__MODULE__{ + entries: Enum.map(entries, fn {:entry, entry} -> entry end), + diagnostics: Enum.map(diagnostics, fn {:diagnostic, diagnostic} -> diagnostic end) + }} + end + + def build(other), do: {:error, {:invalid_fixtures, other}} + + @doc """ + Fetches an entry by event name. Mirrors `StatifierUI.Fixtures.event/2`'s + `{:ok, _} | :error` shape. + """ + @spec entry(t(), String.t()) :: {:ok, Entry.t()} | :error + def entry(%__MODULE__{entries: entries}, name) do + case Enum.find(entries, &(&1.name == name)) do + nil -> :error + entry -> {:ok, entry} + end + end + + @doc """ + Event names in the palette, sorted. + """ + @spec names(t()) :: [String.t()] + def names(%__MODULE__{entries: entries}), do: Enum.map(entries, & &1.name) + + @spec build_entry(Fixtures.t(), Fixtures.event_name()) :: + {:entry, Entry.t()} | {:diagnostic, Fixtures.diagnostic()} + defp build_entry(fixtures, name) do + {:ok, payload} = Fixtures.event(fixtures, name) + + case encode_payload_text(payload) do + {:ok, payload_text} -> + {:entry, %Entry{name: name, payload: payload, payload_text: payload_text}} + + {:error, reason} -> + {:diagnostic, unencodable_diagnostic(name, reason)} + end + end + + @spec encode_payload_text(term()) :: {:ok, String.t()} | {:error, term()} + defp encode_payload_text(:undefined), do: {:ok, ""} + + defp encode_payload_text(payload) do + with {:ok, encoded} <- Value.encode(payload) do + {:ok, Json.encode_to_string(encoded)} + end + end + + @spec unencodable_diagnostic(Fixtures.event_name(), term()) :: Fixtures.diagnostic() + defp unencodable_diagnostic(name, reason) do + %{ + kind: :unencodable_event_payload, + message: "event #{inspect(name)} has a payload that cannot be encoded: #{inspect(reason)}", + path: ["events", name], + source: nil + } + end +end diff --git a/test/statifier_ui/event_injection/draft_test.exs b/test/statifier_ui/event_injection/draft_test.exs new file mode 100644 index 0000000..45470ea --- /dev/null +++ b/test/statifier_ui/event_injection/draft_test.exs @@ -0,0 +1,81 @@ +defmodule StatifierUI.EventInjection.DraftTest do + use ExUnit.Case, async: true + + alias Statifier.Event + alias StatifierUI.EventInjection.Draft + + describe "build/2 - payload spelling" do + test "no payload text means no data (:undefined)" do + assert {:ok, %Event{name: "payment.success", type: :external, cause: nil, data: :undefined}} = + Draft.build("payment.success") + end + + test "blank payload text means no data (:undefined)" do + assert {:ok, %Event{data: :undefined}} = Draft.build("payment.success", " ") + end + + test "\"null\" payload text means data present and null" do + assert {:ok, %Event{data: nil}} = Draft.build("payment.success", "null") + end + + test "\"{}\" payload text means data present and empty" do + assert {:ok, %Event{data: %{}}} = Draft.build("payment.success", "{}") + end + + test "an object payload with nested values decodes fully" do + payload = ~s({"amount": 1999, "meta": {"currency": "usd"}}) + + assert {:ok, %Event{data: %{"amount" => 1999, "meta" => %{"currency" => "usd"}}}} = + Draft.build("payment.success", payload) + end + + test "the $undefined tag decodes to the sentinel" do + assert {:ok, %Event{data: :undefined}} = + Draft.build("payment.success", ~s({"$undefined": true})) + end + + test "the $date tag decodes to a Date" do + assert {:ok, %Event{data: ~D[2026-08-22]}} = + Draft.build("payment.success", ~s({"$date": "2026-08-22"})) + end + end + + describe "build/2 - payload errors" do + test "malformed JSON is rejected as invalid_json" do + assert {:error, {:invalid_json, _reason}} = Draft.build("payment.success", "{not json") + end + + test "an unknown $tag is rejected as invalid_payload" do + assert {:error, {:invalid_payload, {:unknown_tag, "$bogus"}}} = + Draft.build("payment.success", ~s({"$bogus": true})) + end + end + + describe "build/2 - name validation" do + test "a blank name is rejected" do + assert {:error, :blank_event_name} = Draft.build("") + end + + test "an all-whitespace name is rejected as blank after trimming" do + assert {:error, :blank_event_name} = Draft.build(" ") + end + + test "a name containing whitespace is rejected" do + assert {:error, {:invalid_event_name, "payment success"}} = + Draft.build("payment success") + end + + test "a non-binary name is rejected" do + assert {:error, {:invalid_event_name, :payment_success}} = Draft.build(:payment_success) + end + + test "surrounding whitespace on an otherwise valid name is trimmed" do + assert {:ok, %Event{name: "payment.success"}} = Draft.build(" payment.success ") + end + + test "an unmatched but well-formed name is accepted" do + assert {:ok, %Event{name: "no.such.transition", type: :external, cause: nil}} = + Draft.build("no.such.transition") + end + end +end diff --git a/test/statifier_ui/event_injection/palette_test.exs b/test/statifier_ui/event_injection/palette_test.exs new file mode 100644 index 0000000..790ff73 --- /dev/null +++ b/test/statifier_ui/event_injection/palette_test.exs @@ -0,0 +1,126 @@ +defmodule StatifierUI.EventInjection.PaletteTest do + use ExUnit.Case, async: true + + alias StatifierUI.EventInjection.Entry + alias StatifierUI.EventInjection.Palette + alias StatifierUI.Fixtures + alias StatifierUI.Test.Support.Fixtures.PaymentSource + + describe "build/1 - degraded mode" do + test "nil yields an empty palette with no diagnostics" do + assert {:ok, %Palette{entries: [], diagnostics: []}} = Palette.build(nil) + end + + test "a bundle with no events yields an empty palette" do + assert {:ok, fixtures} = Fixtures.new() + assert {:ok, %Palette{entries: [], diagnostics: []}} = Palette.build(fixtures) + end + end + + describe "build/1 - entries" do + test "covers a map, nil, :undefined, a Date, and a duration payload" do + assert {:ok, fixtures} = + Fixtures.new( + events: %{ + "payment.success" => %{"amount" => 1999}, + "payment.nulled" => nil, + "payment.pending" => :undefined, + "payment.scheduled" => ~D[2026-08-22], + "payment.delayed" => %{seconds: 30} + } + ) + + assert {:ok, %Palette{entries: entries, diagnostics: []}} = Palette.build(fixtures) + + assert [ + %Entry{name: "payment.delayed"}, + %Entry{name: "payment.nulled", payload: nil, payload_text: "null"}, + %Entry{name: "payment.pending", payload: :undefined, payload_text: ""}, + %Entry{name: "payment.scheduled"}, + %Entry{name: "payment.success"} + ] = entries + + assert Palette.names(%Palette{entries: entries}) == [ + "payment.delayed", + "payment.nulled", + "payment.pending", + "payment.scheduled", + "payment.success" + ] + + assert {:ok, %Entry{payload: %{"amount" => 1999}, payload_text: ~s({"amount":1999})}} = + Palette.entry(%Palette{entries: entries}, "payment.success") + + assert {:ok, %Entry{payload: ~D[2026-08-22], payload_text: ~s({"$date":"2026-08-22"})}} = + Palette.entry(%Palette{entries: entries}, "payment.scheduled") + + assert {:ok, %Entry{payload: %{seconds: 30}} = delayed} = + Palette.entry(%Palette{entries: entries}, "payment.delayed") + + assert delayed.payload_text == + ~s({"$duration":{"days":0,"hours":0,"milliseconds":0,"minutes":0,"months":0,"seconds":30,"weeks":0,"years":0}}) + end + + test "payload_text has canonical (sorted) object keys regardless of input key order" do + assert {:ok, fixtures} = + Fixtures.new( + events: %{"payment.success" => %{"currency" => "USD", "amount" => 1999}} + ) + + assert {:ok, %Palette{entries: [entry]}} = Palette.build(fixtures) + assert entry.payload_text == ~s({"amount":1999,"currency":"USD"}) + end + + test "an unencodable payload becomes a diagnostic without taking the palette down" do + assert {:ok, fixtures} = + Fixtures.new( + events: %{ + "payment.success" => %{"amount" => 1999}, + "payment.broken" => {:not, :a, :value} + } + ) + + assert {:ok, %Palette{entries: entries, diagnostics: diagnostics}} = Palette.build(fixtures) + + assert [%Entry{name: "payment.success"}] = entries + + assert [ + %{ + kind: :unencodable_event_payload, + path: ["events", "payment.broken"], + source: nil + } + ] = diagnostics + end + + test "atom-keyed payloads keep atoms in payload but round-trip to string keys in payload_text" do + assert {:ok, fixtures} = + Fixtures.new(events: %{"payment.success" => %{amount: 1999}}) + + assert {:ok, %Palette{entries: [entry]}} = Palette.build(fixtures) + assert entry.payload == %{amount: 1999} + assert entry.payload_text == ~s({"amount":1999}) + end + end + + describe "build/1 - invalid input" do + test "anything other than a Fixtures struct or nil is rejected" do + assert {:error, {:invalid_fixtures, :not_a_bundle}} = Palette.build(:not_a_bundle) + end + end + + describe "build/1 - from a behaviour source" do + test "builds a palette from test/support/fixtures/payment_source.ex" do + assert {:ok, fixtures} = Fixtures.from_source(PaymentSource) + assert {:ok, %Palette{entries: entries, diagnostics: []}} = Palette.build(fixtures) + + assert [ + %Entry{ + name: "payment.success", + payload: %{"amount" => 1999, "currency" => "USD"}, + payload_text: ~s({"amount":1999,"currency":"USD"}) + } + ] = entries + end + end +end diff --git a/test/statifier_ui/event_injection/session_test.exs b/test/statifier_ui/event_injection/session_test.exs new file mode 100644 index 0000000..2d405ae --- /dev/null +++ b/test/statifier_ui/event_injection/session_test.exs @@ -0,0 +1,81 @@ +defmodule StatifierUI.EventInjection.SessionTest do + use ExUnit.Case, async: true + + alias StatifierUI.EventInjection + alias StatifierUI.EventInjection.Draft + alias StatifierUI.EventInjection.Palette + alias StatifierUI.Fixtures + alias StatifierUI.Test.Support.Trace.SessionCase + alias StatifierUI.Trace.Subscriber + + # A transition on "payment.success" - the fixture event name the palette + # entry below is built from - from "pending" to "paid". + @chart """ + + + + + + + """ + + # 15 messages for @chart driven with one event: seq 0 (session.start) + # through the driven macrostep's trace.macrostep_stable - same shape as + # the two-state chart in trace/subscriber_test.exs. + @full_seq 15 + + defp dequeued_event_payloads(sub) do + sub + |> Subscriber.messages() + |> Enum.filter(&(&1.type == "trace.event_dequeued")) + |> Enum.map(& &1.payload["event"]) + end + + defp entered_index?(sub, index) do + sub + |> Subscriber.messages() + |> Enum.filter(&(&1.type == "trace.entry_set")) + |> Enum.any?(fn message -> index in message.payload["indexes"] end) + end + + test "a palette entry's payload round-trips through Draft.build/2 and moves the chart" do + machine = SessionCase.compile!(@chart) + paid_index = machine.id_to_index["paid"] + + assert {:ok, fixtures} = + Fixtures.new(events: %{"payment.success" => %{"amount" => 1999}}) + + assert {:ok, palette} = Palette.build(fixtures) + assert {:ok, entry} = Palette.entry(palette, "payment.success") + + {sub, session} = SessionCase.start_early!(machine, "sess_palette_send") + + assert {:ok, event} = Draft.build(entry.name, entry.payload_text) + assert :ok = EventInjection.send(session, event) + + SessionCase.wait_for_seq(sub, @full_seq) + + assert [%{"name" => "payment.success", "type" => "external", "data" => %{"amount" => 1999}}] = + dequeued_event_payloads(sub) + + assert entered_index?(sub, paid_index) + end + + test "a free-form send with an edited payload also moves the chart" do + machine = SessionCase.compile!(@chart) + paid_index = machine.id_to_index["paid"] + + {sub, session} = SessionCase.start_early!(machine, "sess_free_form_send") + + assert :ok = + EventInjection.send_draft(session, "payment.success", ~s({"amount":2500})) + + SessionCase.wait_for_seq(sub, @full_seq) + + assert [%{"name" => "payment.success", "data" => %{"amount" => 2500}}] = + dequeued_event_payloads(sub) + + assert entered_index?(sub, paid_index) + end +end diff --git a/test/statifier_ui/event_injection_test.exs b/test/statifier_ui/event_injection_test.exs new file mode 100644 index 0000000..3249b25 --- /dev/null +++ b/test/statifier_ui/event_injection_test.exs @@ -0,0 +1,98 @@ +defmodule StatifierUI.EventInjectionTest do + use ExUnit.Case, async: true + + alias StatifierUI.EventInjection + alias StatifierUI.EventInjection.Entry + alias StatifierUI.EventInjection.Palette + alias StatifierUI.Fixtures + + describe "build/1 - free_form_only?" do + test "nil yields free_form_only?: true and no entries" do + assert {:ok, %EventInjection{free_form_only?: true} = pane} = EventInjection.build(nil) + assert EventInjection.entries(pane) == [] + assert EventInjection.diagnostics(pane) == [] + end + + test "a bundle with no events yields free_form_only?: true" do + assert {:ok, fixtures} = Fixtures.new() + assert {:ok, %EventInjection{free_form_only?: true} = pane} = EventInjection.build(fixtures) + assert EventInjection.entries(pane) == [] + end + + test "a bundle with at least one encodable event yields free_form_only?: false" do + assert {:ok, fixtures} = + Fixtures.new(events: %{"payment.success" => %{"amount" => 1999}}) + + assert {:ok, %EventInjection{free_form_only?: false} = pane} = + EventInjection.build(fixtures) + + assert [%Entry{name: "payment.success"}] = EventInjection.entries(pane) + end + + test "a bundle whose only event payload is unencodable degrades to free_form_only?" do + assert {:ok, fixtures} = + Fixtures.new(events: %{"payment.broken" => {:not, :a, :value}}) + + assert {:ok, %EventInjection{free_form_only?: true} = pane} = + EventInjection.build(fixtures) + + assert EventInjection.entries(pane) == [] + assert [%{kind: :unencodable_event_payload}] = EventInjection.diagnostics(pane) + end + + test "propagates Palette.build/1's error for invalid input" do + assert {:error, {:invalid_fixtures, :not_a_bundle}} = EventInjection.build(:not_a_bundle) + end + end + + describe "entries/1 and diagnostics/1 - pass-through" do + test "return the underlying palette's entries and diagnostics" do + assert {:ok, fixtures} = + Fixtures.new( + events: %{ + "payment.success" => %{"amount" => 1999}, + "payment.broken" => {:not, :a, :value} + } + ) + + assert {:ok, %Palette{entries: entries, diagnostics: diagnostics} = palette} = + Palette.build(fixtures) + + pane = %EventInjection{palette: palette, free_form_only?: entries == []} + + assert EventInjection.entries(pane) == entries + assert EventInjection.diagnostics(pane) == diagnostics + assert [%Entry{name: "payment.success"}] = entries + assert [%{kind: :unencodable_event_payload}] = diagnostics + end + end + + describe "send_draft/3 - refuses before it sends" do + # A bare (non-GenServer) pid still receives a `GenServer.cast/2` as an + # ordinary `{:"$gen_cast", _}` message - casting does not check that the + # target is a running GenServer. A collector process that never reads its + # mailbox is therefore proof, not just a plausible stand-in: if + # `send_draft/3` ever cast before its build succeeded, this message queue + # would be non-empty. + setup do + collector = spawn(fn -> Process.sleep(:infinity) end) + on_exit(fn -> Process.exit(collector, :kill) end) + %{collector: collector} + end + + test "a build failure is returned without ever casting to the server", %{ + collector: collector + } do + assert {:error, :blank_event_name} = EventInjection.send_draft(collector, "") + assert {:messages, []} = Process.info(collector, :messages) + assert Process.alive?(collector) + end + + test "an invalid payload is also returned without sending", %{collector: collector} do + assert {:error, {:invalid_json, _reason}} = + EventInjection.send_draft(collector, "payment.success", "{not json") + + assert {:messages, []} = Process.info(collector, :messages) + end + end +end