Files
scrawl/game/model_test.go

145 lines
4.5 KiB
Go
Raw Permalink Normal View History

Initial build: scrawl, a shared ASCII canvas over SSH ssh in and draw on a live, shared doodle wall -- everyone connected sees everyone else's edits in real time. Same wish+bubbletea security model as delve-term (no shell, no exec, structurally can't reach a real shell on the host), extended with the one thing delve-term didn't need: multiple sessions sharing live state. wish's bm.Middleware helper hides the *tea.Program it creates, so this builds the program directly instead, keeping a registry (game.Canvas) that broadcasts a redraw signal to every other connected session the instant one of them paints. Banner generated via `figlet -f slant SCRAWL` rather than hand-drawn -- slant felt right for a doodle/scribble tool. Two real bugs caught by actually running this with real SSH sessions, not just unit-testing the game logic in isolation: 1. Program.Send() blocks until that program's Run() event loop is actively reading from it. Broadcasting synchronously from inside Join()/Paint() (including a session broadcasting to its own, not-yet-running program on join) deadlocked every session before it ever reached Run() -- the very first connection just hung with nothing rendered. Fixed by sending asynchronously (go p.Send(...)) everywhere the canvas notifies sessions of a change. 2. Subtler: lipgloss's default package-level styles detect color support from the *server process's* os.Stdout, not any given session's actual terminal -- and a server's stdout is typically redirected (a log file, systemd journal), so every connected session silently lost all color/background styling at once, server-wide. Manifested as painting a cell your own cursor already sat on being invisible (the cursor glyph before/after looked identical, so bubbletea's diffing renderer correctly sent zero bytes for a change that produced no visual diff) -- confirmed via server-side debug logging that painting itself worked correctly every time, isolating the bug to rendering, then confirmed via a Go test that forcing a real color profile was the difference between 0 and 1327 runes of diff between two frames that should look different. Fixed with a lipgloss.Renderer created per-session, bound to that session's actual output, forced to TrueColor; cursors now highlight whatever's already painted at that cell (background tint) rather than replacing the character, so a session's own paint is never masked by its own cursor marker sitting on top of it. Verified end-to-end with two real, simultaneous SSH sessions (scripted via pexpect): peer count syncs correctly, and one session's paint genuinely arrives at the other via the live broadcast -- not just unit-tested in isolation. Also verified the actual Docker image builds and serves correctly over real SSH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:28:17 +02:00
package game
import (
"io"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
func TestColorAndCharSwitching(t *testing.T) {
c := NewCanvas()
m := NewModel(c, "test-id", "tester", newTestRenderer())
c.Join("test-id", "tester", nil)
// First keypress just exits the intro screen, per Update()'s own logic.
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
m = mi.(Model)
if m.state != stateCanvas {
t.Fatalf("expected first keypress to exit intro, state=%v", m.state)
}
if m.inkColorIdx != 0 {
t.Fatalf("expected inkColorIdx unchanged by the intro-exit keypress, got %d", m.inkColorIdx)
}
// Now actually press "3" for color selection.
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
m = mi.(Model)
if m.inkColorIdx != 2 {
t.Fatalf("expected inkColorIdx=2 after pressing '3', got %d", m.inkColorIdx)
}
// Paint with the selected color.
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = mi.(Model)
cells, cursors := c.Snapshot()
cur := cursors["test-id"]
painted := cells[cur.Y][cur.X]
if painted.Char != charPalette[0] {
t.Fatalf("expected painted char to be default charPalette[0]=%q, got %q", charPalette[0], painted.Char)
}
if painted.Color != inkPalette[2] {
t.Fatalf("expected painted color to be inkPalette[2]=%q (selected via '3'), got %q", inkPalette[2], painted.Color)
}
}
func TestCharCycling(t *testing.T) {
c := NewCanvas()
m := NewModel(c, "test-id", "tester", newTestRenderer())
c.Join("test-id", "tester", nil)
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
m = mi.(Model)
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("]")})
m = mi.(Model)
if m.charIdx != 1 {
t.Fatalf("expected charIdx=1 after ']', got %d", m.charIdx)
}
}
func TestViewChangesAfterMoveAwayFromPaint(t *testing.T) {
c := NewCanvas()
m := NewModel(c, "test-id", "tester", newTestRenderer())
c.Join("test-id", "tester", nil)
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
m = mi.(Model)
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // paint
m = mi.(Model)
viewAtPaintPosition := m.View()
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right
m = mi.(Model)
viewAfterMove := m.View()
if viewAtPaintPosition == viewAfterMove {
t.Fatal("expected View() output to differ after moving away from a painted cell, but it's identical")
}
t.Logf("view at paint position:\n%s", viewAtPaintPosition)
t.Logf("view after move:\n%s", viewAfterMove)
}
func TestMoveCursorActuallyMoves(t *testing.T) {
c := NewCanvas()
m := NewModel(c, "test-id", "tester", newTestRenderer())
c.Join("test-id", "tester", nil)
before := c.CursorsSnapshot()["test-id"]
t.Logf("before: x=%d y=%d", before.X, before.Y)
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
m = mi.(Model)
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) // move right
m = mi.(Model)
after := c.CursorsSnapshot()["test-id"]
t.Logf("after: x=%d y=%d", after.X, after.Y)
if after.X != before.X+1 {
t.Fatalf("expected cursor X to move from %d to %d, got %d", before.X, before.X+1, after.X)
}
}
// newTestRenderer forces TrueColor rather than letting lipgloss
// auto-detect from the writer (io.Discard, like any non-*os.File writer,
// would otherwise be treated as non-color-capable) -- matches main.go's
// real per-session renderer setup, so these tests actually exercise the
// same styling path a real SSH session does instead of silently testing
// an unstyled/plain-text code path that would mask exactly this class
// of bug.
func newTestRenderer() *lipgloss.Renderer {
r := lipgloss.NewRenderer(io.Discard)
r.SetColorProfile(termenv.TrueColor)
return r
}
func TestCommaPeriodCharCycling(t *testing.T) {
c := NewCanvas()
m := NewModel(c, "test-id", "tester", newTestRenderer())
c.Join("test-id", "tester", nil)
mi, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // exit intro
m = mi.(Model)
t.Logf("charIdx before: %d", m.charIdx)
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(".")})
m = mi.(Model)
t.Logf("charIdx after '.': %d", m.charIdx)
if m.charIdx != 1 {
t.Fatalf("expected charIdx=1 after '.', got %d", m.charIdx)
}
mi, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(",")})
m = mi.(Model)
t.Logf("charIdx after ',': %d", m.charIdx)
if m.charIdx != 0 {
t.Fatalf("expected charIdx=0 after ',', got %d", m.charIdx)
}
}