A fixture bundle that travels with one reusable chart fragment rather than with a whole chart: the ADR-0003/ADR-0006 bundle plus the fragment's name and a record of where it was loaded from.
ADR-0003 pairs a bundle with a chart - authorization.scxml carries
authorization.fixtures.json beside it. An embedder composing charts from a
palette of reusable fragments has no such file to sit beside: the fragment
is a module in the host's code or an entry in a palette, and the chart it
eventually lands in does not exist yet. This module is the same contract
addressed by fragment name instead of by chart path, so each palette entry
can carry its own executable examples and surface a per-fragment "test this
step" panel.
Nothing here is a new fixture shape. A bundle's fixtures field is exactly
the StatifierUI.Fixtures struct both ADR-0003 delivery paths already
produce, and every spelling below routes through StatifierUI.Fixtures.new/1
or StatifierUI.Fixtures.Sidecar.from_json/2 for validation. What this
record adds is identity (which fragment these examples belong to),
provenance (which module or file they came from), and discovery (finding
every fragment's bundle across a palette or a directory).
The convention
A fragment supplies its bundle in one of four spellings, and load/3
accepts all four:
| Spelling | Recognized by | Validated through |
|---|---|---|
%StatifierUI.Fixtures{} | the struct | already validated at construction |
%{scenarios: ..., events: ..., datasets: ..., expressions: ...} | atom top-level keys | StatifierUI.Fixtures.new/1 |
%{"version" => 1, "datasets" => ...} | string top-level keys | StatifierUI.Fixtures.Sidecar.from_json/2 |
"path/to/step.fixtures.json" | a binary path | StatifierUI.Fixtures.Sidecar.load/1 |
The atom-vs-string top-level key is the whole discriminator, and it is not
an accident of implementation: atom keys are the Elixir spelling a host
writes by hand in a module, string keys are the JSON spelling that survives
a file, and ADR-0003 requires both paths to converge on one struct rather
than one path to be primary. A map mixing the two is rejected as
{:mixed_bundle_keys, name} instead of guessed at.
Unknown top-level keys are treated differently by spelling, deliberately.
The JSON spelling keeps the sidecar's ignore-unknown-keys discipline
(ADR-0006): a file written by a newer producer must still load. The Elixir
spelling rejects an unknown atom key as {:unknown_bundle_key, name, key},
because an atom key in a host's own module is compiled code the author is
looking at, and a silently ignored :datsets typo there is a bundle that
reports zero datasets and no reason why. Forward compatibility is a
property of a wire format, not of a function call.
A fragment that ships no examples at all is not an error anywhere in this
module. discover/2 reports it as without, never as a failure.
Discovery
Two discovery paths, mirroring the two delivery paths:
discover/2walks a palette - a map or list of{name, module}pairs - and loads each module's bundle callback (fixtures/0by default,:callbackto name another). This is the host-application path.discover_dir/2walks a directory of*.fixtures.jsonfiles and names each bundle after its file (authorize.fixtures.jsonbecomes"authorize"). This is the corpus and CLI path, and it is what lets a palette of fragments travel as files with no host code around them.
Neither raises and neither is all-or-nothing: one fragment's malformed bundle is reported against that fragment's name and the rest still load, because a palette is exactly the setting where the alternative - one bad entry hiding every good one - is least useful.
This module names no block, palette, or fragment type of its own and takes no dependency on any package that defines one. It calls a zero-arity callback on whatever modules it is handed, which is all a bundle convention needs to be.
Rendering
StatifierUI.Fixtures.Bundle.Markdown renders a bundle as its truth table
plus its expectation results - the per-fragment test panel - and
StatifierUI.Kino.test_panel/2 wraps that for a Livebook cell.
Summary
Types
What discover/2 and discover_dir/2 found.
The fragment a bundle belongs to: a palette entry name or a file basename.
Where a bundle came from. {:module, mod} for a palette entry's callback,
{:sidecar, path} for a file, :inline for a term handed straight to
load/3.
Functions
Every bundle in discovery, keyed by name.
Loads the bundle every entry of palette ships.
Loads every *.fixtures.json file directly inside dir, naming each
bundle after its file: authorize.fixtures.json becomes "authorize".
Whether the bundle carries anything at all to evaluate.
Builds a bundle named name from term, in any of the four spellings
above.
Like load/3 but raises ArgumentError on failure.
Derives a bundle name from a sidecar filename:
"authorize.fixtures.json" becomes "authorize".
Types
What discover/2 and discover_dir/2 found.
bundles are the ones that loaded, sorted by name. without names the
entries that ship no bundle at all - an absence, not a failure. errors
pairs a name with the reason its bundle did not load.
@type name() :: String.t()
The fragment a bundle belongs to: a palette entry name or a file basename.
Where a bundle came from. {:module, mod} for a palette entry's callback,
{:sidecar, path} for a file, :inline for a term handed straight to
load/3.
@type t() :: %StatifierUI.Fixtures.Bundle{ diagnostics: [StatifierUI.Fixtures.diagnostic()], fixtures: StatifierUI.Fixtures.t(), name: name(), origin: origin() }
Functions
Every bundle in discovery, keyed by name.
A convenience for a consumer that holds a palette by name and wants the matching bundle without scanning the list.
Loads the bundle every entry of palette ships.
palette is a map or list of {name, module} pairs - a palette of
fragment types, however the caller happens to hold it. For each entry, when
the module exports the bundle callback it is called and its return value
goes through load/3; when it does not, the entry is reported under
without.
Options:
:callback- the zero-arity function to call. Defaults to:fixtures.
Never raises: a module whose callback itself raises is reported as
{:bundle_callback_raised, exception} against its name, because one
fragment's broken examples must not take down the panel for every other
fragment in the palette.
Loads every *.fixtures.json file directly inside dir, naming each
bundle after its file: authorize.fixtures.json becomes "authorize".
The directory is not walked recursively - a palette directory is a flat list of fragments, and a nested one is a second palette, not a deeper part of this one.
A missing or unreadable directory is {:error, reason}; a directory that
exists and holds no sidecars is an empty discovery, which is a legitimate
answer rather than a failure.
Whether the bundle carries anything at all to evaluate.
A bundle with no expressions has no truth table and no expectations to run; a renderer says so once rather than drawing an empty matrix and an empty results list.
Builds a bundle named name from term, in any of the four spellings
above.
Options:
:origin- theorigin/0to record. Defaults to:inline, or to{:sidecar, path}whentermis a path.
Never raises. A term in none of the four spellings is
{:error, {:unrecognized_bundle, name, term}}.
Examples
iex> {:ok, bundle} =
...> StatifierUI.Fixtures.Bundle.load("myapp.signup", %{
...> datasets: %{
...> "variant-b-complete" => %{
...> "signup" => %{"steps_completed" => 4, "variant" => "B"}
...> }
...> },
...> expressions: %{
...> "is-complete-variant-b" => %{
...> "source" => "signup.steps_completed >= 3 and signup.variant == 'B'"
...> }
...> }
...> })
iex> bundle.name
"myapp.signup"
iex> StatifierUI.Fixtures.dataset_names(bundle.fixtures)
["variant-b-complete"]
Like load/3 but raises ArgumentError on failure.
For a host that wants a malformed bundle to fail loudly at wiring time rather than be carried around as an error tuple.
Derives a bundle name from a sidecar filename:
"authorize.fixtures.json" becomes "authorize".
The inverse of StatifierUI.Fixtures.Sidecar.sidecar_path/1 for the
fragment case, where the name is the identity and the path is derived from
it rather than the other way round.
Examples
iex> StatifierUI.Fixtures.Bundle.name_from_path("palette/authorize.fixtures.json")
"authorize"