Selkie.git | docs/api/ | Selkie--Test--Snapshot.md


NAME
====

Selkie::Test::Snapshot - Golden-file snapshot testing for widget rendering

SYNOPSIS
========

```raku
use Test;
use Selkie::Test::Snapshot;
use Selkie::Widget::Text;
use Selkie::Sizing;
use Selkie::Style;

my $header = Selkie::Widget::Text.new(
    text   => ' Selkie 1.0',
    sizing => Sizing.fixed(1),
    style  => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);

# First run: creates t/snapshots/header.snap from the rendered output.
# Subsequent runs: compares the current render against the saved file.
snapshot-ok $header, 'header', rows => 1, cols => 30;

done-testing;
```

DESCRIPTION
===========

Classic snapshot-testing pattern (Rails' `rspec-snapshot`, Jest's `toMatchSnapshot`, Elixir's Mneme). First run writes the rendered output of a widget to a golden file; subsequent runs render the widget again and fail if the output differs.

Under the hood:

  * Detaches from the controlling terminal (POSIX `setsid`) so notcurses cannot reach the developer's real terminal via `/dev/tty`. Without this, notcurses interrogates the controlling terminal during init and blocks *without timeout* on the query replies — a lost or fragmented reply (tmux fragments them) hangs the test process forever, and a garbled one can degrade init into a blank render. Detached, init is deterministic everywhere.

  * Initialises notcurses against stdout in headless mode (no alternate screen, no signal handlers, banners suppressed, stderr redirected to silence notcurses shutdown chatter).

  * Sizes the stdplane to the requested dimensions.

  * Gives the widget that plane via `init-plane`.

  * Calls the widget's `render`.

  * Reads the rendered cells back with `ncplane_at_yx` row by row.

  * Shuts notcurses down cleanly.

Trailing whitespace is trimmed from each row; trailing empty rows are dropped so resizes don't churn snapshots unnecessarily.

**By default, snapshots capture character content only**, not styles. That's a deliberate tradeoff — catches layout bugs, text mistakes, and most rendering regressions without the complexity of a style-aware snapshot format. Diffs stay one-row-per-row legible in PR review.

Practical consequence: widgets whose state changes are style-only (e.g. `ListView` cursor, `Button` focus highlight, `Checkbox` toggle color) won't produce a different snapshot between states. Test those via the widget's attributes or the `Selkie::Test::Keys` + event assertions directly, not via plain snapshots.

For widgets where colour *is* the regression — `Heatmap`, multi-series `LineChart`, multi-bar `BarChart`, themed elements — pass `:capture-styles` to `render-to-string` (or `snapshot-ok`). The output then includes a parallel grid of style keys and a legend mapping each key to `(fg, bg, stylemask)`. The harness routes styled goldens to `xt/snapshots/golden-styled/` automatically (it detects the format marker on the first line of stdout).

WORKFLOW
========

  * **First run or missing snapshot:** the file is created and the test passes.

  * **Matching snapshot:** the test passes silently.

  * **Differing snapshot:** the test fails, and a diff is printed to TAP diagnostics.

To accept new output (when an intentional change makes existing snapshots stale), re-run with the update env var:

```bash
SELKIE_UPDATE_SNAPSHOTS=1 prove6 -l t
```

Every snapshot-ok call overwrites its file in update mode. Then re-run without the flag to confirm everything matches.

EXAMPLES
========

A basic widget
--------------

```raku
snapshot-ok $my-widget, 'my-widget-default', rows => 10, cols => 40;
```

Testing multiple states of the same widget
------------------------------------------

```raku
use Selkie::Test::Keys;

my $list = Selkie::Widget::ListView.new(sizing => Sizing.flex);
$list.set-items(<alpha beta gamma>);

snapshot-ok $list, 'list-initial',  rows => 5, cols => 20;

press-key($list, 'down');
snapshot-ok $list, 'list-cursor-1', rows => 5, cols => 20;

press-key($list, 'end');
snapshot-ok $list, 'list-cursor-end', rows => 5, cols => 20;
```

Custom snapshot directory
-------------------------

```raku
snapshot-ok $widget, 'thing', :rows(4), :cols(20), dir => 'xt/snaps';
```

Style-aware snapshot
--------------------

For widgets where colour or text style is the regression you care about, opt into the styled format:

```raku
snapshot-ok $heatmap, 'heatmap-viridis', :rows(8), :cols(20), :capture-styles;
```

The captured output begins with `=== styled-snapshot v1 ===` and contains three blocks (`--- glyphs ---`, `--- styles ---`, `--- legend ---`). The harness recognises the marker and stores the golden under `{$dir}/golden-styled/{$name}.snap` rather than `{$dir}/golden/{$name}.snap`.

Style equality is the `(fg-rgb, bg-rgb, stylemask)` tuple. Cells with no fg, no bg, and no styles get the `.` key. Other tuples get single-character keys (`A`, `B`, ..., `Z`, `a`, ..., `z`, `0`, ..., `9`) in first-seen order.

FILE FORMAT
===========

Snapshots are plain UTF-8 text files. One line per rendered row; trailing whitespace stripped; trailing blank rows removed; final newline appended.

No metadata, no escaped characters beyond what the widget actually rendered. This means snapshot files render legibly on GitHub and are trivial to diff by eye:

    ┌──────────────┐
    │ Hello, Selkie│
    └──────────────┘

CAVEATS
=======

  * **Styles aren't captured.** Two widgets that render the same glyphs in different colors produce identical snapshots. If style matters, test it via the widget's attributes directly.

  * **Non-ASCII width.** Snapshots use one-character-per-cell. Wide characters (CJK, emoji) may render over multiple cells; the captured output reflects what `ncplane_at_yx` returns at each cell position.

  * **Real notcurses init.** Each call spins notcurses up and tears it down. ~50-100ms per snapshot. For small test suites this is fine; for large ones, group related snapshots in the same test and share context if performance matters.

  * **Headless-friendly.** Init is done against a pipe-compatible output and the process detaches from its controlling terminal first, so tests behave identically in CI, under a release tool, and in an interactive shell — notcurses never queries (or mutates) the real terminal. Output to stderr is silenced during init/stop to avoid the notcurses "signals weren't registered" diagnostic leaking into TAP.

  * **Debugging against a real terminal.** Set `SELKIE_SNAPSHOT_ATTACH_TTY=1` to skip the detach and let notcurses interrogate the controlling terminal as it would in a live app. Expect terminal-dependent behavior (and possible hangs if replies are fragmented) — this is a debugging aid, not a test mode. A scenario launched directly from an interactive shell is a process-group leader, where `setsid` cannot apply; that standalone case is always attached.

SEE ALSO
========

  * [Selkie::Test::Keys](Selkie--Test--Keys.md) — simulate events before taking a snapshot

  * [Selkie::Test::Focus](Selkie--Test--Focus.md) — focus-gated rendering paths

### sub ensure-null-fp

```raku
sub ensure-null-fp() returns NativeCall::Types::Pointer
```

Marker line that identifies a styled snapshot. The fork-per-scenario harness (`Selkie::Test::Snapshot::Harness`) detects this on the first line of subprocess stdout and routes the golden file to a separate `golden-styled/` subdirectory. Plain snapshots without this marker continue to use the existing `golden/` subdirectory.

### sub render-to-string

```raku
sub render-to-string(
    Selkie::Widget $widget,
    Int :$rows where { ... } = 24,
    Int :$cols where { ... } = 80,
    Bool :$capture-styles = Bool::False
) returns Str
```

Render a widget to a plain-text string via a shared headless notcurses instance. Returns the rendered cells row-by-row, joined with newlines. Trailing whitespace on each line is stripped, and trailing blank lines are removed. The widget is given its own plane as a child of the stdplane, sized to `$rows` × `$cols`. Containers that manage child planes in their `render` work correctly — the standard mount path is exercised. The notcurses instance persists across calls within a test process (init is once-per-process). The widget's plane is destroyed after each call so renders don't leak. Pass `:capture-styles` to emit the style-aware format instead of the plain glyph grid — see [Selkie::Test::Snapshot](Selkie--Test--Snapshot.md) Pod6 for the format spec. The harness routes styled goldens to `golden-styled/` automatically.

### sub snapshot-ok

```raku
sub snapshot-ok(
    Selkie::Widget $widget,
    Str:D $name,
    Int :$rows where { ... } = 24,
    Int :$cols where { ... } = 80,
    Bool :$capture-styles = Bool::False,
    Str :$dir is copy
) returns Mu
```

Test assertion: render the widget to `$rows` × `$cols` and compare against a stored snapshot file. =item First run or missing file: the snapshot is created and the test passes. =item Matching output: the test passes. =item Differing output: the test fails and a unified-ish diff is printed as TAP diagnostics. Set the env var `SELKIE_UPDATE_SNAPSHOTS` to a truthy value to overwrite existing snapshots with current output. The snapshot directory defaults to `t/snapshots`. With `:capture-styles` the directory is automatically suffixed with `-styled` (default: `t/snapshots-styled`) so plain and styled goldens never collide. Override `$dir` for a custom location; when overriding alongside `:capture-styles`, suffix `$dir` yourself. The directory is auto-created on first use.