From 41ef866e0e11bb9a157d4330b68a5c1ada552d1f Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:03:35 +0900 Subject: [PATCH 01/10] feat: Move round options into a dedicated settings screen --- cmd/termtype/settings.go | 174 ++++++++++++++++++++++++++++++++++ cmd/termtype/settings_test.go | 67 +++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 cmd/termtype/settings.go create mode 100644 cmd/termtype/settings_test.go diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go new file mode 100644 index 0000000..a7c42ca --- /dev/null +++ b/cmd/termtype/settings.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/store" + "github.com/namest504/termtype/internal/ui" +) + +// chartStyles are the result-graph styles the settings screen cycles +// through; codes are what config.json stores (see Config.ChartStyle). +var chartStyles = []struct{ code, label string }{ + {"braille1", "braille · 1px"}, + {"braille2", "braille · 2px"}, + {"braille3", "braille · 3px"}, + {"box", "box"}, +} + +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 +} + +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: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.Style }), + 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() + gl := ui.Glyphs() + drawText(s, 2, 1, tcell.StyleDefault.Bold(true), "Settings") + + 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, line) + } + help := gl.ArrowUD + " select " + gl.Sep + " " + gl.ArrowLR + " change " + gl.Sep + " Esc back" + drawText(s, 2, 3+settingsRows+1, tcell.StyleDefault.Foreground(tcell.ColorGray), help) + s.Show() +} diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go new file mode 100644 index 0000000..4fe9297 --- /dev/null +++ b/cmd/termtype/settings_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "testing" + + "github.com/gdamore/tcell/v2" + "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 → wraps to box + m.row = 4 + 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 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) + } +} From b25a7016c29e845d5b5ee393e30b4f7de2e5c823 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:07:19 +0900 Subject: [PATCH 02/10] fix: Restore the settings style row from the defaulted chart style --- cmd/termtype/settings.go | 2 +- cmd/termtype/settings_test.go | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go index a7c42ca..caab337 100644 --- a/cmd/termtype/settings.go +++ b/cmd/termtype/settings.go @@ -35,7 +35,7 @@ func newSettingsModel(cfg store.Config) 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: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.Style }), + styleIdx: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.ChartStyle() }), graphOn: cfg.GraphAuto(), } } diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go index 4fe9297..18cc235 100644 --- a/cmd/termtype/settings_test.go +++ b/cmd/termtype/settings_test.go @@ -28,15 +28,31 @@ func TestSettingsCycleAndApply(t *testing.T) { if changed, _ := m.handleKey(key(tcell.KeyRight)); !changed { t.Fatal("right on mode row should report a change") } - // row 4 = Style: Left → wraps to box + // 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) + } +} + func TestSettingsLanguagePinnedForWords(t *testing.T) { m := newSettingsModel(store.Config{Source: "words"}) m.row = 2 // Language From e9db9c39d252f0aa3230f95e25ea866b51ecf961 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:10:29 +0900 Subject: [PATCH 03/10] feat: Simplify the menu into a theme carousel --- cmd/termtype/menu.go | 161 ++++++++++++++++++++++++++++++++++++++ cmd/termtype/menu_test.go | 73 +++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 cmd/termtype/menu.go create mode 100644 cmd/termtype/menu_test.go 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) + } +} From 848b2563be251d403ec06af98147e24cef3df85c Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:13:41 +0900 Subject: [PATCH 04/10] feat: Wire the carousel menu and settings screen into the app --- README.md | 15 ++-- cmd/termtype/main.go | 158 +++++++++++-------------------------------- 2 files changed, 45 insertions(+), 128 deletions(-) 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/main.go b/cmd/termtype/main.go index 96b4b23..5cdafd8 100644 --- a/cmd/termtype/main.go +++ b/cmd/termtype/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "sort" "strings" "time" @@ -136,128 +135,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 +230,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: From 644452bdff0fbce288d78774b89a0805a68171bb Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:23:29 +0900 Subject: [PATCH 05/10] fix: Share one style table and polish review findings Make chartStyles the single source of truth for style codes so chartOptionsFor and newSettingsModel can no longer disagree on the fallback for an unknown code: both now fall back to braille2 instead of chartOptionsFor's braille2 vs newSettingsModel's index-0 braille1, which used to render braille2, display braille1, and silently rewrite the config to braille1 on any unrelated settings change. Also: drawSettings truncates rows/help to terminal width like drawMenu/history do; renderBraille's loop locals no longer shadow the lo/hi bounds parameters; MockScreen in typing_renderer_test.go keeps one cell map instead of two. --- cmd/termtype/main.go | 11 +------ cmd/termtype/settings.go | 48 ++++++++++++++++++++++------- cmd/termtype/settings_test.go | 28 +++++++++++++++++ internal/chart/chart.go | 8 ++--- internal/ui/typing_renderer_test.go | 20 +++++------- internal/ui/window_test.go | 6 ++-- 6 files changed, 80 insertions(+), 41 deletions(-) diff --git a/cmd/termtype/main.go b/cmd/termtype/main.go index 5cdafd8..e4c62ff 100644 --- a/cmd/termtype/main.go +++ b/cmd/termtype/main.go @@ -102,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, diff --git a/cmd/termtype/settings.go b/cmd/termtype/settings.go index caab337..464bf14 100644 --- a/cmd/termtype/settings.go +++ b/cmd/termtype/settings.go @@ -4,19 +4,32 @@ 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 are the result-graph styles the settings screen cycles -// through; codes are what config.json stores (see Config.ChartStyle). -var chartStyles = []struct{ code, label string }{ - {"braille1", "braille · 1px"}, - {"braille2", "braille · 2px"}, - {"braille3", "braille · 3px"}, - {"box", "box"}, +// 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 @@ -30,12 +43,24 @@ type settingsModel struct { 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: indexOf(len(chartStyles), func(i int) bool { return chartStyles[i].code == cfg.ChartStyle() }), + styleIdx: styleIdxFor(cfg.ChartStyle()), graphOn: cfg.GraphAuto(), } } @@ -128,8 +153,9 @@ func runSettings(s tcell.Screen, events <-chan tcell.Event, cfg *store.Config, s func drawSettings(s tcell.Screen, m settingsModel) { s.Clear() + w, _ := s.Size() gl := ui.Glyphs() - drawText(s, 2, 1, tcell.StyleDefault.Bold(true), "Settings") + drawText(s, 2, 1, tcell.StyleDefault.Bold(true), ui.Truncate("Settings", w-2)) langName := languages[m.langIdx].name langPinned := textSources[m.srcIdx].code == "words" @@ -166,9 +192,9 @@ func drawSettings(s tcell.Screen, m settingsModel) { if ui.IsASCII() { line = fmt.Sprintf("%-10s < %s >", row.name, row.value) } - drawText(s, 3, 3+i, st, line) + 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), help) + 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 index 18cc235..49bc9c3 100644 --- a/cmd/termtype/settings_test.go +++ b/cmd/termtype/settings_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/gdamore/tcell/v2" + "github.com/namest504/termtype/internal/chart" "github.com/namest504/termtype/internal/store" ) @@ -53,6 +54,33 @@ func TestSettingsFreshConfigDefaultsToBraille2(t *testing.T) { } } +// 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 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/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) } } } From 3ce66870a47e16082ea34a48a47b49b4411efa2d Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:07:50 +0900 Subject: [PATCH 06/10] test: Cover the menu summary line and ASCII resolution --- cmd/termtype/main_test.go | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) 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) + } + }) + } +} From bfb6dc80e72d8d6542fa1b40197aef464a7a7da3 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:12:04 +0900 Subject: [PATCH 07/10] test: Smoke-test the menu, settings, and history drawing --- cmd/termtype/draw_test.go | 107 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 cmd/termtype/draw_test.go 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") + }) +} From 49757455e928a30b2cd8649aa4dce30e8797c7b1 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:14:33 +0900 Subject: [PATCH 08/10] test: Drive the menu, settings, and history loops with fake events --- cmd/termtype/loop_test.go | 127 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 cmd/termtype/loop_test.go 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) + }) +} From 522660db90ddd3c97cfbcd2d617315791f14cb5e Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:18:13 +0900 Subject: [PATCH 09/10] test: Cover game construction, key handling, and the graph view --- internal/app/game_test.go | 128 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) 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 From 587046b710ca69d02b44e0f17503ab879779fe6e Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:20:37 +0900 Subject: [PATCH 10/10] test: Pin monotone interpolation at direction changes --- internal/chart/interp_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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) + } + } + }) + } +}