StatifierUI.Fixtures.Bundle (StatifierUI v0.9.1)

Copy Markdown View Source

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:

SpellingRecognized byValidated through
%StatifierUI.Fixtures{}the structalready validated at construction
%{scenarios: ..., events: ..., datasets: ..., expressions: ...}atom top-level keysStatifierUI.Fixtures.new/1
%{"version" => 1, "datasets" => ...}string top-level keysStatifierUI.Fixtures.Sidecar.from_json/2
"path/to/step.fixtures.json"a binary pathStatifierUI.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/2 walks a palette - a map or list of {name, module} pairs - and loads each module's bundle callback (fixtures/0 by default, :callback to name another). This is the host-application path.
  • discover_dir/2 walks a directory of *.fixtures.json files and names each bundle after its file (authorize.fixtures.json becomes "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

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.

t()

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

discovery()

@type discovery() :: %{bundles: [t()], without: [name()], errors: [{name(), term()}]}

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.

name()

@type name() :: String.t()

The fragment a bundle belongs to: a palette entry name or a file basename.

origin()

@type origin() :: {:module, module()} | {:sidecar, Path.t()} | :inline

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.

t()

@type t() :: %StatifierUI.Fixtures.Bundle{
  diagnostics: [StatifierUI.Fixtures.diagnostic()],
  fixtures: StatifierUI.Fixtures.t(),
  name: name(),
  origin: origin()
}

Functions

by_name(map)

@spec by_name(discovery()) :: %{optional(name()) => t()}

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.

discover(palette, opts \\ [])

@spec discover(
  %{optional(name()) => module()} | [{name(), module()}],
  keyword()
) :: discovery()

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.

discover_dir(dir, opts \\ [])

@spec discover_dir(
  Path.t(),
  keyword()
) :: {:ok, discovery()} | {:error, term()}

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.

empty?(bundle)

@spec empty?(t()) :: boolean()

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.

load(name, term, opts \\ [])

@spec load(name(), term(), keyword()) :: {:ok, t()} | {:error, term()}

Builds a bundle named name from term, in any of the four spellings above.

Options:

  • :origin - the origin/0 to record. Defaults to :inline, or to {:sidecar, path} when term is 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"]

load!(name, term, opts \\ [])

@spec load!(name(), term(), keyword()) :: t()

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.

name_from_path(path)

@spec name_from_path(Path.t()) :: name()

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"