sumi
Chapters

Testing

Sumi is built to be tested without a real terminal. Rendering is synchronous and deterministic in test mode, events are plain values you construct and feed in, and the output is a cell buffer you can read directly. On top of that sit snapshot scenarios for whole-frame assertions, an in-repo terminal model for verifying the actual bytes sumi emits, and PTY-driven subprocess tests for the few behaviours that need a real terminal file descriptor.

Component tests with TestApp

tui.TestApp(comp, w, h) builds an app that renders into an in-memory buffer of the given size and does an initial render immediately. You then read cells or step events — no goroutines, no stdout, no timing.

comp := &tui.Component{
    Tree: &layout.Input{
        Kind: layout.KindBox, CursorCol: -1, CursorRow: -1,
        Children: []*layout.Input{
            {Kind: layout.KindText, Content: "Hello"},
        },
    },
}
app := tui.TestApp(comp, 20, 3)

if app.TestBuffer.Cell(0, 0).Ch != 'H' {
    t.Errorf("Cell(0,0) = %c, want 'H'", app.TestBuffer.Cell(0, 0).Ch)
}

app.Step(evt) dispatches one event and then converges the render (bounded to a few passes so a signal change that triggers another render settles before Step returns):

app.Step(input.Event{Kind: input.EventKey, Rune: '+'})

Each render.Cell exposes its Ch rune and Style, so you can assert on both content and styling. app.Render() forces a fresh frame after you mutate a signal directly. In practice you build the component through generated constructors rather than hand-writing the layout.Input tree — the tree above is spelled out only to show what the buffer holds.

Snapshot scenarios

For asserting whole frames across a sequence of interactions, define a sumitest.Scenario: a name, a viewport size, a factory that returns a test app, and a list of steps. A step with a nil Action captures the current frame without dispatching anything; a step with an Action runs it (usually feeding an event) and captures the result.

func clickerScenario() sumitest.Scenario {
    return sumitest.Scenario{
        Name:   "clicker-basics",
        Width:  30,
        Height: 6,
        NewApp: func(w, h int) *tui.App {
            comp := NewApp(AppProps{})
            return tui.TestApp(comp, w, h)
        },
        Steps: []sumitest.Step{
            {Name: "initial"},
            {Name: "after-click", Action: func(h *sumitest.Harness) {
                h.Step(sumitest.ClickEvent(1, 3)) // row 1, col 3
            }},
        },
    }
}

func TestClickerSnapshots(t *testing.T) {
    sumitest.AssertSnapshots(t, clickerScenario())
}

sumitest provides event constructors so tests read clearly: KeyEvent(r), CtrlEvent(r), SpecialEvent(k), PasteEvent(text), EnterEvent(), EscapeEvent(), BackspaceEvent(), TabEvent(), ShiftTabEvent(), ClickEvent(row, col), DragEvent(row, col), and the scroll variants. Note that click and drag take row first, then column.

AssertSnapshots compares each captured frame’s styled text against a file in testdata/<name>.snapshot. The file stores each frame under a === Frame: name === header:

=== Frame: initial ===
┌──────────────┐
│[ Click me ]  │
└──────────────┘
Count: 0

=== Frame: after-click ===

Run the tests with the -update flag to (re)write the snapshot files instead of comparing:

go test ./... -update

Review the resulting diff as you would any golden file — a snapshot change is a visible change to what the user sees. The Harness also exposes Text(), StyledText(), Buffer() and Resize(w, h) for ad-hoc assertions, and sumitest has AssertText, AssertStyledText, AssertContains and AssertStyledContains helpers for checking a single frame without a snapshot.

Verifying emitted bytes with the vt100 model

The buffer tests above check what sumi intends to draw. To check the actual escape sequences it emits, replay them through the in-repo terminal model. Run a real app against in-memory streams, then feed the captured output to a vt100.Screen and assert on the reconstructed cells:

var out bytes.Buffer
tui.RunWithOptions(comp, tui.RunOptions{
    In: strings.NewReader("q"), Out: &out, ExitOn: []string{"q"},
})

screen := vt100.NewScreen(80, 24)
if _, err := screen.Write(out.Bytes()); err != nil {
    t.Fatalf("vt100 replay: %v", err)
}

cell := screen.Cell(0, 0)
if !cell.Style.Bold {
    t.Errorf("expected bold at (0,0)")
}

This is a genuine round trip: sumi’s emitted bytes are parsed back into a screen by an independent model, so a sequence that a real terminal would misread fails the test. It also covers diffed updates — feeding a stream that drives several frames and asserting the final reconstructed screen confirms the incremental redraw logic, not just the first paint.

PTY subprocess tests

A few behaviours need a real terminal file descriptor — job control being the clearest example. Those tests build the app as a subprocess, start it on a pseudo-terminal with pty.Start(cmd, rows, cols), drive it by writing control bytes to the master (for instance 0x1a for Ctrl+Z), and poll the accumulated output with a deadline. They are skipped under go test -short, since building and spawning a subprocess is slower than an in-memory test. Reach for a PTY test only when the in-memory streams above cannot reproduce the behaviour.

Previewing scenarios

sumi test-preview <component-dir> runs a scenario interactively in a real terminal for manual inspection. It generates a temporary program that serves the scenario over a Unix socket, runs it on a PTY, and drives stepping from a preview UI, showing the rendered frames alongside the source. This is a development aid for eyeballing frames, not part of the automated suite — the snapshot tests above are what run in CI.