Files
terminal/tui/widget_test.go
T
Lixen d4cc9f2407 v0.5.2 tui: titles and labels drawn a column a rune; an unchanged frame writes nothing
* tui: titles, labels and buttons drawn a column a rune

Ten places drew each rune of a title or label at its byte offset, so a
string holding runes of more than one byte drew with holes and ran past the
width it was measured and truncated to: the Pane title, the Modal title and
hint, the Divider label, TabBar titles, Table cells, Form labels, the
TextField placeholder, the ConfirmDialog and AlertDialog buttons, and the
ProgressOverlay title. Each now draws through Region.Text, which counts
columns by rune, within the bound its loop kept; a Form label stays within
its label column, as the byte count meant it to.

* an unchanged frame writes nothing

flush closed every frame with SGR 0, so a frame that changed no cell still
sent four bytes, and an app drawing an idle screen kept writing to the
terminal. The reset now follows only a frame that wrote cells: the next
cell written restates its whole style, and Fini resets on its own.

Each widget, given a title such as "● é · ñ", draws it whole where it drew
it with holes before; a frame flushed twice writes nothing the second time,
where it wrote ESC [ 0 m.
2026-10-24 19:00:37 -04:00

504 lines
19 KiB
Go

package tui
import (
"fmt"
"runtime"
"slices"
"strings"
"testing"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
)
// canvas returns a w x h region filled with th's panel
func canvas(w, h int, th Theme) Region {
r := NewRegion(make([]terminal.Cell, w*h), w, 0, 0, w, h)
r.FillStyle(th.Text)
return r
}
// row returns the runes of row y, trailing spaces trimmed
func row(r Region, y int) string {
var b strings.Builder
for x := 0; x < r.W; x++ {
b.WriteRune(r.Cells[y*r.TotalW+x].Rune)
}
return strings.TrimRight(b.String(), " ")
}
// A transparent background keeps the kind of the one beneath it: text drawn,
// or a style put, on the terminal's own background or a palette color stays
// on it
func TestTransparentBackgroundKeepsItsKind(t *testing.T) {
for _, panel := range []Style{MonoTheme.Text, {Bg: color.RGB{R: 4}, Attr: terminal.AttrBg256}} {
r := canvas(4, 1, Theme{Text: panel})
r.Text(0, 0, "ab", color.RGB{R: 1}, color.RGB{}, terminal.AttrBold)
if c := r.Cells[0]; c.Bg != panel.Bg || c.Attrs != terminal.AttrBold|panel.Attr&terminal.AttrBgColor {
t.Errorf("on %+v: %+v", panel, c)
}
if on := panel.On(Style{Attr: terminal.AttrReverse}); on.Bg != panel.Bg || on.Attr != panel.Attr&terminal.AttrBgColor|terminal.AttrFgDefault|terminal.AttrReverse {
t.Errorf("%+v on reverse: %+v", panel, on)
}
}
}
// A reverse cursor row is one bar: its pointer, key, name, hint and blanks
// take one style, whatever each draws in off the row, where they keep it
func TestReverseCursorRowIsOneStyle(t *testing.T) {
s := NewOptionListState([]Option{{Name: "file", Hint: "rotating files", Key: 'f'}, {Name: "tcp", Key: 't'}})
s.Menu = true
for name, th := range map[string]Theme{"16 colors": Theme16, "mono": MonoTheme} {
r := canvas(40, 2, th)
r.OptionList(s, th)
for x, c := range r.Cells[:r.W] {
if first := r.Cells[0]; c.Fg != first.Fg || c.Bg != first.Bg || c.Attrs != first.Attrs {
t.Errorf("%s %q: column %d %+v, column 0 %+v", name, row(r, 0), x, c, first)
break
}
}
if c, a := r.Cells[r.W+2], th.Accent; c.Rune != 't' || c.Fg != a.Fg || c.Attrs&^terminal.AttrBgColor != a.Attr {
t.Errorf("%s %q: the key off the cursor row %+v, want %+v", name, row(r, 1), c, a)
}
}
}
// Every widget marks its selection with a glyph, so it reads with no color
func TestSelectionChangesAGlyph(t *testing.T) {
for _, th := range []Theme{DefaultTheme, Theme16, MonoTheme} {
selectionChangesAGlyph(t, th)
}
}
func selectionChangesAGlyph(t *testing.T, th Theme) {
options := NewOptionListState([]Option{{Name: "file"}, {Name: "tcp"}})
for name, draw := range map[string]func(r Region, on bool){
"radio": func(r Region, on bool) { r.Radio(0, 0, []string{"raw", "txt"}, map[bool]int{true: 1}[on], th) },
"toggle": func(r Region, on bool) { r.Toggle(0, 0, on, th) },
"field": func(r Region, on bool) { r.Field(0, 6, Field{Label: "port"}, on, th) },
"group": func(r Region, on bool) { r.Group(0, "tls", "", on, false, th) },
"option list": func(r Region, on bool) {
options.Cursor = map[bool]int{true: 1}[on]
r.OptionList(options, th)
},
} {
off, on := canvas(20, 3, th), canvas(20, 3, th)
draw(off, false)
draw(on, true)
if row(off, 0)+row(off, 1)+row(off, 2) == row(on, 0)+row(on, 1)+row(on, 2) {
t.Errorf("%s: %q either way", name, row(on, 0)+"|"+row(on, 1))
}
}
}
// The 16-color theme names only ANSI colors and the terminal's own, which a
// text console shows as it shows everything else; ThemeFor picks it
func TestTheme16NamesOnlyANSIColors(t *testing.T) {
ansiOrOwn := func(c color.RGB, attr, palette, own terminal.Attr, transparent bool) bool {
return attr&own != 0 || attr&palette != 0 && c.R < 16 || transparent && c == (color.RGB{}) && attr&(palette|own) == 0
}
for _, s := range []Style{Theme16.Text, Theme16.Muted, Theme16.Accent, Theme16.Selected, Theme16.Input, Theme16.Cursor, Theme16.Error, Theme16.Border} {
if !ansiOrOwn(s.Fg, s.Attr, terminal.AttrFg256, terminal.AttrFgDefault, false) || !ansiOrOwn(s.Bg, s.Attr, terminal.AttrBg256, terminal.AttrBgDefault, true) {
t.Errorf("%+v", s)
}
}
if ThemeFor(terminal.ColorMode16).Muted != Theme16.Muted || ThemeFor(terminal.ColorModeNone).Muted != MonoTheme.Muted ||
ThemeFor(terminal.ColorModeTrueColor).Muted != DefaultTheme.Muted {
t.Error("ThemeFor")
}
}
// The option list matches name or hint regardless of case, a changed filter
// puts the cursor on the first match, and the cursor stays on the matches
func TestOptionListFilters(t *testing.T) {
s := NewOptionListState([]Option{{Name: "file", Hint: "tail files"}, {Name: "Console", Hint: "stdin or stdout"},
{Name: "tcp", Hint: "listen on TCP"}, {Name: "http", Hint: "Serve HTTP"}})
for range 5 {
s.HandleKey(terminal.KeyDown, 0, 0)
}
if i, _ := s.Chosen(); i != 3 {
t.Fatalf("past the end: %d", i)
}
s.Paste("t")
if m := s.Matches(); len(m) != 4 || s.Cursor != 0 {
t.Fatalf("t: %v cursor %d", m, s.Cursor)
}
s.HandleKey(terminal.KeyDown, 0, 0)
if s.HandleKey(terminal.KeyLeft, 0, 0); s.Cursor != 1 {
t.Fatalf("filter cursor moved: list cursor %d", s.Cursor)
}
if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "t" || s.Cursor != 1 {
t.Fatalf("nothing before the filter cursor: %q cursor %d", s.Filter.Value(), s.Cursor)
}
s.HandleKey(terminal.KeyRight, 0, 0)
if s.HandleKey(terminal.KeyBackspace, 0, 0); s.Filter.Value() != "" || s.Cursor != 0 {
t.Fatalf("erased: %q cursor %d", s.Filter.Value(), s.Cursor)
}
for filter, want := range map[string]int{"TCP": 2, "cons": 1, "serve h": 3} {
s.Filter.SetValue(filter)
if m := s.Matches(); len(m) != 1 || m[0] != want {
t.Errorf("%s: %v", filter, m)
}
}
s.Filter.SetValue("udp")
if _, ok := s.Chosen(); ok {
t.Fatal("udp matched")
}
}
// A field holds no control character, which the terminal or a file it is
// saved to would act on: pasted line breaks and tabs become spaces, other
// controls, pasted or typed, are dropped
func TestFieldHoldsNoControls(t *testing.T) {
var f TextFieldState
f.Paste("a\x1b[31mb\r\nc\td\u009b")
f.HandleKey(terminal.KeyRune, 0x9b, 0)
f.HandleKey(terminal.KeyRune, 0x85, 0)
if got := f.Value(); got != "a[31mb c d" {
t.Fatalf("%q", got)
}
}
// A field whose text the caller replaced draws and takes input without
// panicking: the cursor stays within the text
func TestTextInputSurvivesReplacedText(t *testing.T) {
f := NewTextFieldState("abcdefghij")
canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme)
f.Text = []rune("ab")
canvas(4, 1, DefaultTheme).TextInput(f, "", true, DefaultTheme)
f.Cursor = 9
f.Paste("x")
type press struct {
key terminal.Key
mod terminal.Modifier
}
ctrl := terminal.ModCtrl
for cursor, keys := range map[int][]press{
9: {{terminal.KeyBackspace, 0}, {terminal.KeyCtrlW, 0}, {terminal.KeyCtrlU, 0}, {terminal.KeyLeft, ctrl}, {terminal.KeyRight, 0}},
-1: {{terminal.KeyDelete, 0}, {terminal.KeyDelete, ctrl}, {terminal.KeyRight, ctrl}, {terminal.KeyLeft, 0}, {terminal.KeyCtrlK, 0}},
} {
for _, k := range keys {
f.Text, f.Cursor = []rune("a b"), cursor
f.HandleKey(k.key, 0, k.mod)
}
}
f.Text, f.Cursor = []rune("a b"), -1
f.HandleKey(terminal.KeyCtrlK, 0, 0)
if got := f.Value(); got != "" {
t.Fatalf("%q", got)
}
}
// A typed field takes only its runes, typed or pasted
func TestTypedFieldTakesItsRunes(t *testing.T) {
f := TextFieldState{Accept: AcceptInteger}
for _, r := range "4a2" {
f.HandleKey(terminal.KeyRune, r, 0)
}
f.Paste("1 0x8")
if got := f.Value(); got != "42108" {
t.Fatalf("%q", got)
}
}
// Tab and Shift+Tab move focus around the ring, wrapping either way
func TestFocusRingWraps(t *testing.T) {
f := Focus{Len: 3}
f.HandleKey(terminal.KeyBacktab, terminal.ModShift)
back := f.Index
f.HandleKey(terminal.KeyTab, terminal.ModShift)
shifted := f.Index
for range 2 {
f.HandleKey(terminal.KeyTab, 0)
}
if back != 2 || shifted != 1 || f.Index != 0 || f.HandleKey(terminal.KeyEnter, 0) {
t.Fatalf("back %d, shifted %d, then %d", back, shifted, f.Index)
}
}
// A field shows its error whether or not it has focus, its help only with
// focus, and its value beside the label column
func TestFieldShowsErrorOrHelp(t *testing.T) {
f := Field{Label: "port", Required: true, Help: "listen port"}
r := canvas(30, 2, DefaultTheme)
value, rows := r.Field(0, 6, f, false, DefaultTheme)
if rows != 1 || value.X != 9 || row(r, 0) != " port *" {
t.Fatalf("unfocused: %d rows, value at %d, %q", rows, value.X, row(r, 0))
}
r = canvas(30, 2, DefaultTheme)
if _, rows = r.Field(0, 6, f, true, DefaultTheme); rows != 2 || row(r, 1) != " listen port" {
t.Fatalf("focused: %d rows, %q", rows, row(r, 1))
}
f.Error = "port: want 1-65535"
r = canvas(30, 2, DefaultTheme)
if _, rows = r.Field(0, 6, f, false, DefaultTheme); rows != 2 || !strings.HasSuffix(row(r, 1), "want 1-65535") {
t.Fatalf("error: %d rows, %q", rows, row(r, 1))
}
}
// An empty input shows its default, muted, with the cursor over it; typed
// text replaces it
func TestTextInputShowsTheDefaultUntilTyped(t *testing.T) {
var f TextFieldState
r := canvas(10, 1, DefaultTheme)
r.TextInput(&f, "8080", true, DefaultTheme)
if c := r.Cells[1]; row(r, 0) != "8080" || c.Fg != DefaultTheme.Muted.Fg || r.Cells[0].Bg != DefaultTheme.Cursor.Bg {
t.Fatalf("empty: %q %+v", row(r, 0), c)
}
f.HandleKey(terminal.KeyRune, '9', 0)
r.TextInput(&f, "8080", true, DefaultTheme)
if c := r.Cells[0]; row(r, 0) != "9" || c.Fg != DefaultTheme.Text.Fg || c.Bg != DefaultTheme.Input.Bg {
t.Fatalf("typed: %q %+v", row(r, 0), c)
}
}
// A choice moves with Left and Right, or h and l, and stops at either end
func TestChoiceStopsAtTheEnds(t *testing.T) {
i, _ := StepChoice(terminal.KeyRune, 'l', 0, 2)
j, moved := StepChoice(terminal.KeyRight, 0, i, 2)
k, _ := StepChoice(terminal.KeyLeft, 0, j, 2)
if i != 1 || j != 1 || moved || k != 0 {
t.Fatalf("%d %d %v %d", i, j, moved, k)
}
}
// A folded group says what it holds; an open one shows it instead
func TestGroupSummarisesWhileFolded(t *testing.T) {
folded, open := canvas(30, 1, MonoTheme), canvas(30, 1, MonoTheme)
folded.Group(0, "tls", "on, pinned", false, false, MonoTheme)
open.Group(0, "tls", "on, pinned", true, false, MonoTheme)
if row(folded, 0) != " ▸ tls on, pinned" || row(open, 0) != " ▾ tls" {
t.Fatalf("%q %q", row(folded, 0), row(open, 0))
}
}
// A radio row too wide for its region shows the chosen option and those
// around it, a '…' on each side where others are hidden, and reports the
// columns drawn
func TestRadioKeepsTheChosenInView(t *testing.T) {
options := []string{"alpha", "beta", "gamma", "delta"}
for _, c := range []struct {
chosen, w int
want string
}{
{3, 40, "○ alpha ○ beta ○ gamma ● delta"},
{3, 30, "… ○ beta ○ gamma ● delta"},
{3, 16, "… ● delta"},
{3, 7, "… ● de…"},
{0, 17, "● alpha …"},
{1, 19, "○ alpha ● beta …"},
{1, 25, "… ● beta ○ gamma …"},
} {
r := canvas(c.w, 1, MonoTheme)
if n := r.Radio(0, 0, options, c.chosen, MonoTheme); row(r, 0) != c.want || n != min(c.w, RuneLen(c.want)) {
t.Errorf("%d chosen in %d columns: %q, %d drawn; want %q", c.chosen, c.w, row(r, 0), n, c.want)
}
}
}
// A field's view pulls back when its text shrinks, so no column is left
// blank while text is hidden, and a muted '…' marks text out of view, never
// under the cursor
func TestTextInputPullsBackAndMarksHiddenText(t *testing.T) {
f := NewTextFieldState(strings.Repeat("abcdefghij", 2))
draw := func(focused bool) string {
r := canvas(10, 1, MonoTheme)
r.TextInput(f, "", focused, MonoTheme)
return row(r, 0)
}
if got := draw(true); got != "…cdefghij" {
t.Fatalf("the cursor after 20 in 10 columns: %q", got)
}
for range 8 {
f.HandleKey(terminal.KeyBackspace, 0, 0)
}
if got := draw(true); got != "…efghijab" {
t.Fatalf("12 left: %q, scroll %d", got, f.Scroll)
}
f.HandleKey(terminal.KeyHome, 0, 0)
if got := draw(true); got != "abcdefghi…" {
t.Fatalf("the cursor at the start: %q", got)
}
}
// A field's label leaves the value at least half the row; label width 0
// stacks the label on its own row, whole, and the value under it
func TestFieldLeavesTheValueHalfTheRow(t *testing.T) {
for _, w := range []int{12, 30, 80} {
r := canvas(w, 3, MonoTheme)
if v, rows := r.Field(0, 40, Field{Label: "password_file"}, false, MonoTheme); v.W < (w-3)/2 || rows != 1 {
t.Errorf("%d columns: value %d wide, %d rows", w, v.W, rows)
}
r = canvas(w, 3, MonoTheme)
v, rows := r.Field(0, 0, Field{Label: "password_file"}, false, MonoTheme)
if label := Truncate("password_file", w-2); row(r, 0) != " "+label || v.Y != 1 || v.W != w-4 || rows != 2 {
t.Errorf("%d columns stacked: %q, value at row %d, %d wide, %d rows", w, row(r, 0), v.Y, v.W, rows)
}
}
}
// Text wraps at each line break, keeping an empty line, and a word longer
// than the line breaks after a '.', ',', ':', '/', '[' or ']'
func TestWrapTextBreaksAtLinesAndMarks(t *testing.T) {
for text, want := range map[string][]string{
"ab\ncd ef\n\ngh": {"ab", "cd ef", "", "gh"},
"key: /etc/logwisp/very/long/path.pem": {"key:", "/etc/logwisp/very/", "long/path.pem"},
} {
if got := WrapText(text, map[bool]int{true: 5, false: 20}[strings.HasPrefix(text, "ab")]); !slices.Equal(got, want) {
t.Errorf("%q: %q, want %q", text, got, want)
}
}
}
// Wrapping is linear in the lines: 4000 lines allocate under 1 MiB
func TestWrapTextIsLinearInLines(t *testing.T) {
text := strings.Repeat("x\n", 4000)
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
lines := WrapText(text, 80)
runtime.ReadMemStats(&after)
if len(lines) != 4001 || after.TotalAlloc-before.TotalAlloc > 1<<20 {
t.Fatalf("%d lines, %d bytes allocated", len(lines), after.TotalAlloc-before.TotalAlloc)
}
}
// Where the hint column would be narrow, the list shows names alone and the
// cursor's hint wraps below them, in rows Rows counts; wide, hints sit beside
func TestNarrowOptionListShowsTheCursorsHint(t *testing.T) {
s := NewOptionListState([]Option{{Name: "pipe", Hint: "standard input to standard output"}, {Name: "tail", Hint: "follow files"}})
s.Cursor = 1
for w, want := range map[int][]string{
60: {"/ type to filter", " pipe standard input to standard output", "▸ tail follow files"},
20: {"/ type to filter", " pipe", "▸ tail", "", " follow files"},
} {
r := canvas(w, s.Rows(w), MonoTheme)
r.OptionList(s, MonoTheme)
var got []string
for y := range r.H {
got = append(got, row(r, y))
}
if !slices.Equal(got, want) {
t.Errorf("%d columns: %q, want %q", w, got, want)
}
}
}
// A menu has no filter: a key picks its option, and typing filters nothing
func TestMenuPicksByKey(t *testing.T) {
s := NewOptionListState([]Option{{Name: "run", Key: 'r'}, {Name: "command", Key: 'c'}})
s.Menu = true
if i, ok := s.Pick('c'); !ok || i != 1 {
t.Fatalf("c: %d, %v", i, ok)
}
if _, ok := s.Pick('x'); ok || s.HandleKey(terminal.KeyRune, 'x', 0) || s.Paste("r") || s.Filter.Value() != "" {
t.Fatalf("x picked or filtered: %q", s.Filter.Value())
}
r := canvas(20, s.Rows(20), MonoTheme)
r.OptionList(s, MonoTheme)
if r.H != 2 || row(r, 0) != "▸ r run" || row(r, 1) != " c command" {
t.Fatalf("%d rows: %q %q", r.H, row(r, 0), row(r, 1))
}
}
// A frame's inside is the region within its border and padding, so a frame
// sized to what it holds ends on it; the border takes the glyphs' line type
func TestFrameHoldsItsInside(t *testing.T) {
ascii := MonoTheme
ascii.Glyphs = GlyphsASCII
for _, c := range []struct {
th Theme
want [3]string
}{{MonoTheme, [3]string{"┌─ T ───┐", "│ abcde │", "└───────┘"}}, {ascii, [3]string{"+- T ---+", "| abcde |", "+-------+"}}} {
r := canvas(9, 3, c.th)
in := r.Frame("T", c.th)
in.TextStyled(0, 0, "abcde", c.th.Text)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != c.want || in.W != 5 || in.H != 1 {
t.Errorf("%q, inside %dx%d", got, in.W, in.H)
}
}
}
// A rule keeps its title; the hint at its end goes first when both do not fit
func TestRuleKeepsTheTitleBeforeTheHint(t *testing.T) {
for w, want := range map[int]string{40: "── file · source ────────────── ⏎ edit ─", 24: "── file · source ───────"} {
r := canvas(w, 1, MonoTheme)
if r.Rule(0, "file · source", "⏎ edit", MonoTheme); row(r, 0) != want {
t.Errorf("%d columns: %q", w, row(r, 0))
}
}
}
// Wires join where they meet: each cell draws the arms laid through it,
// whatever order they were laid in
func TestWiresJoinWhereTheyMeet(t *testing.T) {
g := NewWires(5, 3)
g.V(4, 1, 2)
g.H(1, 0, 4)
g.V(2, 0, 2)
g.H(9, 0, 9) // off the grid
for line, want := range map[LineType][3]string{LineSingle: {" │", "──┼─┐", " │ │"}, LineASCII: {" |", "--+-+", " | |"}} {
r := canvas(5, 3, MonoTheme)
r.DrawWires(g, line, MonoTheme.Border)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != want {
t.Errorf("%d: %q, want %q", line, got, want)
}
}
}
// A window shows the content's rows from the scroll offset, and fills what
// the content does not reach; a window with no columns draws nothing
func TestWindowShowsTheRowsInView(t *testing.T) {
r := canvas(6, 3, MonoTheme)
var v ViewportScroll
v.SetDimensions(10, r.H)
v.EnsureRange(7, 1)
content := func(c Region) {
for y := range c.H {
c.TextStyled(0, y, fmt.Sprintf("row %d", y), MonoTheme.Text)
}
}
r.Window(10, &v, MonoTheme.Text, content)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 5", "row 6", "row 7"} {
t.Fatalf("rows 5-7: %q", got)
}
r.Window(2, &v, MonoTheme.Text, content)
if got := [3]string{row(r, 0), row(r, 1), row(r, 2)}; got != [3]string{"row 0", "row 1", ""} {
t.Fatalf("two rows of content: %q", got)
}
r.Sub(8, 0, 4, 3).Window(10, &v, MonoTheme.Text, content) // no columns, past the right edge
}
// A label is drawn a column a rune, so one holding runes of more than a byte
// keeps them together: each widget draws s as many times as it is given it
func TestLabelsKeepTheirRunesTogether(t *testing.T) {
const s = "● é · ñ"
for _, c := range []struct {
name string
n int
draw func(r Region)
}{
{"pane", 1, func(r Region) { r.Pane(PaneOpts{Title: s}) }},
{"modal", 2, func(r Region) { r.Modal(ModalOpts{Title: s, Hint: s}) }},
{"divider", 1, func(r Region) { r.Divider(0, s, LineSingle, color.RGB{}) }},
{"tabs", 2, func(r Region) { r.TabBar(0, []string{s, s}, 0, DefaultTabBarOpts()) }},
{"table", 2, func(r Region) { r.Table([]string{s}, [][]string{{s}}, TableOpts{}) }},
{"form", 1, func(r Region) { r.Form(NewFormState(s), FormOpts{}) }},
{"field", 1, func(r Region) { r.TextField(NewTextFieldState(""), TextFieldOpts{Placeholder: s}) }},
{"confirm", 4, func(r Region) {
r.ConfirmDialog(NewConfirmState(true), ConfirmOpts{Title: s, Message: s, YesLabel: s, NoLabel: s})
}},
{"alert", 3, func(r Region) { r.AlertDialog(AlertOpts{Title: s, Message: s, Button: s}) }},
{"progress", 1, func(r Region) { r.ProgressOverlay(DefaultProgressOpts(s, "", ProgressSpinner)) }},
} {
r := canvas(48, 14, MonoTheme)
c.draw(r)
rows := make([]string, r.H)
for y := range rows {
rows[y] = row(r, y)
}
if got := strings.Count(strings.Join(rows, "\n"), s); got != c.n {
t.Errorf("%s: %d of %d, rows %q", c.name, got, c.n, rows)
}
}
}