package game import ( "fmt" "regexp" "strings" "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/color" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) var rouletteSGR = regexp.MustCompile(`\x1b\[[0-9;]*m`) func plainRoulette(s string) string { return rouletteSGR.ReplaceAllString(s, "") } func TestRouletteArtModes(t *testing.T) { for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { for _, double := range []bool{false, true} { t.Run(fmt.Sprintf("%s/unicode=%v/double=%v", mode, unicode, double), func(t *testing.T) { wheelOrder := casino.WheelOrder(double) s := &casino.RouletteSnapshot{DoubleZero: double, Wheel: wheelOrder} for _, phase := range []string{"betting", "playing", "result"} { s.Phase = phase s.Pockets = [5]string{"25", "26", "0", "32", "15"} art := renderRouletteSnapshot(s, mode, unicode, 7) plain := plainRoulette(art) if mode == "xterm256" && unicode && double { t.Logf("%s preview (colors stripped):\n%s", phase, plain) } if strings.Contains(plain, "\x1b") { t.Fatal("malformed ANSI") } if mode == "none" && art != plain { t.Fatal("color disabled but ANSI present") } for _, r := range plain { if !unicode && r > 127 { t.Fatalf("non-ASCII rune %q in ASCII mode", r) } } for _, line := range strings.Split(art, "\n") { if color.VisibleLen(line) > 78 { t.Fatalf("line exceeds terminal width: %q", plainRoulette(line)) } } if mode != "none" { pocketBgs := []int{0, 1, 2} if phase == "playing" { pocketBgs = []int{rouletteWheelBlack, 1, 2} } for _, bg := range pocketBgs { if !strings.Contains(art, color.BgCode(mode, bg)) { t.Fatalf("phase=%s missing pocket background %d", phase, bg) } } if !strings.Contains(art, color.FgCode(mode, 15)) || !strings.Contains(art, color.StyleCode("bold")) { t.Fatal("missing bold white text") } if !strings.HasSuffix(strings.TrimRight(art, "\n"), color.Reset) { t.Fatal("art leaves terminal colored") } } } layoutArt := renderRouletteLayout(double, false, mode, unicode) layout := plainRoulette(layoutArt) rows := strings.Split(layout, "\n") if double { if !strings.Contains(string([]rune(rows[1])[:7]), "00") || !strings.Contains(string([]rune(rows[5])[:7]), "0") { t.Fatal("double zero not left of grid", layout) } } else if !strings.Contains(string([]rune(rows[3])[:7]), "0") { t.Fatal("zero not left of grid", layout) } for _, target := range []string{"1to12", "13to24", "25to36", "row1", "row2", "row3", "red", "black", "odd", "even", "low", "high"} { if !strings.Contains(layout, target) { t.Fatal("missing bet", target) } } // The odds reminder belongs to "table", never the board. for _, reminder := range []string{"Numbers 35:1", "Low: 1-18", "bet 10 on"} { if strings.Contains(layout, reminder) { t.Fatal("reminder text leaked onto the betting board") } } if mode != "none" { // The whole 0/00 lane is green: the three numbered rows plus // the two separator rows that run through the lane. wantGreen := 5 if got := strings.Count(layoutArt, color.BgCode(mode, 2)); got != wantGreen { t.Fatalf("green cells on board = %d, want %d", got, wantGreen) } } wheelArt := renderRouletteWheel(wheelOrder, mode, unicode) plainWheel := plainRoulette(wheelArt) lines := strings.Split(plainWheel, "\n") if len(lines) != 9 { t.Fatalf("wheel height = %d, want 9", len(lines)) } if strings.ContainsAny(plainWheel, "0123456789") { t.Fatalf("wheel should not show numbers:\n%s", plainWheel) } if mode == "none" || mode == "" { for _, r := range "RBG" { if !strings.ContainsRune(plainWheel, r) { t.Fatalf("colorless wheel missing %q:\n%s", r, plainWheel) } } // The top and bottom rows must be several cells wide so the // disc reads as a circle rather than a pointed oval. for _, idx := range []int{0, 8} { if got := len(strings.TrimSpace(lines[idx])); got != 5 { t.Fatalf("wheel row %d = %d wide, want 5:\n%s", idx, got, plainWheel) } } } else { for _, bg := range []int{rouletteWheelBlack, 1, 2} { if !strings.Contains(wheelArt, color.BgCode(mode, bg)) { t.Fatalf("wheel missing pocket background %d", bg) } } } result := plainRoulette(renderRouletteResult([5]string{"36", "1", "00", "27", "13"}, mode, unicode, 7)) for _, banned := range []string{"PREV", "BALL", "NEXT", "WINNER"} { if strings.Contains(result, banned) { t.Fatalf("result prose %q should be removed:\n%s", banned, result) } } }) } } } } // TestRouletteWheelGreenPockets checks that the single-zero wheel has one green // pocket and the American wheel adds the diametrically opposite one. func TestRouletteWheelGreenPockets(t *testing.T) { single := plainRoulette(renderRouletteWheel(casino.WheelOrder(false), "none", true)) double := plainRoulette(renderRouletteWheel(casino.WheelOrder(true), "none", true)) singleGreen := strings.Count(single, "G") doubleGreen := strings.Count(double, "G") if singleGreen == 0 { t.Fatalf("single-zero wheel has no green:\n%s", single) } if doubleGreen <= singleGreen { t.Fatalf("double-zero green = %d, want more than single-zero %d:\n%s", doubleGreen, singleGreen, double) } } // TestRouletteLayoutConnections guards the box-drawing joints: the dozen-row // boxes must connect to the numbered grid above and be closed under, the // row-bet cells must be boxed on the right, and the bottom rule must tee under // each outside-bet divider. func TestRouletteLayoutConnections(t *testing.T) { for _, double := range []bool{false, true} { art := plainRoulette(renderRouletteLayout(double, false, "xterm256", true)) rows := strings.Split(art, "\n") // rows[6] is the numbered grid's bottom edge, directly above the dozen // labels. The dozen boundaries and the grid/row-lane joint cross; the // row-bet lane closes with a bottom-right corner. gridBottom := []rune(rows[6]) for _, col := range []int{6, 26, 46, 66} { if gridBottom[col] != '┼' { t.Fatalf("double=%v: grid bottom col %d = %q, want ┼:\n%s", double, col, gridBottom[col], art) } } if gridBottom[73] != '┘' { t.Fatalf("double=%v: grid bottom row-lane corner = %q, want ┘:\n%s", double, gridBottom[73], art) } // The row-bet lane is boxed: its separators cross at the grid edge and // close on the right. for _, idx := range []int{2, 4} { sep := []rune(rows[idx]) if sep[66] != '┼' || sep[73] != '┤' { t.Fatalf("double=%v: row lane separator %q lacks box joints:\n%s", double, rows[idx], art) } } // rows[8] is the dozen boxes' bottom edge: tees point down where the // even-money dividers sit and crosses pass the dozen dividers through. dozenBottom := []rune(rows[8]) wantDozen := map[int]rune{6: '├', 16: '┬', 26: '┼', 36: '┬', 46: '┼', 56: '┬', 66: '┤'} for col, want := range wantDozen { if dozenBottom[col] != want { t.Fatalf("double=%v: dozen bottom col %d = %q, want %q:\n%s", double, col, dozenBottom[col], want, art) } } // The last line is the table's bottom rule; it tees upward under each // outside-bet divider. bottom := []rune(rows[len(rows)-1]) for _, col := range []int{16, 26, 36, 46, 56} { if bottom[col] != '┴' { t.Fatalf("double=%v: table bottom col %d = %q, want ┴:\n%s", double, col, bottom[col], art) } } } } // TestRouletteZeroPanel checks the two zero-panel fixes: the whole five-row // lane carries the green background, and its outer corners are rounded in // Unicode mode while ASCII mode stays square. func TestRouletteZeroPanel(t *testing.T) { for _, double := range []bool{false, true} { colored := renderRouletteLayout(double, false, "xterm256", true) rows := strings.Split(colored, "\n") // Interior rows 1-5: top numbered row, separator, middle, separator, // bottom numbered row. Each must contain the green background. for row := 1; row <= 5; row++ { if !strings.Contains(rows[row], color.BgCode("xterm256", 2)) { t.Fatalf("double=%v: zero lane row %d is not green:\n%s", double, row, plainRoulette(colored)) } } plainRows := strings.Split(plainRoulette(colored), "\n") if !strings.HasPrefix(plainRows[0], "╭") || !strings.HasPrefix(plainRows[6], "╰") { t.Fatalf("double=%v: zero panel corners are not rounded:\n%s", double, plainRoulette(colored)) } ascii := plainRoulette(renderRouletteLayout(double, false, "none", false)) for _, r := range ascii { if r > 127 { t.Fatalf("double=%v: non-ASCII rune %q in ASCII mode", double, r) } } } } // TestRouletteRacetrackPanel checks that an enabled racetrack adds the call-bet // panel above the board, names the four call-bet areas (not Neighbors), and // keeps the same width and ASCII/Unicode guarantees as the board. func TestRouletteRacetrackPanel(t *testing.T) { for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { art := renderRouletteLayout(false, true, mode, unicode) plain := plainRoulette(art) for _, want := range []string{"Tiers du", "Cylindre", "Orphelins", "Voisins du Zero", "Jeu"} { if !strings.Contains(plain, want) { t.Fatalf("mode=%s unicode=%v: missing %q:\n%s", mode, unicode, want, plain) } } for _, line := range strings.Split(art, "\n") { if color.VisibleLen(line) > 78 { t.Fatalf("mode=%s unicode=%v: line exceeds terminal width: %q", mode, unicode, plainRoulette(line)) } if !unicode { for _, r := range plainRoulette(line) { if r > 127 { t.Fatalf("mode=%s: non-ASCII rune %q in ASCII mode", mode, r) } } } } } } // The panel comes from the snapshot flag, and a plain board omits it. snap := &casino.RouletteSnapshot{Phase: "betting", Wheel: casino.WheelOrder(false), Racetrack: true} if !strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") { t.Fatal("racetrack snapshot did not draw the panel") } snap.Racetrack = false if strings.Contains(plainRoulette(renderRouletteSnapshot(snap, "none", true, 7)), "Voisins") { t.Fatal("plain snapshot drew the racetrack panel") } } func TestRouletteResultSizesAndNeighbors(t *testing.T) { for _, unicode := range []bool{false, true} { for _, size := range []int{1, 5, 7, 9} { art := plainRoulette(renderRouletteResult([5]string{"36", "1", "00", "27", "13"}, "xterm256", unicode, size)) lines := strings.Split(art, "\n") height := max(5, cardRenderSize(unicode, size)) width := height + 2 if len(lines) != height { t.Fatalf("size=%d, height=%d want %d", size, len(lines), height) } for _, line := range lines { if color.VisibleLen(line) != 5*width+4 { t.Fatalf("unequal pocket widths: %q", line) } } top := plainRoulette(strings.Split(art, "\n")[1]) last := -1 for _, pocket := range []string{"36", "1", "00", "27", "13"} { at := strings.Index(top, pocket) if at < 0 || at < last { t.Fatalf("wrong pocket order for %s: %q", pocket, top) } last = at } ball := "o" if unicode { ball = "●" } if strings.Count(art, ball) != 1 { t.Fatal("missing or duplicate ball", art) } } } } // TestRouletteResultLayout checks the new card shape: number on top, a rule // under it, the ball one row above centre, and the color spelled out on the // bottom row only when color is off. cardRenderSize caps non-Unicode cards at // five rows, so the full nine-row shape is exercised through Unicode. func TestRouletteResultLayout(t *testing.T) { pockets := [5]string{"1", "10", "27", "2", "3"} // RED, BLACK, RED, BLACK, RED for _, tc := range []struct { unicode bool size int height int }{ {true, 9, 9}, {true, 7, 7}, {true, 5, 5}, {false, 9, 5}, } { unicode, height := tc.unicode, tc.height ball := "o" rule := "-" if unicode { ball = "●" rule = "─" } ballRow := height / 2 colorRow := height - 2 hasRule := ballRow > 2 colored := plainRoulette(renderRouletteResult(pockets, "xterm256", unicode, tc.size)) lines := strings.Split(colored, "\n") if len(lines) != height { t.Fatalf("unicode=%v size=%d: height=%d, want %d", unicode, tc.size, len(lines), height) } for i, want := range pockets { if !strings.Contains(lines[1], want) { t.Fatalf("unicode=%v size=%d: top row missing pocket %d (%s):\n%s", unicode, tc.size, i, want, colored) } } if hasRule { if !strings.Contains(lines[2], rule) { t.Fatalf("unicode=%v size=%d: missing rule under the number:\n%s", unicode, tc.size, colored) } } if !strings.Contains(lines[ballRow], ball) { t.Fatalf("unicode=%v size=%d: ball should be on row %d:\n%s", unicode, tc.size, ballRow, colored) } if strings.Contains(colored, "RED") || strings.Contains(colored, "BLACK") { t.Fatalf("unicode=%v size=%d: color label shown while color is on:\n%s", unicode, tc.size, colored) } plain := plainRoulette(renderRouletteResult(pockets, "none", unicode, tc.size)) plainLines := strings.Split(plain, "\n") if strings.Contains(plainLines[1], "RED") || strings.Contains(plainLines[1], "BLACK") { t.Fatalf("unicode=%v size=%d: color label should not be on the top row:\n%s", unicode, tc.size, plain) } if !strings.Contains(plainLines[colorRow], "RED") || !strings.Contains(plainLines[colorRow], "BLACK") { t.Fatalf("unicode=%v size=%d: missing color label on row %d:\n%s", unicode, tc.size, colorRow, plain) } } } func TestRouletteEventColorsDoNotWrapArt(t *testing.T) { for _, public := range []bool{false, true} { for _, mode := range []string{"none", "ansi", "xterm256"} { g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub()} p := player.New("alice") p.RoomID = 1 p.Options = map[string]any{"color": mode, "unicode": true} conn := &casinoTestConn{} sess := &net.Session{Player: p, Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} g.Hub.EnterRoom(sess, 1) // Use an emphatic blue background to detect whole-message color wrapping. sess.Account = &player.Account{Colors: map[string]string{"casino_action": "0F bg:04 bold"}} g.Casino.Join(1, casino.TableConfig{ID: "r", Game: casino.GameRoulette}, p.Name) e := casino.Event{RoomID: 1, TableID: "r", Message: "Spinning now", Color: "casino_action", Public: public, Snapshot: &casino.RouletteSnapshot{Phase: "playing", Players: []string{p.Name}}} if !public { e.PrivateTo = p.Name } g.emitCasinoEvents([]casino.Event{e}) output := strings.ReplaceAll(string(conn.data), "\r\n", "\n") expected := g.colorize(sess, "casino_action", e.Message) + "\n" + g.renderCasinoSnapshot(sess, e.Snapshot) + "\n" if output != expected { t.Fatalf("public=%v mode=%s: prose color leaked into art or snapshot missing\n%q\nwant\n%q", public, mode, output, expected) } } } } func TestRouletteSettlementShowsBalanceOnce(t *testing.T) { for _, action := range []string{"", "sitout", "leave_after"} { g := &Game{Casino: casino.NewManager(7), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} p := player.New("alice") p.Credits = 1000 p.Options = map[string]any{"color": "none"} conn := &casinoTestConn{} sess := &net.Session{Player: p, Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, BettingTicks: 1, Roulette: casino.RouletteConfig{SpinTicks: 1}} g.Casino.Join(1, cfg, p.Name) g.Casino.Bet(1, "r", p.Name, "dozen2", 10, casinoWallet{g: g, p: p, saveCharacter: func(*player.Player) error { return nil }}) if action != "" { g.Casino.Action(1, "r", p.Name, action) } // A lone player's spin starts on the bet, so the first tick settles. g.emitCasinoEvents(g.Casino.Tick()) if got := strings.Count(string(conn.data), "Available "); got != 1 { t.Fatalf("action=%q: balance printed %d times\n%s", action, got, conn.data) } } } // Read the perimeter clockwise, not in terminal row order. Every displayed // pocket must match the actual European wheel, including the wraparound. func TestRouletteRacetrackPhysicalOrder(t *testing.T) { for _, unicode := range []bool{false, true} { art := renderRouletteRacetrack("none", unicode) rows := strings.Split(art, "\n") top, bottom := racetrackNumbers(rows[1]), racetrackNumbers(rows[9]) if len(top) != 16 || len(bottom) != 15 { t.Fatalf("missing track pockets:\n%s", art) } order := append([]string(nil), top...) for _, i := range []int{3, 5, 7} { side := racetrackNumbers(rows[i]) if len(side) != 2 { t.Fatalf("missing curved-end pockets:\n%s", art) } order = append(order, side[1]) } for i := len(bottom) - 1; i >= 0; i-- { order = append(order, bottom[i]) } for _, i := range []int{7, 5, 3} { order = append(order, racetrackNumbers(rows[i])[0]) } wheel := casino.WheelOrder(false) if len(order) != len(wheel) { t.Fatalf("got %d pockets", len(order)) } for i, p := range order { if p != wheel[(19+i)%len(wheel)] { t.Errorf("pocket %d: %s", i, p) } } if got := racetrackNumbers(rows[5]); got[1] != "26" { t.Fatalf("right tip is %s, want 26", got[1]) } } } func racetrackNumbers(row string) []string { var out []string for _, match := range regexp.MustCompile(`([0-9]+)[RBG]`).FindAllStringSubmatch(row, -1) { out = append(out, match[1]) } return out } // The sections must touch their actual bet coverage, not merely contain // labels. Zero is a nested part of Voisins, not a fourth disjoint sector. func TestRouletteRacetrackSections(t *testing.T) { for _, unicode := range []bool{false, true} { rows := strings.Split(renderRouletteRacetrack("none", unicode), "\n") segment := func(y, left, right int) string { return string([]rune(rows[y])[left:right]) } nums := func(y, left, right int) []string { return racetrackNumbers(segment(y, left, right)) } sections := map[string][]string{ "tiers": append(nums(1, 4, 20), nums(9, 4, 24)...), "orphelins": append(nums(1, 20, 40), nums(9, 24, 40)...), "voisins": append(nums(1, 40, 60), nums(9, 40, 60)...), "jeuzero": append(nums(1, 60, 68), nums(9, 60, 68)...), } for _, y := range []int{3, 5, 7} { sections["tiers"] = append(sections["tiers"], nums(y, 0, 4)...) sections["jeuzero"] = append(sections["jeuzero"], nums(y, 68, 73)...) } sections["voisins"] = append(sections["voisins"], sections["jeuzero"]...) for _, bet := range casino.RacetrackBets() { got := sections[bet.Syntax] if len(got) != len(bet.Series) { t.Fatalf("%s: coverage %v, want %v", bet.Syntax, got, bet.Series) } seen := map[string]bool{} for _, n := range got { seen[n] = true } for _, n := range bet.Series { if !seen[n] { t.Errorf("%s does not touch %s", bet.Syntax, n) } } } // Labels wrap onto the rows around the middle so no glyph touches a // divider; every field keeps a blank cell on both sides of its text. for _, label := range []struct { row, left, right int text string }{ {4, 5, 20, "Tiers du"}, {6, 5, 22, "Cylindre"}, {5, 23, 40, "Orphelins"}, {5, 41, 58, "Voisins du Zero"}, {4, 60, 68, "Jeu"}, {6, 60, 68, "Zero"}, } { field := segment(label.row, label.left, label.right) if strings.TrimSpace(field) != label.text { t.Errorf("misplaced %q label on row %d: %q", label.text, label.row, field) } if !strings.HasPrefix(field, " ") || !strings.HasSuffix(field, " ") { t.Errorf("%q label touches a divider on row %d: %q", label.text, label.row, field) } } // Every step joins orthogonally; no font-dependent diagonal gaps. b := rouletteBorders(unicode) for _, step := range []struct { x, y int text string }{ {20, 3, b.v}, {20, 4, b.bl + b.h + b.tr}, {22, 5, b.v}, {22, 6, b.bl + b.h + b.tr}, {24, 7, b.v}, } { if got := segment(step.y, step.x, step.x+len([]rune(step.text))); got != step.text { t.Errorf("disconnected step at %d,%d: %q", step.x, step.y, got) } } // The Zero boundary bows into Voisins then returns to the same column. for _, p := range [][2]int{{60, 2}, {59, 3}, {58, 4}, {58, 5}, {58, 6}, {59, 7}, {60, 8}} { if []rune(rows[p[1]])[p[0]] == ' ' { t.Errorf("gap in Zero curve at %v", p) } } } for _, mode := range []string{"none", "ansi", "xterm256"} { for _, unicode := range []bool{false, true} { art := renderRouletteRacetrack(mode, unicode) if strings.Contains(strings.ToLower(art), "neighbor") { t.Fatal("Neighbors belongs in help, not on the racetrack") } rows := strings.Split(art, "\n") for y, row := range rows { wantWidth := 73 if y == 0 || y == 10 { wantWidth = 69 } if y == 1 || y == 9 { wantWidth = 72 } if color.VisibleLen(row) != wantWidth { t.Errorf("mode=%s unicode=%v: row %d width=%d, want %d", mode, unicode, y, color.VisibleLen(row), wantWidth) } } } } } // TestRouletteWheelBlackAndSpacing locks in the visual tweaks: the spinning // wheel uses a visible dark blue for its black pockets while the betting board // keeps true black, and the wheel and result graphics get blank line framing. func TestRouletteWheelBlackAndSpacing(t *testing.T) { wheelArt := renderRouletteWheel(casino.WheelOrder(false), "xterm256", true) if !strings.Contains(wheelArt, color.BgCode("xterm256", rouletteWheelBlack)) { t.Fatal("spinning wheel lost its dark blue black pockets") } if strings.Contains(wheelArt, color.BgCode("xterm256", 0)) { t.Fatal("spinning wheel should not use pure black") } board := renderRouletteLayout(false, false, "xterm256", true) if !strings.Contains(board, color.BgCode("xterm256", 0)) { t.Fatal("betting board should keep true black pockets") } for _, tc := range []struct { phase string want string }{ {"playing", renderRouletteWheel(casino.WheelOrder(false), "none", true)}, {"result", renderRouletteResult([5]string{"25", "26", "0", "32", "15"}, "none", true, 7)}, } { s := &casino.RouletteSnapshot{Wheel: casino.WheelOrder(false), Phase: tc.phase, Pockets: [5]string{"25", "26", "0", "32", "15"}} art := renderRouletteSnapshot(s, "none", true, 7) if !strings.HasPrefix(art, "\n") || !strings.HasSuffix(art, "\n") { t.Fatalf("phase=%s: graphic is not blank-line framed: %q", tc.phase, art) } if !strings.Contains(art, tc.want) { t.Fatalf("phase=%s: framed art lost the graphic", tc.phase) } } } // The roulette menu draws the board before the wager reminder so the // instructions follow the table they describe, and the tracker gets a blank // line above it instead of butting into the heading. func TestRouletteMenuPutsReminderAfterBoard(t *testing.T) { g := &Game{Casino: casino.NewManager(1), Hub: net.NewHub(), Combat: combat.NewTracker(), Deps: Deps{World: world.New(t.TempDir())}} p := player.New("alice") p.RoomID, p.Credits = 1, 1000 p.Options = map[string]any{"color": "none", "unicode": true} conn := &casinoTestConn{} sess := &net.Session{Player: p, Conn: conn} g.loggedInChars = map[string]*net.Session{"alice": sess} g.Hub.EnterRoom(sess, 1) cfg := casino.TableConfig{ID: "r", Game: casino.GameRoulette, Roulette: casino.RouletteConfig{Racetrack: true}} g.emitCasinoEvents(g.Casino.Join(1, cfg, p.Name)) out := strings.ReplaceAll(string(conn.data), "\r\n", "\n") tracker := strings.Index(out, "History: last 0 spins") board := strings.Index(out, "R = red, B = black, G = green") reminder := strings.Index(out, "bet on ") if tracker < 0 || board < 0 || reminder < 0 { t.Fatalf("missing tracker, board, or reminder:\n%s", out) } if !(tracker < board && board < reminder) { t.Fatalf("expected tracker before board before reminder, got %d/%d/%d:\n%s", tracker, board, reminder, out) } if !strings.Contains(out, "\n\n┌─ History: last 0 spins") { t.Fatalf("tracker is not preceded by a blank line:\n%s", out) } }