Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 additions & 1 deletion internal/themes/hex_editor_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,136 @@
package themes

import "testing"
import (
"strconv"
"strings"
"testing"

"github.com/gdamore/tcell/v2"
"github.com/namest504/termtype/internal/domain"
"github.com/namest504/termtype/internal/ui"
)

// gridRenderer is a screen-capturing fake implementing domain.Renderer: it
// keeps the full cell grid so tests can read back what a theme drew. Unlike
// mockRenderer (responsive_test.go), which only reports size, this one
// actually records content. Every string this theme writes is single-byte
// ASCII (hex digits, ".", printable bytes), so a naive one-rune-per-cell
// write is faithful to what the real tcell renderer would do here.
type gridRenderer struct {
w, h int
grid [][]rune
}

func newGridRenderer(w, h int) *gridRenderer {
g := &gridRenderer{w: w, h: h}
g.grid = make([][]rune, h)
for y := range g.grid {
g.grid[y] = make([]rune, w)
}
g.Clear()
return g
}

func (g *gridRenderer) Clear() {
for y := 0; y < g.h; y++ {
for x := 0; x < g.w; x++ {
g.grid[y][x] = ' '
}
}
}
func (g *gridRenderer) DrawText(x, y int, style tcell.Style, text string) {
for i, r := range []rune(text) {
g.SetContent(x+i, y, r, style)
}
}
func (g *gridRenderer) DrawRune(x, y int, r rune, style tcell.Style) int {
g.SetContent(x, y, r, style)
return 1
}
func (g *gridRenderer) Show() {}
func (g *gridRenderer) Size() (int, int) { return g.w, g.h }
func (g *gridRenderer) SetContent(x, y int, r rune, style tcell.Style) {
if x >= 0 && x < g.w && y >= 0 && y < g.h {
g.grid[y][x] = r
}
}
func (g *gridRenderer) HideCursor() {}
func (g *gridRenderer) ShowCursor(x, y int) {}

// TestHexResultEncodesStats verifies the result screen encodes the round's
// stats as a real dump row right below the target, instead of overlapping an
// existing background row: (1) the stat text appears in the ascii gutter,
// (2) the hex columns on that row parse back to the stat string's UTF-8
// bytes, and (3) nothing bleeds through past the stat content on that row —
// i.e. the row was cleared, not drawn over.
func TestHexResultEncodesStats(t *testing.T) {
theme := &HexTheme{}
w, h := 100, 30

gs := &domain.GameState{Sentences: []string{"hi"}}
theme.ResetState(gs)
gs.TargetSentence = "hi" // short + deterministic: exactly one target dump row
gs.IsFinished = true
gs.WPM = 61.2
gs.Accuracy = 97.5
gs.FinalDurS = 12

r := newGridRenderer(w, h)
theme.UpdateScreen(r, gs)

stats := []byte(ui.ResultText(gs))
targetRows := (len([]byte(gs.TargetSentence)) + 15) / 16
startRow := h/2 + targetRows + 1 // StartLine (h/2) + target rows + one blank row
if startRow >= h {
t.Fatalf("test setup: expected result row %d falls off screen height %d", startRow, h)
}
chunk := stats[:min(len(stats), 16)]

// (1) stat text appears in the ascii gutter (x=62..77)
asciiLine := string(r.grid[startRow][62:78])
if !strings.Contains(asciiLine, "wpm") {
t.Fatalf("ascii gutter row %d = %q, want it to contain \"wpm\"", startRow, asciiLine)
}

// (2) hex columns (x=10..) parse back to the stat string's UTF-8 bytes
for i, want := range chunk {
hi, lo := r.grid[startRow][10+i*3], r.grid[startRow][10+i*3+1]
got, err := strconv.ParseUint(string(hi)+string(lo), 16, 8)
if err != nil {
t.Fatalf("row %d byte %d: hex cell %q%q does not parse: %v", startRow, i, hi, lo, err)
}
if byte(got) != want {
t.Errorf("row %d byte %d = %#02x, want %#02x (stats %q)", startRow, i, byte(got), want, string(chunk))
}
}

// ascii gutter should be the matching byte-for-byte translation too
// (printable ASCII as itself, everything else -- including multibyte
// UTF-8 continuation bytes -- as '.').
wantAscii := make([]rune, len(chunk))
for i, b := range chunk {
if b >= 32 && b <= 126 {
wantAscii[i] = rune(b)
} else {
wantAscii[i] = '.'
}
}
if got := string(r.grid[startRow][62 : 62+len(chunk)]); got != string(wantAscii) {
t.Errorf("row %d ascii gutter = %q, want %q", startRow, got, string(wantAscii))
}

// (3) no leftover background bytes after the stat content on this row
for x := 62 + len(chunk); x < 78; x++ {
if r.grid[startRow][x] != ' ' {
t.Errorf("row %d ascii cell x=%d = %q, want cleared space (background bleed-through)", startRow, x, r.grid[startRow][x])
}
}
for x := 10 + len(chunk)*3; x < 10+16*3; x++ {
if r.grid[startRow][x] != ' ' {
t.Errorf("row %d hex cell x=%d = %q, want cleared space (background bleed-through)", startRow, x, r.grid[startRow][x])
}
}
}

func TestHexWindow(t *testing.T) {
cases := []struct {
Expand Down
39 changes: 35 additions & 4 deletions internal/themes/hex_editor_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (t *HexTheme) UpdateScreen(renderer domain.Renderer, gs *domain.GameState)
winStart := t.drawTarget(renderer, gs, state, h)

if gs.IsFinished {
t.drawResult(renderer, gs, h)
t.drawResult(renderer, gs, state, h)
} else {
t.drawCursor(renderer, gs, state, winStart)
}
Expand Down Expand Up @@ -171,10 +171,41 @@ func (t *HexTheme) drawCursor(renderer domain.Renderer, gs *domain.GameState, st
renderer.ShowCursor(62+byteIdx%16, state.StartLine+row)
}

func (t *HexTheme) drawResult(renderer domain.Renderer, gs *domain.GameState, h int) {
// drawResult writes the round's stats INTO the dump as real encoded rows:
// the stat string's UTF-8 bytes appear in the hex columns and the string in
// the ascii gutter, highlighted, right below the target rows.
func (t *HexTheme) drawResult(renderer domain.Renderer, gs *domain.GameState, state *HexThemeState, h int) {
renderer.HideCursor()
resultText := ui.ResultText(gs)
renderer.DrawText(0, h-1, tcell.StyleDefault, resultText)
statStyle := tcell.StyleDefault.Foreground(tcell.ColorGreen)
addrStyle := tcell.StyleDefault.Foreground(tcell.ColorBlue)

stats := []byte(ui.ResultText(gs))
targetRows := (len([]byte(gs.TargetSentence)) + 15) / 16
startRow := state.StartLine + targetRows + 1 // one blank dump row of breathing room

for r := 0; r*16 < len(stats); r++ {
y := startRow + r
if y >= h {
break
}
chunk := stats[r*16 : min(len(stats), r*16+16)]
// clear the whole dump row first so no background bytes bleed through
for x := 0; x < 62+16; x++ {
renderer.SetContent(x, y, ' ', tcell.StyleDefault)
}
renderer.DrawText(0, y, addrStyle, fmt.Sprintf("%08x", (startRow+r)*16))
hexStr, asciiStr := "", ""
for _, b := range chunk {
hexStr += fmt.Sprintf("%02x ", b)
if b >= 32 && b <= 126 {
asciiStr += string(rune(b))
} else {
asciiStr += "."
}
}
renderer.DrawText(10, y, statStyle, hexStr)
renderer.DrawText(62, y, statStyle, asciiStr)
}
}

// OnTick flips a few background bytes, like memory being written.
Expand Down
41 changes: 30 additions & 11 deletions internal/themes/matrix_theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,26 @@ func matrixMaxLines(h, startY int) int {
return maxLines
}

// clearBand blanks rows [top, bottom] so the text sits in a readable
// clearing instead of on top of the rain.
func clearBand(renderer domain.Renderer, w, top, bottom, h int) {
for y := top; y <= bottom; y++ {
if y < 0 || y >= h {
continue
}
for x := 0; x < w; x++ {
renderer.SetContent(x, y, ' ', tcell.StyleDefault.Background(tcell.ColorBlack))
}
}
}

func (t *MatrixTheme) drawTypingArea(renderer domain.Renderer, gs *domain.GameState, w, h, startY int) {
rows := len(ui.WrapText(gs.TargetSentence, w-4))
if cap := matrixMaxLines(h, startY); rows > cap {
rows = cap
}
clearBand(renderer, w, startY-1, startY+rows, h)

tr := &ui.TypingRenderer{}
tr.Draw(renderer, gs, ui.TypingRendererOptions{
StartY: startY,
Expand All @@ -129,19 +148,19 @@ func (t *MatrixTheme) drawTypingArea(renderer domain.Renderer, gs *domain.GameSt
}

func (t *MatrixTheme) drawResultArea(renderer domain.Renderer, gs *domain.GameState, w, h, startY int) {
// Recompute how many rows the typing window occupied.
rows := len(ui.WrapText(gs.TargetSentence, w-4))
if cap := matrixMaxLines(h, startY); rows > cap {
rows = cap
}

renderer.HideCursor()
resultText := ui.ResultText(gs)
x := (w - runewidth.StringWidth(resultText)) / 2
if x < 0 {
x = 0
title := "TRACE COMPLETE"
stats := ui.ResultText(gs)
clearBand(renderer, w, startY-1, startY+3, h)
center := func(y int, style tcell.Style, s string) {
x := (w - runewidth.StringWidth(s)) / 2
if x < 0 {
x = 0
}
renderer.DrawText(x, y, style, ui.Truncate(s, w))
}
renderer.DrawText(x, startY+rows+1, tcell.StyleDefault.Background(tcell.ColorBlack), resultText)
center(startY, tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true), title)
center(startY+2, tcell.StyleDefault.Foreground(tcell.ColorWhite), stats)
}

func (t *MatrixTheme) OnTick(gs *domain.GameState) {
Expand Down
103 changes: 103 additions & 0 deletions internal/themes/matrix_theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package themes

import (
"strings"
"testing"

"github.com/namest504/termtype/internal/domain"
"github.com/namest504/termtype/internal/ui"
)

// TestMatrixTypingClearing verifies the sentence band sits in a readable
// clearing: the row just above the wrapped sentence and the row just below
// it must be entirely blank, even though the rain is animating underneath.
func TestMatrixTypingClearing(t *testing.T) {
theme := &MatrixTheme{}
w, h := 80, 24

gs := &domain.GameState{Sentences: []string{"hello world"}}
theme.ResetState(gs)
gs.TargetSentence = "hello world"

r := newGridRenderer(w, h)
// Rain drops initialize on the first UpdateScreen call, so the screen
// size must already be set before that call. Then mirror the real game
// loop's Update -> Tick -> Update order.
theme.UpdateScreen(r, gs)
theme.OnTick(gs)
theme.UpdateScreen(r, gs)

startY := h/2 - 2
rows := len(ui.WrapText(gs.TargetSentence, w-4))
if cap := matrixMaxLines(h, startY); rows > cap {
rows = cap
}

top := startY - 1
bottom := startY + rows
for _, y := range []int{top, bottom} {
if y < 0 || y >= h {
continue
}
for x := 0; x < w; x++ {
if r.grid[y][x] != ' ' {
t.Errorf("row %d (clearing band edge) cell x=%d = %q, want blank", y, x, r.grid[y][x])
}
}
}
}

// TestMatrixResultPanel verifies the finished-state panel shows "TRACE
// COMPLETE" and the result stats, and that the rain is still alive outside
// the cleared panel band.
func TestMatrixResultPanel(t *testing.T) {
theme := &MatrixTheme{}
w, h := 80, 24

gs := &domain.GameState{Sentences: []string{"hello world"}}
theme.ResetState(gs)
gs.TargetSentence = "hello world"
gs.IsFinished = true
gs.WPM = 61.2
gs.Accuracy = 97.5
gs.FinalDurS = 12

r := newGridRenderer(w, h)
theme.UpdateScreen(r, gs)
theme.OnTick(gs)
theme.UpdateScreen(r, gs)

startY := h/2 - 2

titleLine := string(r.grid[startY])
if !strings.Contains(titleLine, "TRACE COMPLETE") {
t.Errorf("row %d = %q, want it to contain %q", startY, titleLine, "TRACE COMPLETE")
}

statsLine := string(r.grid[startY+2])
stats := ui.ResultText(gs)
if !strings.Contains(statsLine, stats) {
t.Errorf("row %d = %q, want it to contain %q", startY+2, statsLine, stats)
}

// Outside the panel band ([startY-1, startY+3]) the rain should still be
// alive: at least one non-blank cell somewhere else on the screen.
found := false
for y := 0; y < h; y++ {
if y >= startY-1 && y <= startY+3 {
continue
}
for x := 0; x < w; x++ {
if r.grid[y][x] != ' ' {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("expected non-blank rain cells outside the result panel band, found none")
}
}
Loading