diff --git a/README.md b/README.md index 9381c78..cf5d6c3 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,8 @@ termtype The menu opens on the `cozy` theme and remembers your selections for the next launch: -- `↑`/`↓` — theme -- `Tab` — mode: Normal, or Time Attack (15s / 30s / 60s) -- `Space` — text: built-in sentences, or a stream of common English words -- `←`/`→` — language: English or Korean (한국어; the words stream is - English-only for now) -- `g` — result graph on/off +- `←`/`→` — theme; `↓` unfolds the full theme list +- `s` — settings: mode, text source, language, result graph, graph style - `h` — history browser - `Enter` — start, `Esc` — quit @@ -65,8 +61,11 @@ Attack) sits in the top-right corner on every theme. `Ctrl-P` pauses, TermType samples your WPM once a second. When a round ends, a WPM-over-time graph pops up with an accuracy/raw/cpm summary; `g` toggles back to the theme's own result screen. The `cozy` theme draws the chart -right on its result screen instead. Turn the automatic graph off from the -menu (`g` — `Graph: Off`) and it stays on the `g` key only. +right on its result screen instead. Turn the automatic graph off from +settings (`s` — `Graph: Off`) and it stays on the `g` key only. + +Pick the curve's look in settings (`s` — `Style`): a braille wave at 1–3px +thickness, or a solid box-drawing line. ## History & personal bests diff --git a/cmd/termtype/draw_test.go b/cmd/termtype/draw_test.go new file mode 100644 index 0000000..fb662ed --- /dev/null +++ b/cmd/termtype/draw_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/store" +) + +// newSimScreen returns an initialized in-memory tcell screen. +func newSimScreen(t *testing.T, w, h int) tcell.SimulationScreen { + t.Helper() + s := tcell.NewSimulationScreen("UTF-8") + if err := s.Init(); err != nil { + t.Fatalf("init simulation screen: %v", err) + } + s.SetSize(w, h) + t.Cleanup(s.Fini) + return s +} + +// screenString flattens the screen contents into one searchable string, +// one row per line. +func screenString(s tcell.SimulationScreen) string { + cells, w, h := s.GetContents() + var b strings.Builder + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + c := cells[y*w+x] + if len(c.Runes) > 0 { + b.WriteRune(c.Runes[0]) + } else { + b.WriteRune(' ') + } + } + b.WriteRune('\n') + } + return b.String() +} + +func wantOnScreen(t *testing.T, s tcell.SimulationScreen, substrs ...string) { + t.Helper() + dump := screenString(s) + for _, sub := range substrs { + if !strings.Contains(dump, sub) { + t.Errorf("screen missing %q; dump:\n%s", sub, dump) + } + } +} + +func TestDrawMenu(t *testing.T) { + t.Run("collapsed shows carousel and summary", func(t *testing.T) { + s := newSimScreen(t, 80, 24) + m := newMenuModel("cozy") + drawMenu(s, m, "Normal · Sentences · English") + wantOnScreen(t, s, "termtype", "cozy", "Normal · Sentences · English", "start") + }) + t.Run("expanded lists every theme", func(t *testing.T) { + s := newSimScreen(t, 80, 24) + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) + drawMenu(s, m, "Normal · Sentences · English") + wantOnScreen(t, s, sortedThemeNames()...) + }) + t.Run("narrow terminal does not panic", func(t *testing.T) { + s := newSimScreen(t, 20, 10) + m := newMenuModel("cozy") + drawMenu(s, m, "Normal · Sentences · English") + wantOnScreen(t, s, "termtype") + }) +} + +func TestDrawSettings(t *testing.T) { + t.Run("shows all five rows", func(t *testing.T) { + s := newSimScreen(t, 80, 24) + drawSettings(s, newSettingsModel(store.Config{})) + wantOnScreen(t, s, "Settings", "Mode", "Text", "Language", "Graph", "Style", "braille") + }) + t.Run("narrow terminal does not panic", func(t *testing.T) { + s := newSimScreen(t, 20, 10) + drawSettings(s, newSettingsModel(store.Config{})) + wantOnScreen(t, s, "Settings") + }) +} + +func TestDrawRoundDetail(t *testing.T) { + round := store.Round{ + TS: time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC), + Theme: "cozy", Mode: "normal", Lang: "en", Source: "builtin", + WPM: 72.4, Acc: 98.5, DurS: 30, + WPMSeries: []float64{40, 55, 60, 72, 70}, + } + t.Run("with series draws graph and summary", func(t *testing.T) { + s := newSimScreen(t, 80, 24) + drawRoundDetail(s, round, 80, 24) + s.Show() + wantOnScreen(t, s, "wpm: 72", "accuracy: 98.5") + }) + t.Run("short terminal skips the chart", func(t *testing.T) { + s := newSimScreen(t, 40, 8) + drawRoundDetail(s, round, 40, 8) + s.Show() + wantOnScreen(t, s, "wpm: 72") + }) +} diff --git a/cmd/termtype/loop_test.go b/cmd/termtype/loop_test.go new file mode 100644 index 0000000..6c856cf --- /dev/null +++ b/cmd/termtype/loop_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "testing" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/store" +) + +// feedKeys returns a buffered event channel pre-loaded with the given key +// events. The loops under test consume them in order; the channel is left +// open (loops exit via their own key handling, not channel close). +func feedKeys(keys ...*tcell.EventKey) chan tcell.Event { + ch := make(chan tcell.Event, len(keys)) + for _, k := range keys { + ch <- k + } + return ch +} + +// mustTS parses an RFC3339 timestamp, failing the test on error. +func mustTS(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return ts +} + +func TestRunMenuQuitsOnEsc(t *testing.T) { + s := newSimScreen(t, 80, 24) + cfg := store.Config{} + if _, err := runMenu(s, feedKeys(key(tcell.KeyEscape)), &cfg, store.New(t.TempDir())); err == nil { + t.Fatal("Esc should return an error (menu cancelled), got nil") + } +} + +func TestRunMenuStartReturnsSelection(t *testing.T) { + s := newSimScreen(t, 80, 24) + cfg := store.Config{Theme: "log", Mode: "ta15", Graph: "off"} + sel, err := runMenu(s, feedKeys(key(tcell.KeyEnter)), &cfg, store.New(t.TempDir())) + if err != nil { + t.Fatalf("Enter should start, got error %v", err) + } + if sel.themeName != "log" { + t.Errorf("themeName = %q, want log", sel.themeName) + } + if got := store.ModeString(sel.limit); got != "ta15" { + t.Errorf("mode = %q, want ta15", got) + } + if sel.graphOn { + t.Error("graphOn = true, want false (config graph off)") + } +} + +func TestRunMenuCarouselPicksTheme(t *testing.T) { + s := newSimScreen(t, 80, 24) + cfg := store.Config{Theme: "cozy"} + // ↓ expand, ↓ move to second theme, Enter select (collapse), Enter start. + sel, err := runMenu(s, feedKeys( + key(tcell.KeyDown), key(tcell.KeyDown), key(tcell.KeyEnter), key(tcell.KeyEnter), + ), &cfg, store.New(t.TempDir())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := sortedThemeNames()[1]; sel.themeName != want { + t.Errorf("themeName = %q, want %q", sel.themeName, want) + } +} + +func TestRunMenuSettingsRoundTrip(t *testing.T) { + s := newSimScreen(t, 80, 24) + dir := t.TempDir() + st := store.New(dir) + cfg := store.Config{} + // s → settings, → (mode to ta15), Esc → back to menu, Esc → quit. + _, err := runMenu(s, feedKeys( + rkey('s'), key(tcell.KeyRight), key(tcell.KeyEscape), key(tcell.KeyEscape), + ), &cfg, st) + if err == nil { + t.Fatal("final Esc should cancel the menu") + } + if cfg.Mode != "ta15" { + t.Errorf("cfg.Mode = %q, want ta15 (settings change must mutate cfg)", cfg.Mode) + } + if saved := st.LoadConfig(); saved.Mode != "ta15" { + t.Errorf("saved config Mode = %q, want ta15 (change must persist immediately)", saved.Mode) + } +} + +func TestRunSettingsSavesEachChange(t *testing.T) { + s := newSimScreen(t, 80, 24) + dir := t.TempDir() + st := store.New(dir) + cfg := store.Config{} + runSettings(s, feedKeys( + key(tcell.KeyDown), key(tcell.KeyDown), key(tcell.KeyDown), key(tcell.KeyDown), // row → Style + key(tcell.KeyRight), // braille2 → braille3 + key(tcell.KeyEscape), + ), &cfg, st) + if cfg.Style != "braille3" { + t.Errorf("cfg.Style = %q, want braille3", cfg.Style) + } + if saved := st.LoadConfig(); saved.Style != "braille3" { + t.Errorf("saved Style = %q, want braille3", saved.Style) + } +} + +func TestShowHistory(t *testing.T) { + rounds := []store.Round{ + {TS: mustTS(t, "2026-08-19T10:00:00Z"), Theme: "cozy", Mode: "normal", Lang: "en", Source: "builtin", WPM: 60, Acc: 97, DurS: 20, WPMSeries: []float64{50, 60}}, + {TS: mustTS(t, "2026-08-20T10:00:00Z"), Theme: "log", Mode: "ta15", Lang: "en", Source: "words", WPM: 70, Acc: 99, DurS: 15}, + } + t.Run("empty history escapes cleanly", func(t *testing.T) { + s := newSimScreen(t, 80, 24) + showHistory(s, feedKeys(key(tcell.KeyEscape)), nil) + }) + t.Run("detail and back", func(t *testing.T) { + s := newSimScreen(t, 80, 24) + showHistory(s, feedKeys( + key(tcell.KeyDown), key(tcell.KeyEnter), // open detail of older round + key(tcell.KeyEscape), key(tcell.KeyEscape), // back to list, then out + ), rounds) + }) +} diff --git a/cmd/termtype/main.go b/cmd/termtype/main.go index 96b4b23..e4c62ff 100644 --- a/cmd/termtype/main.go +++ b/cmd/termtype/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "sort" "strings" "time" @@ -103,16 +102,7 @@ func drawText(s tcell.Screen, x, y int, style tcell.Style, text string) { // chartOptionsFor maps a config style code onto chart options. Unknown // codes fall back to the default so an edited config never breaks startup. func chartOptionsFor(code string) chart.Options { - o := chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2} - switch code { - case "braille1": - o.Thickness = 1 - case "braille3": - o.Thickness = 3 - case "box": - o.Style, o.Thickness = chart.StyleBox, 1 - } - return o + return chartStyles[styleIdxFor(code)].opts } // selection is everything the menu picks: the theme (and its registry name, @@ -136,128 +126,52 @@ func indexOf(n int, match func(int) bool) int { return 0 } -func selectTheme(s tcell.Screen, events <-chan tcell.Event, cfg store.Config, st *store.Store) (selection, error) { - var themeNames []string - for name := range themes.Themes { - themeNames = append(themeNames, name) - } - // cozy leads (the default), log keeps second place, the rest follow - // alphabetically. - rank := func(name string) int { - switch name { - case "cozy": - return 0 - case "log": - return 1 - } - return 2 - } - sort.Slice(themeNames, func(i, j int) bool { - if ri, rj := rank(themeNames[i]), rank(themeNames[j]); ri != rj { - return ri < rj - } - return themeNames[i] < themeNames[j] - }) - - // Start from the remembered selections; zero-value config lands on 0s. - selectedIndex := indexOf(len(themeNames), func(i int) bool { return themeNames[i] == cfg.Theme }) - modeIndex := indexOf(len(gameModes), func(i int) bool { return store.ModeString(gameModes[i].limit) == cfg.Mode }) - srcIndex := indexOf(len(textSources), func(i int) bool { return textSources[i].code == cfg.Source }) - langIndex := indexOf(len(languages), func(i int) bool { return languages[i].code == cfg.Lang }) - graphOn := cfg.GraphAuto() - +// runMenu is the menu ↔ settings/history hub. It returns the round +// selection on Enter, or an error when the player quits. +func runMenu(s tcell.Screen, events <-chan tcell.Event, cfg *store.Config, st *store.Store) (selection, error) { + m := newMenuModel(cfg.Theme) for { - s.Clear() - drawText(s, 2, 1, tcell.StyleDefault.Bold(true), "Select a theme:") - - for i, name := range themeNames { - style := tcell.StyleDefault - if i == selectedIndex { - style = style.Reverse(true) - } - drawText(s, 4, 3+i, style, name) - } - - gl := ui.Glyphs() - w, _ := s.Size() - modeRow := 3 + len(themeNames) + 1 - drawText(s, 2, modeRow, tcell.StyleDefault.Foreground(tcell.ColorYellow), - "Mode: "+gameModes[modeIndex].name) - drawText(s, 2, modeRow+1, tcell.StyleDefault.Foreground(tcell.ColorGreen), - "Text: "+textSources[srcIndex].name) - // The words pool is English-only, so the language row pins to English - // while Words is selected. - langLabel := "Language: " + languages[langIndex].name - if textSources[srcIndex].code == "words" { - langLabel = "Language: English" - } - drawText(s, 2, modeRow+2, tcell.StyleDefault.Foreground(tcell.ColorTeal), langLabel) - graphLabel := "Graph: On" - if !graphOn { - graphLabel = "Graph: Off" - } - drawText(s, 2, modeRow+3, tcell.StyleDefault.Foreground(tcell.ColorPurple), graphLabel) - - // Pick the widest help line that fits the terminal. - sep := " " + gl.Sep + " " - full := strings.Join([]string{ - gl.ArrowUD + " theme", "Tab mode", "Space text", gl.ArrowLR + " language", - "g graph", "h history", gl.Enter + " start", "Esc quit", - }, sep) - compact := strings.Join([]string{gl.ArrowUD + " theme", "Tab mode", "Space text"}, " ") - help := full - if runewidth.StringWidth(help) > w-2 { - help = compact - } - if runewidth.StringWidth(help) > w-2 { - help = gl.Enter + " start" - } - drawText(s, 2, modeRow+5, tcell.StyleDefault.Foreground(tcell.ColorGray), help) - s.Show() - - ev := <-events - switch ev := ev.(type) { + drawMenu(s, m, summaryLine(*cfg)) + switch ev := (<-events).(type) { case nil: return selection{}, fmt.Errorf("screen closed") case *tcell.EventResize: s.Sync() case *tcell.EventKey: - switch ev.Key() { - case tcell.KeyEscape, tcell.KeyCtrlC: - return selection{}, fmt.Errorf("theme selection cancelled") - case tcell.KeyUp: - if selectedIndex > 0 { - selectedIndex-- - } - case tcell.KeyDown: - if selectedIndex < len(themeNames)-1 { - selectedIndex++ - } - case tcell.KeyTab: - modeIndex = (modeIndex + 1) % len(gameModes) - case tcell.KeyLeft, tcell.KeyRight: - if textSources[srcIndex].code != "words" { - langIndex = (langIndex + 1) % len(languages) - } - case tcell.KeyRune: - switch ev.Rune() { - case ' ': - srcIndex = (srcIndex + 1) % len(textSources) - case 'g', 'G': - graphOn = !graphOn - case 'h', 'H': - showHistory(s, events, st.LoadHistory()) - } - case tcell.KeyEnter: - name := themeNames[selectedIndex] - return selection{theme: themes.Themes[name], themeName: name, - limit: gameModes[modeIndex].limit, src: textSources[srcIndex], - lang: languages[langIndex], graphOn: graphOn}, nil + switch m.handleKey(ev) { + case actQuit: + return selection{}, fmt.Errorf("menu cancelled") + case actSettings: + runSettings(s, events, cfg, st) + case actHistory: + showHistory(s, events, st.LoadHistory()) + case actStart: + name := m.themes[m.idx] + sm := newSettingsModel(*cfg) + return selection{ + theme: themes.Themes[name], + themeName: name, + limit: gameModes[sm.modeIdx].limit, + src: textSources[sm.srcIdx], + lang: languages[sm.langIdx], + graphOn: cfg.GraphAuto(), + }, nil } } } } +// summaryLine is the read-only settings recap under the carousel. +func summaryLine(cfg store.Config) string { + sm := newSettingsModel(cfg) + lang := languages[sm.langIdx].name + if textSources[sm.srcIdx].code == "words" { + lang = "English" + } + sep := " " + ui.Glyphs().Sep + " " + return gameModes[sm.modeIdx].name + sep + textSources[sm.srcIdx].name + sep + lang +} + func main() { versionFlag := flag.Bool("version", false, "Print version information") vFlag := flag.Bool("v", false, "Print version information (shorthand)") @@ -307,18 +221,13 @@ func main() { cfg := st.LoadConfig() ui.SetChartOptions(chartOptionsFor(cfg.ChartStyle())) for { - sel, err := selectTheme(s, events, cfg, st) + sel, err := runMenu(s, events, &cfg, st) if err != nil { return // menu cancelled; the deferred Fini restores the terminal } - // Remember the selections for the next launch. - cfg.Theme, cfg.Mode = sel.themeName, store.ModeString(sel.limit) - cfg.Source, cfg.Lang = sel.src.code, sel.lang.code - cfg.Graph = "on" - if !sel.graphOn { - cfg.Graph = "off" - } + // Settings save on change; only the theme needs saving here. + cfg.Theme = sel.themeName st.SaveConfig(cfg) // The words source replaces the sentence pool with a generated stream: diff --git a/cmd/termtype/main_test.go b/cmd/termtype/main_test.go index 441b43a..453052c 100644 --- a/cmd/termtype/main_test.go +++ b/cmd/termtype/main_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/namest504/termtype/internal/chart" + "github.com/namest504/termtype/internal/store" + "github.com/namest504/termtype/internal/ui" ) func TestChartOptionsFor(t *testing.T) { @@ -25,3 +27,59 @@ func TestChartOptionsFor(t *testing.T) { } } } + +func TestSummaryLine(t *testing.T) { + ui.SetASCII(false) + cases := []struct { + name string + cfg store.Config + want string + }{ + {"zero config defaults", store.Config{}, "Normal · Sentences · English"}, + {"time attack korean", store.Config{Mode: "ta30", Lang: "ko"}, "Time Attack (30s) · Sentences · 한국어 (Korean)"}, + {"words pins english", store.Config{Source: "words", Lang: "ko"}, "Normal · Words · English"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := summaryLine(tc.cfg); got != tc.want { + t.Errorf("summaryLine(%+v) = %q, want %q", tc.cfg, got, tc.want) + } + }) + } +} + +func TestResolveASCII(t *testing.T) { + clear := func(t *testing.T) { + t.Helper() + for _, k := range []string{"TERMTYPE_ASCII", "LC_ALL", "LC_CTYPE", "LANG"} { + t.Setenv(k, "") + } + } + cases := []struct { + name string + flagSet bool + env map[string]string + want bool + }{ + {"explicit flag wins", true, map[string]string{"LANG": "en_US.UTF-8"}, true}, + {"env var on", false, map[string]string{"TERMTYPE_ASCII": "1"}, true}, + {"env var off beats non-utf8 locale", false, map[string]string{"TERMTYPE_ASCII": "off", "LANG": "C"}, false}, + {"invalid env falls through to locale", false, map[string]string{"TERMTYPE_ASCII": "banana", "LANG": "C"}, true}, + {"lc_all beats lang", false, map[string]string{"LC_ALL": "en_US.UTF-8", "LANG": "C"}, false}, + {"lc_ctype beats lang", false, map[string]string{"LC_CTYPE": "C", "LANG": "en_US.UTF-8"}, true}, + {"posix locale is ascii", false, map[string]string{"LANG": "POSIX"}, true}, + {"utf8 without dash", false, map[string]string{"LANG": "ko_KR.utf8"}, false}, + {"no locale assumes utf8", false, nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + clear(t) + for k, v := range tc.env { + t.Setenv(k, v) + } + if got := resolveASCII(tc.flagSet); got != tc.want { + t.Errorf("resolveASCII(%v) with %v = %v, want %v", tc.flagSet, tc.env, got, tc.want) + } + }) + } +} diff --git a/cmd/termtype/menu.go b/cmd/termtype/menu.go new file mode 100644 index 0000000..4763f0c --- /dev/null +++ b/cmd/termtype/menu.go @@ -0,0 +1,161 @@ +package main + +import ( + "sort" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/mattn/go-runewidth" + "github.com/namest504/termtype/internal/themes" + "github.com/namest504/termtype/internal/ui" +) + +// sortedThemeNames returns the registry names in menu order: cozy leads +// (the default), log keeps second place, the rest follow alphabetically. +func sortedThemeNames() []string { + var names []string + for name := range themes.Themes { + names = append(names, name) + } + rank := func(name string) int { + switch name { + case "cozy": + return 0 + case "log": + return 1 + } + return 2 + } + sort.Slice(names, func(i, j int) bool { + if ri, rj := rank(names[i]), rank(names[j]); ri != rj { + return ri < rj + } + return names[i] < names[j] + }) + return names +} + +// menuAction is what a key press asks the menu loop to do. +type menuAction int + +const ( + actNone menuAction = iota + actStart + actSettings + actHistory + actQuit +) + +// menuModel is the main-menu state: a theme carousel that can expand into +// a full list. Drawing is separate so transitions are unit-testable. +type menuModel struct { + themes []string + idx int // carousel position (the picked theme) + expanded bool // theme list unfolded below the carousel + sel int // list selection while expanded +} + +func newMenuModel(cfgTheme string) menuModel { + names := sortedThemeNames() + return menuModel{ + themes: names, + idx: indexOf(len(names), func(i int) bool { return names[i] == cfgTheme }), + } +} + +func (m *menuModel) handleKey(ev *tcell.EventKey) menuAction { + if m.expanded { + switch ev.Key() { + case tcell.KeyUp: + if m.sel > 0 { + m.sel-- + } + case tcell.KeyDown: + if m.sel < len(m.themes)-1 { + m.sel++ + } + case tcell.KeyEnter: + m.idx = m.sel + m.expanded = false + case tcell.KeyEscape: + m.expanded = false + case tcell.KeyCtrlC: + return actQuit + } + return actNone + } + switch ev.Key() { + case tcell.KeyLeft: + m.idx = cycleIdx(m.idx, -1, len(m.themes)) + case tcell.KeyRight: + m.idx = cycleIdx(m.idx, 1, len(m.themes)) + case tcell.KeyDown: + m.expanded, m.sel = true, m.idx + case tcell.KeyEnter: + return actStart + case tcell.KeyEscape, tcell.KeyCtrlC: + return actQuit + case tcell.KeyRune: + switch ev.Rune() { + case 's', 'S': + return actSettings + case 'h', 'H': + return actHistory + } + } + return actNone +} + +// drawMenu renders the carousel main screen; summary is the read-only +// "Mode · Text · Language" line built by the caller from config. +func drawMenu(s tcell.Screen, m menuModel, summary string) { + s.Clear() + w, _ := s.Size() + gl := ui.Glyphs() + centered := func(y int, style tcell.Style, text string) { + x := (w - runewidth.StringWidth(text)) / 2 + if x < 0 { + x = 0 + } + drawText(s, x, y, style, ui.Truncate(text, w)) + } + + centered(1, tcell.StyleDefault.Bold(true), "termtype") + + l, r := "‹", "›" + if ui.IsASCII() { + l, r = "<", ">" + } + centered(3, tcell.StyleDefault.Reverse(true), " "+l+" "+m.themes[m.idx]+" "+r+" ") + centered(5, tcell.StyleDefault.Foreground(tcell.ColorGray), summary) + + helpY := 7 + if m.expanded { + for i, name := range m.themes { + style := tcell.StyleDefault + if i == m.sel { + style = style.Reverse(true) + } + centered(7+i, style, " "+name+" ") + } + helpY = 7 + len(m.themes) + 1 + centered(helpY, tcell.StyleDefault.Foreground(tcell.ColorGray), + gl.ArrowUD+" pick "+gl.Sep+" "+gl.Enter+" select "+gl.Sep+" Esc close") + s.Show() + return + } + + full := strings.Join([]string{ + gl.Enter + " start", "s settings", "h history", "Esc quit", + }, " "+gl.Sep+" ") + compact := gl.Enter + " start " + gl.Sep + " s settings" + help := full + if runewidth.StringWidth(help) > w-2 { + help = compact + } + if runewidth.StringWidth(help) > w-2 { + help = gl.Enter + " start" + } + centered(helpY, tcell.StyleDefault.Foreground(tcell.ColorGray), help) + s.Show() +} diff --git a/cmd/termtype/menu_test.go b/cmd/termtype/menu_test.go new file mode 100644 index 0000000..b34b17d --- /dev/null +++ b/cmd/termtype/menu_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func rkey(r rune) *tcell.EventKey { return tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone) } + +func TestSortedThemesCozyFirst(t *testing.T) { + names := sortedThemeNames() + if len(names) < 3 || names[0] != "cozy" || names[1] != "log" { + t.Fatalf("theme order wrong: %v", names) + } +} + +func TestCarouselWraps(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyLeft)) + if m.idx != len(m.themes)-1 { + t.Fatalf("left from first should wrap to last, got %d", m.idx) + } + m.handleKey(key(tcell.KeyRight)) + if m.idx != 0 { + t.Fatalf("right should wrap back to first, got %d", m.idx) + } +} + +func TestExpandSelectCollapse(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) + if !m.expanded || m.sel != m.idx { + t.Fatal("down should expand with selection on current theme") + } + m.handleKey(key(tcell.KeyDown)) // move selection + m.handleKey(key(tcell.KeyEnter)) + if m.expanded || m.idx != 1 { + t.Fatalf("enter should pick sel and collapse, idx=%d expanded=%v", m.idx, m.expanded) + } +} + +func TestExpandedEscCollapsesWithoutQuit(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) + if act := m.handleKey(key(tcell.KeyEscape)); act != actNone || m.expanded { + t.Fatalf("esc while expanded should just collapse, got act=%v", act) + } + if act := m.handleKey(key(tcell.KeyEscape)); act != actQuit { + t.Fatalf("esc while collapsed should quit, got %v", act) + } +} + +func TestMenuActions(t *testing.T) { + m := newMenuModel("cozy") + if act := m.handleKey(key(tcell.KeyEnter)); act != actStart { + t.Fatalf("enter → start, got %v", act) + } + if act := m.handleKey(rkey('s')); act != actSettings { + t.Fatalf("s → settings, got %v", act) + } + if act := m.handleKey(rkey('h')); act != actHistory { + t.Fatalf("h → history, got %v", act) + } +} + +func TestRestoresSavedTheme(t *testing.T) { + names := sortedThemeNames() + m := newMenuModel(names[len(names)-1]) + if m.idx != len(names)-1 { + t.Fatalf("saved theme not restored, idx=%d", m.idx) + } +} diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go new file mode 100644 index 0000000..464bf14 --- /dev/null +++ b/cmd/termtype/settings.go @@ -0,0 +1,200 @@ +package main + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/chart" + "github.com/namest504/termtype/internal/store" + "github.com/namest504/termtype/internal/ui" +) + +// chartStyles is the single source of truth for the result-graph styles: +// codes are what config.json stores (see Config.ChartStyle), and opts is +// the chart.Options each code renders with. Both the settings screen and +// chartOptionsFor derive from this table so they can never disagree on +// what an unknown/legacy code falls back to. +var chartStyles = []struct { + code, label string + opts chart.Options +}{ + {"braille1", "braille · 1px", chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 1}}, + {"braille2", "braille · 2px", chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2}}, + {"braille3", "braille · 3px", chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 3}}, + {"box", "box", chart.Options{Style: chart.StyleBox, Interp: chart.InterpSmooth, Thickness: 1}}, +} + +// defaultChartStyleIdx is the table index for store.Config{}.ChartStyle() +// (currently "braille2"), used as the fallback when a code isn't found. +var defaultChartStyleIdx = indexOf(len(chartStyles), func(i int) bool { + return chartStyles[i].code == store.Config{}.ChartStyle() +}) + +const settingsRows = 5 // Mode, Text, Language, Graph, Style + +// settingsModel is the settings screen state, kept free of drawing so key +// transitions are unit-testable. +type settingsModel struct { + row int + modeIdx int + srcIdx int + langIdx int + styleIdx int + graphOn bool +} + +// styleIdxFor finds a style code's index in chartStyles, falling back to +// the braille2 entry when the code is unknown (e.g. a stale/hand-edited +// config) so the settings screen shows the same style chartOptionsFor +// renders. +func styleIdxFor(code string) int { + idx := indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == code }) + if chartStyles[idx].code != code { + return defaultChartStyleIdx + } + return idx +} + +func newSettingsModel(cfg store.Config) settingsModel { + return settingsModel{ + modeIdx: indexOf(len(gameModes), func(i int) bool { return store.ModeString(gameModes[i].limit) == cfg.Mode }), + srcIdx: indexOf(len(textSources), func(i int) bool { return textSources[i].code == cfg.Source }), + langIdx: indexOf(len(languages), func(i int) bool { return languages[i].code == cfg.Lang }), + styleIdx: styleIdxFor(cfg.ChartStyle()), + graphOn: cfg.GraphAuto(), + } +} + +// handleKey advances the model. changed means a value moved (caller saves); +// done means Esc closed the screen. +func (m *settingsModel) handleKey(ev *tcell.EventKey) (changed, done bool) { + switch ev.Key() { + case tcell.KeyEscape: + return false, true + case tcell.KeyUp: + if m.row > 0 { + m.row-- + } + case tcell.KeyDown: + if m.row < settingsRows-1 { + m.row++ + } + case tcell.KeyLeft: + return m.cycle(-1), false + case tcell.KeyRight: + return m.cycle(1), false + } + return false, false +} + +func cycleIdx(i, d, n int) int { return (i + d + n) % n } + +func (m *settingsModel) cycle(d int) bool { + switch m.row { + case 0: + m.modeIdx = cycleIdx(m.modeIdx, d, len(gameModes)) + case 1: + m.srcIdx = cycleIdx(m.srcIdx, d, len(textSources)) + case 2: + // The words pool is English-only; the row is pinned while it is active. + if textSources[m.srcIdx].code == "words" { + return false + } + m.langIdx = cycleIdx(m.langIdx, d, len(languages)) + case 3: + m.graphOn = !m.graphOn + case 4: + m.styleIdx = cycleIdx(m.styleIdx, d, len(chartStyles)) + } + return true +} + +// apply writes the model's values onto cfg, leaving unrelated fields alone. +func (m settingsModel) apply(cfg store.Config) store.Config { + cfg.Mode = store.ModeString(gameModes[m.modeIdx].limit) + cfg.Source = textSources[m.srcIdx].code + cfg.Lang = languages[m.langIdx].code + cfg.Graph = "on" + if !m.graphOn { + cfg.Graph = "off" + } + cfg.Style = chartStyles[m.styleIdx].code + return cfg +} + +// runSettings shows the settings screen. Every value change is saved to +// config immediately; Esc returns to the menu. +func runSettings(s tcell.Screen, events <-chan tcell.Event, cfg *store.Config, st *store.Store) { + m := newSettingsModel(*cfg) + for { + drawSettings(s, m) + switch ev := (<-events).(type) { + case nil: + return + case *tcell.EventResize: + s.Sync() + case *tcell.EventKey: + if ev.Key() == tcell.KeyCtrlC { + // quit is the menu's job; treat as Esc here + return + } + changed, done := m.handleKey(ev) + if changed { + *cfg = m.apply(*cfg) + st.SaveConfig(*cfg) + ui.SetChartOptions(chartOptionsFor(cfg.ChartStyle())) + } + if done { + return + } + } + } +} + +func drawSettings(s tcell.Screen, m settingsModel) { + s.Clear() + w, _ := s.Size() + gl := ui.Glyphs() + drawText(s, 2, 1, tcell.StyleDefault.Bold(true), ui.Truncate("Settings", w-2)) + + langName := languages[m.langIdx].name + langPinned := textSources[m.srcIdx].code == "words" + if langPinned { + langName = "English" + } + graph := "On" + if !m.graphOn { + graph = "Off" + } + style := chartStyles[m.styleIdx].label + if ui.IsASCII() { + style += " (ascii)" + } + rows := []struct { + name, value string + dim bool + }{ + {"Mode", gameModes[m.modeIdx].name, false}, + {"Text", textSources[m.srcIdx].name, false}, + {"Language", langName, langPinned}, + {"Graph", graph, false}, + {"Style", style, false}, + } + for i, row := range rows { + st := tcell.StyleDefault + if row.dim { + st = st.Foreground(tcell.ColorGray) + } + if i == m.row { + st = st.Reverse(true) + } + line := fmt.Sprintf("%-10s %s %s %s", row.name, "‹", row.value, "›") + if ui.IsASCII() { + line = fmt.Sprintf("%-10s < %s >", row.name, row.value) + } + drawText(s, 3, 3+i, st, ui.Truncate(line, w-3)) + } + help := gl.ArrowUD + " select " + gl.Sep + " " + gl.ArrowLR + " change " + gl.Sep + " Esc back" + drawText(s, 2, 3+settingsRows+1, tcell.StyleDefault.Foreground(tcell.ColorGray), ui.Truncate(help, w-2)) + s.Show() +} diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go new file mode 100644 index 0000000..49bc9c3 --- /dev/null +++ b/cmd/termtype/settings_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/chart" + "github.com/namest504/termtype/internal/store" +) + +func key(k tcell.Key) *tcell.EventKey { return tcell.NewEventKey(k, 0, tcell.ModNone) } + +func TestSettingsModelFromConfig(t *testing.T) { + m := newSettingsModel(store.Config{Mode: "ta30", Source: "words", Lang: "ko", Graph: "off", Style: "box"}) + if gameModes[m.modeIdx].name != "Time Attack (30s)" { + t.Fatalf("mode idx wrong: %s", gameModes[m.modeIdx].name) + } + if textSources[m.srcIdx].code != "words" || languages[m.langIdx].code != "ko" { + t.Fatal("source/lang not restored") + } + if m.graphOn || chartStyles[m.styleIdx].code != "box" { + t.Fatal("graph/style not restored") + } +} + +func TestSettingsCycleAndApply(t *testing.T) { + m := newSettingsModel(store.Config{}) + // row 0 = Mode: Right → Time Attack (15s) + if changed, _ := m.handleKey(key(tcell.KeyRight)); !changed { + t.Fatal("right on mode row should report a change") + } + // row 4 = Style: Left twice (braille2 → braille1 → box) + m.row = 4 + m.handleKey(key(tcell.KeyLeft)) + m.handleKey(key(tcell.KeyLeft)) + cfg := m.apply(store.Config{Theme: "cozy"}) + if cfg.Mode != "ta15" || cfg.Style != "box" || cfg.Theme != "cozy" { + t.Fatalf("apply produced %+v", cfg) + } +} + +func TestSettingsFreshConfigDefaultsToBraille2(t *testing.T) { + m := newSettingsModel(store.Config{}) + // Fresh config should show braille2 (not braille1) + if chartStyles[m.styleIdx].code != "braille2" { + t.Fatalf("fresh config should default to braille2, got %s", chartStyles[m.styleIdx].code) + } + // Changing an unrelated row should not downgrade the style + m.row = 0 // Mode + m.handleKey(key(tcell.KeyRight)) + cfg := m.apply(store.Config{}) + if cfg.Style != "braille2" { + t.Fatalf("changing unrelated row should preserve braille2, got %s", cfg.Style) + } +} + +// TestUnknownStyleFallsBackToBraille2Consistently guards against +// chartOptionsFor and newSettingsModel disagreeing on the fallback for an +// unknown/legacy style code: both must treat it as braille2, and an +// unrelated settings change must persist "braille2" rather than +// silently rewriting it to "braille1" (index-0 fallback). +func TestUnknownStyleFallsBackToBraille2Consistently(t *testing.T) { + const unknown = "braille4" + + o := chartOptionsFor(unknown) + want := chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2} + if o != want { + t.Fatalf("chartOptionsFor(%q) = %+v, want %+v", unknown, o, want) + } + + m := newSettingsModel(store.Config{Style: unknown}) + if chartStyles[m.styleIdx].code != "braille2" { + t.Fatalf("newSettingsModel(%q) showed %s, want braille2", unknown, chartStyles[m.styleIdx].code) + } + + m.row = 0 // Mode: unrelated to Style + m.handleKey(key(tcell.KeyRight)) + cfg := m.apply(store.Config{Style: unknown}) + if cfg.Style != "braille2" { + t.Fatalf("unrelated change rewrote style to %q, want braille2", cfg.Style) + } +} + +func TestSettingsLanguagePinnedForWords(t *testing.T) { + m := newSettingsModel(store.Config{Source: "words"}) + m.row = 2 // Language + if changed, _ := m.handleKey(key(tcell.KeyRight)); changed { + t.Fatal("language must not cycle while Words is selected") + } +} + +func TestSettingsEscDone(t *testing.T) { + m := newSettingsModel(store.Config{}) + if _, done := m.handleKey(key(tcell.KeyEscape)); !done { + t.Fatal("esc should finish the screen") + } +} + +func TestSettingsRowNavigationClamps(t *testing.T) { + m := newSettingsModel(store.Config{}) + m.handleKey(key(tcell.KeyUp)) // already at top + if m.row != 0 { + t.Fatal("up at top should clamp") + } + for i := 0; i < 10; i++ { + m.handleKey(key(tcell.KeyDown)) + } + if m.row != settingsRows-1 { + t.Fatalf("down should clamp at %d, got %d", settingsRows-1, m.row) + } +} diff --git a/internal/app/game_test.go b/internal/app/game_test.go index 23cfa5d..014924b 100644 --- a/internal/app/game_test.go +++ b/internal/app/game_test.go @@ -13,6 +13,134 @@ func typeRunes(g *Game, s string) { } } +// stubTheme is a minimal domain.Theme whose ResetState mirrors SimpleTheme: +// it resets the round and draws a fresh target from the pool, which the +// overlay_test.go fakeTheme (an empty no-op ResetState) does not do. +type stubTheme struct{} + +func (stubTheme) ResetState(gs *domain.GameState) { + gs.ResetCommon() + gs.TargetSentence = gs.RandomSentence() +} +func (stubTheme) UpdateScreen(r domain.Renderer, gs *domain.GameState) {} +func (stubTheme) OnTick(gs *domain.GameState) {} + +// newTestGame returns a Game built through NewGame (not the newGame test +// helper) so NewGame's own construction logic is exercised: sentence-pool +// fallback, autoGraph-vs-cozy, and initial ResetState via the theme. +func newTestGame(t *testing.T, autoGraph bool, themeName string) *Game { + t.Helper() + s := tcell.NewSimulationScreen("UTF-8") + if err := s.Init(); err != nil { + t.Fatalf("init sim screen: %v", err) + } + s.SetSize(80, 24) + t.Cleanup(s.Fini) + g, err := NewGame(s, stubTheme{}, 0, []string{"ab"}, nil, + RoundMeta{Theme: themeName, Lang: "en", Source: "builtin"}, nil, autoGraph) + if err != nil { + t.Fatalf("NewGame: %v", err) + } + return g +} + +func TestNewGame(t *testing.T) { + t.Run("empty sentence pool falls back to default", func(t *testing.T) { + s := tcell.NewSimulationScreen("UTF-8") + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + t.Cleanup(s.Fini) + g, err := NewGame(s, stubTheme{}, 0, nil, nil, RoundMeta{Theme: "simple"}, nil, false) + if err != nil { + t.Fatalf("NewGame: %v", err) + } + if len(g.state.Sentences) == 0 { + t.Error("sentences should fall back to the default English pool") + } + }) + t.Run("cozy theme disables the auto graph", func(t *testing.T) { + if g := newTestGame(t, true, "cozy"); g.autoGraph { + t.Error("autoGraph must be forced off on the cozy theme") + } + }) + t.Run("other themes keep the auto graph", func(t *testing.T) { + if g := newTestGame(t, true, "simple"); !g.autoGraph { + t.Error("autoGraph should stay on for non-cozy themes") + } + }) +} + +func TestHandleKeyEvent_GameLifecycle(t *testing.T) { + t.Run("esc goes back, ctrl-c quits", func(t *testing.T) { + g := newTestGame(t, false, "simple") + if back, quit := g.handleKeyEvent(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)); !back || quit { + t.Errorf("Esc = (%v,%v), want (true,false)", back, quit) + } + if back, quit := g.handleKeyEvent(tcell.NewEventKey(tcell.KeyCtrlC, 0, tcell.ModNone)); !back || !quit { + t.Errorf("Ctrl-C = (%v,%v), want (true,true)", back, quit) + } + }) + t.Run("pause swallows input", func(t *testing.T) { + g := newTestGame(t, false, "simple") + typeRunes(g, "a") // start the timer first so pause is meaningful + g.handleKeyEvent(tcell.NewEventKey(tcell.KeyCtrlP, 0, tcell.ModNone)) + before := g.state.UserInput + typeRunes(g, "b") + if g.state.UserInput != before { + t.Errorf("input while paused changed UserInput to %q", g.state.UserInput) + } + g.handleKeyEvent(tcell.NewEventKey(tcell.KeyCtrlP, 0, tcell.ModNone)) + typeRunes(g, "b") + if g.state.UserInput == before { + t.Error("input after resume should register") + } + }) + t.Run("backspace removes the last rune", func(t *testing.T) { + g := newTestGame(t, false, "simple") + typeRunes(g, "a") + g.handleKeyEvent(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone)) + if g.state.UserInput != "" { + t.Errorf("UserInput = %q, want empty after backspace", g.state.UserInput) + } + }) + t.Run("typing the full target finishes the round", func(t *testing.T) { + g := newTestGame(t, false, "simple") + typeRunes(g, g.state.TargetSentence) + if !g.state.IsFinished { + t.Fatal("round should finalize when the target is fully typed") + } + }) + t.Run("g toggles the graph view after finishing", func(t *testing.T) { + g := newTestGame(t, false, "simple") + typeRunes(g, g.state.TargetSentence) + g.handleKeyEvent(tcell.NewEventKey(tcell.KeyRune, 'g', tcell.ModNone)) + if !g.showGraph { + t.Error("g should raise the graph view") + } + g.handleKeyEvent(tcell.NewEventKey(tcell.KeyRune, 'g', tcell.ModNone)) + if g.showGraph { + t.Error("second g should dismiss the graph view") + } + }) + t.Run("enter after finishing starts a new round", func(t *testing.T) { + g := newTestGame(t, false, "simple") + typeRunes(g, g.state.TargetSentence) + g.handleKeyEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + if g.state.IsFinished { + t.Error("Enter should reset the round") + } + }) +} + +func TestDrawGraphViewSmoke(t *testing.T) { + g := newTestGame(t, false, "simple") + typeRunes(g, g.state.TargetSentence) + g.state.WPMSamples = []float64{30, 45, 50, 48} + g.showGraph = true + g.render() // routes to drawGraphView when finished+showGraph +} + // BUG 5 regression: for multibyte sentences, completion and accuracy must be // rune-based, not byte-based. // "héllo" is 5 runes / 6 bytes. Typing it perfectly should give 100% accuracy diff --git a/internal/chart/chart.go b/internal/chart/chart.go index a502b70..4f0c266 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -175,15 +175,15 @@ func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi } prev := pxRows[0] for c, row := range pxRows { - lo, hi := row, row + top, bot := row, row if c > 0 { if prev < row { - lo = prev + 1 + top = prev + 1 } else if prev > row { - hi = prev - 1 + bot = prev - 1 } } - for py := lo; py <= hi; py++ { + for py := top; py <= bot; py++ { for t := 0; t < thick; t++ { set(py+t, c) } diff --git a/internal/chart/interp_test.go b/internal/chart/interp_test.go index 518407a..4b21f04 100644 --- a/internal/chart/interp_test.go +++ b/internal/chart/interp_test.go @@ -46,3 +46,24 @@ func TestSampleSmoothFallsBackBelowThree(t *testing.T) { } } } + +func TestMonotoneCubicLocalExtrema(t *testing.T) { + cases := []struct { + name string + series []float64 + }{ + {"valley", []float64{80, 20, 80}}, + {"peak", []float64{20, 80, 20}}, + {"zigzag", []float64{10, 60, 30, 70, 40}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lo, hi := bounds(tc.series) + for i, v := range monotoneCubic(tc.series, 101) { + if v < lo-1e-9 || v > hi+1e-9 { + t.Fatalf("point %d overshoots at a direction change: %v outside [%v,%v]", i, v, lo, hi) + } + } + }) + } +} diff --git a/internal/ui/typing_renderer_test.go b/internal/ui/typing_renderer_test.go index 0bb1eb4..10022b2 100644 --- a/internal/ui/typing_renderer_test.go +++ b/internal/ui/typing_renderer_test.go @@ -16,15 +16,13 @@ type mockCell struct { // MockScreen is a mock implementation of tcell.Screen for testing type MockScreen struct { tcell.Screen - cells map[int]map[int]rune - sty map[int]map[int]mockCell + cells map[int]map[int]mockCell w, h int } func NewMockScreen(w, h int) *MockScreen { return &MockScreen{ - cells: make(map[int]map[int]rune), - sty: make(map[int]map[int]mockCell), + cells: make(map[int]map[int]mockCell), w: w, h: h, } @@ -32,19 +30,15 @@ func NewMockScreen(w, h int) *MockScreen { func (m *MockScreen) SetContent(x, y int, mainc rune, combc []rune, style tcell.Style) { if m.cells[y] == nil { - m.cells[y] = make(map[int]rune) + m.cells[y] = make(map[int]mockCell) } - m.cells[y][x] = mainc - if m.sty[y] == nil { - m.sty[y] = make(map[int]mockCell) - } - m.sty[y][x] = mockCell{r: mainc, style: style} + m.cells[y][x] = mockCell{r: mainc, style: style} } // Cell returns the rune and style last written at (x, y). A position never // written returns the zero rune and tcell.StyleDefault. func (m *MockScreen) Cell(x, y int) (rune, tcell.Style) { - if row, ok := m.sty[y]; ok { + if row, ok := m.cells[y]; ok { if c, ok := row[x]; ok { return c.r, c.style } @@ -98,8 +92,8 @@ func TestTypingRenderer_Draw_Padding(t *testing.T) { for y := 0; y < height; y++ { if row, ok := mockScreen.cells[y]; ok { for x := width - 3; x < width; x++ { - if char, exists := row[x]; exists && char != ' ' && char != 0 { - t.Errorf("Found character '%c' at (%d, %d), expected padding", char, x, y) + if c, exists := row[x]; exists && c.r != ' ' && c.r != 0 { + t.Errorf("Found character '%c' at (%d, %d), expected padding", c.r, x, y) } } } diff --git a/internal/ui/window_test.go b/internal/ui/window_test.go index 4966a5c..888356d 100644 --- a/internal/ui/window_test.go +++ b/internal/ui/window_test.go @@ -59,9 +59,9 @@ func TestDrawWindowsLongTargets(t *testing.T) { t.Fatalf("Draw returned %d rows, want the 3-line window", rows) } for y := 3; y < 12; y++ { - for x, ch := range mock.cells[y] { - if ch != ' ' && ch != 0 { - t.Fatalf("content %q at (%d,%d) below the window", ch, x, y) + for x, c := range mock.cells[y] { + if c.r != ' ' && c.r != 0 { + t.Fatalf("content %q at (%d,%d) below the window", c.r, x, y) } } }