From 41ef866e0e11bb9a157d4330b68a5c1ada552d1f Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 13:03:35 +0900 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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) + } + } + }) + } +} From d23343816b6b3a8278342fbf64fce9afda533497 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:25:36 +0900 Subject: [PATCH 11/14] test: Name chart and store table cases as subtests --- internal/chart/chart_test.go | 42 ++++++++++++++++++++--------------- internal/chart/interp_test.go | 27 +++++++++++++--------- internal/store/stats_test.go | 17 +++++++++----- internal/store/store_test.go | 38 ++++++++++++++++++++----------- 4 files changed, 77 insertions(+), 47 deletions(-) diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go index 4f33d34..e7dcd5a 100644 --- a/internal/chart/chart_test.go +++ b/internal/chart/chart_test.go @@ -3,15 +3,21 @@ package chart import "testing" func TestRenderNilOnDegenerate(t *testing.T) { - o := Options{Width: 20, Height: 5} - if Render([]float64{42}, o) != nil { - t.Fatal("single sample should render nil") - } - if Render([]float64{1, 2}, Options{Width: 6, Height: 5}) != nil { - t.Fatal("too-narrow rect should render nil") + cases := []struct { + name string + series []float64 + opts Options + }{ + {"single sample", []float64{42}, Options{Width: 20, Height: 5}}, + {"too-narrow rect", []float64{1, 2}, Options{Width: 6, Height: 5}}, + {"too-short rect", []float64{1, 2}, Options{Width: 20, Height: 1}}, } - if Render([]float64{1, 2}, Options{Width: 20, Height: 1}) != nil { - t.Fatal("too-short rect should render nil") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Render(tc.series, tc.opts); got != nil { + t.Errorf("Render() = %v, want nil", got) + } + }) } } @@ -29,7 +35,7 @@ func TestRenderAxisAndLabels(t *testing.T) { const labelW = 4 for row := 0; row < 5; row++ { if g[row][labelW].Kind != KindAxis || g[row][labelW].Rune != '┤' { - t.Fatalf("row %d col %d should be axis tick, got %q/%v", + t.Errorf("row %d col %d = %q/%v, want '┤'/KindAxis", row, labelW, g[row][labelW].Rune, g[row][labelW].Kind) } } @@ -51,7 +57,7 @@ func TestRenderLineCellsTagged(t *testing.T) { for cx, c := range row { if c.Kind == KindLine { if cx <= 4 { - t.Fatalf("line cell inside axis area at col %d", cx) + t.Errorf("line cell inside axis area at col %d", cx) } found = true } @@ -74,7 +80,7 @@ func TestRenderFlatSeriesMidRow(t *testing.T) { } // flat series → all line pixels land in the middle pixel row band if hasLine && row != 2 { - t.Fatalf("flat series drew on cell row %d, want only row 2", row) + t.Errorf("flat series drew on cell row %d, want only row 2", row) } } } @@ -89,7 +95,7 @@ func TestRenderASCIIStyle(t *testing.T) { stars++ } if c.Rune >= 0x2800 && c.Rune <= 0x28FF { - t.Fatal("ASCII style must not emit braille runes") + t.Errorf("ASCII style must not emit braille runes, got %q", c.Rune) } } } @@ -97,7 +103,7 @@ func TestRenderASCIIStyle(t *testing.T) { t.Fatal("ASCII style should draw * markers") } if g[0][4].Rune != '|' { - t.Fatalf("ASCII tick should be '|', got %q", g[0][4].Rune) + t.Fatalf("ASCII tick = %q, want '|'", g[0][4].Rune) } } @@ -106,7 +112,7 @@ func TestSampleLinearInterpolates(t *testing.T) { want := []float64{0, 5, 10} for i := range want { if got[i] != want[i] { - t.Fatalf("sample[%d] = %v, want %v", i, got[i], want[i]) + t.Errorf("sample[%d] = %v, want %v", i, got[i], want[i]) } } } @@ -115,7 +121,7 @@ func TestPixelRowsFlat(t *testing.T) { rows := pixelRows([]float64{5, 5, 5, 5}, 8, 5, 5) for _, r := range rows { if r != 4 { - t.Fatalf("flat series pixel row = %d, want 4", r) + t.Errorf("flat series pixel row = %d, want 4", r) } } } @@ -234,11 +240,11 @@ func TestBoxStyleCorners(t *testing.T) { } for _, r := range []rune{'╮', '╰', '╯', '╭'} { if counts[r] == 0 { - t.Fatalf("box line missing corner %q; got %v", r, counts) + t.Errorf("box line missing corner %q; got %v", r, counts) } } if counts['─'] == 0 { - t.Fatalf("box line missing horizontal runs; got %v", counts) + t.Errorf("box line missing horizontal runs; got %v", counts) } } @@ -247,7 +253,7 @@ func TestBoxStyleFlat(t *testing.T) { for y, row := range g { for _, c := range row { if c.Kind == KindLine && (c.Rune != '─' || y != 2) { - t.Fatalf("flat box line should be ─ on middle row, got %q on row %d", c.Rune, y) + t.Errorf("flat box line cell = %q on row %d, want '─' on row 2", c.Rune, y) } } } diff --git a/internal/chart/interp_test.go b/internal/chart/interp_test.go index 4b21f04..26427d3 100644 --- a/internal/chart/interp_test.go +++ b/internal/chart/interp_test.go @@ -2,6 +2,15 @@ package chart import "testing" +// assertWithinBounds fails (without aborting the test) if v falls outside +// [lo,hi] beyond a small floating-point tolerance. +func assertWithinBounds(t *testing.T, i int, v, lo, hi float64) { + t.Helper() + if v < lo-1e-9 || v > hi+1e-9 { + t.Errorf("point %d = %v, want within [%v,%v]", i, v, lo, hi) + } +} + // 단조 구간에서 보간값도 단조 — 오버슈트 없음이 스펙 요구사항이다. func TestMonotoneCubicNoOvershoot(t *testing.T) { series := []float64{0, 10, 12, 60, 62, 100} @@ -9,11 +18,9 @@ func TestMonotoneCubicNoOvershoot(t *testing.T) { lo, hi := bounds(series) prev := got[0] for i, v := range got { - if v < lo-1e-9 || v > hi+1e-9 { - t.Fatalf("overshoot at %d: %v outside [%v,%v]", i, v, lo, hi) - } + assertWithinBounds(t, i, v, lo, hi) if v < prev-1e-9 { - t.Fatalf("monotone increasing series produced a dip at %d: %v < %v", i, v, prev) + t.Errorf("monotone increasing series produced a dip at %d: %v < %v", i, v, prev) } prev = v } @@ -24,15 +31,15 @@ func TestMonotoneCubicHitsSamples(t *testing.T) { got := monotoneCubic(series, 7) // n = 2*(len-1)+1 → 짝수 인덱스가 원본 샘플 for i, want := range series { if diff := got[i*2] - want; diff > 1e-9 || diff < -1e-9 { - t.Fatalf("sample %d: got %v, want %v", i, got[i*2], want) + t.Errorf("sample %d = %v, want %v", i, got[i*2], want) } } } func TestMonotoneCubicFlat(t *testing.T) { - for _, v := range monotoneCubic([]float64{5, 5, 5}, 10) { + for i, v := range monotoneCubic([]float64{5, 5, 5}, 10) { if v != 5 { - t.Fatalf("flat series interpolated to %v", v) + t.Errorf("point %d = %v, want 5", i, v) } } } @@ -42,7 +49,7 @@ func TestSampleSmoothFallsBackBelowThree(t *testing.T) { want := []float64{0, 5, 10} for i := range want { if got[i] != want[i] { - t.Fatalf("2-sample smooth should be linear, got %v", got) + t.Errorf("2-sample smooth[%d] = %v, want %v", i, got, want[i]) } } } @@ -60,9 +67,7 @@ func TestMonotoneCubicLocalExtrema(t *testing.T) { 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) - } + assertWithinBounds(t, i, v, lo, hi) } }) } diff --git a/internal/store/stats_test.go b/internal/store/stats_test.go index 528f2e0..640b149 100644 --- a/internal/store/stats_test.go +++ b/internal/store/stats_test.go @@ -42,13 +42,20 @@ func TestRecentWPMsLastN(t *testing.T) { func TestModeString(t *testing.T) { cases := []struct { + name string limit time.Duration want string - }{{0, "normal"}, {30 * time.Second, "ta30"}, {60 * time.Second, "ta60"}} - for _, c := range cases { - if got := ModeString(c.limit); got != c.want { - t.Errorf("ModeString(%v) = %q, want %q", c.limit, got, c.want) - } + }{ + {"no limit", 0, "normal"}, + {"30s limit", 30 * time.Second, "ta30"}, + {"60s limit", 60 * time.Second, "ta60"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ModeString(tc.limit); got != tc.want { + t.Errorf("ModeString(%v) = %q, want %q", tc.limit, got, tc.want) + } + }) } } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index cc830a5..743d44e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -58,23 +58,35 @@ func TestLoadHistorySkipsCorruptLines(t *testing.T) { func TestNoopStore(t *testing.T) { var nilStore *Store - for name, s := range map[string]*Store{"nil": nilStore, "empty": {}} { - s.SaveConfig(Config{Theme: "x"}) - s.AppendRound(Round{TS: time.Now()}) - if got := s.LoadHistory(); got != nil { - t.Errorf("%s store LoadHistory() = %v, want nil", name, got) - } - if got := s.LoadConfig(); got != (Config{}) { - t.Errorf("%s store LoadConfig() = %+v, want zero value", name, got) - } + cases := map[string]*Store{"nil": nilStore, "empty": {}} + for name, s := range cases { + t.Run(name, func(t *testing.T) { + s.SaveConfig(Config{Theme: "x"}) + s.AppendRound(Round{TS: time.Now()}) + if got := s.LoadHistory(); got != nil { + t.Errorf("LoadHistory() = %v, want nil", got) + } + if got := s.LoadConfig(); got != (Config{}) { + t.Errorf("LoadConfig() = %+v, want zero value", got) + } + }) } } func TestChartStyleDefault(t *testing.T) { - if got := (Config{}).ChartStyle(); got != "braille2" { - t.Fatalf("empty style should default to braille2, got %q", got) + cases := []struct { + name string + style string + want string + }{ + {"empty defaults to braille2", "", "braille2"}, + {"explicit style passes through", "box", "box"}, } - if got := (Config{Style: "box"}).ChartStyle(); got != "box" { - t.Fatalf("explicit style should pass through, got %q", got) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (Config{Style: tc.style}).ChartStyle(); got != tc.want { + t.Errorf("ChartStyle() = %q, want %q", got, tc.want) + } + }) } } From e4691f6e5695e88ce155784a1ef7f4877e579f43 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:30:08 +0900 Subject: [PATCH 12/14] test: Tidy cmd test naming and shared key helpers --- cmd/termtype/main_test.go | 23 +++++---- cmd/termtype/menu_test.go | 93 +++++++++++++++++++++-------------- cmd/termtype/settings_test.go | 72 ++++++++++++++++----------- 3 files changed, 113 insertions(+), 75 deletions(-) diff --git a/cmd/termtype/main_test.go b/cmd/termtype/main_test.go index 453052c..e72c1f7 100644 --- a/cmd/termtype/main_test.go +++ b/cmd/termtype/main_test.go @@ -10,21 +10,24 @@ import ( func TestChartOptionsFor(t *testing.T) { cases := []struct { + name string code string style chart.Style thick int }{ - {"braille1", chart.StyleBraille, 1}, - {"braille2", chart.StyleBraille, 2}, - {"braille3", chart.StyleBraille, 3}, - {"box", chart.StyleBox, 1}, - {"unknown", chart.StyleBraille, 2}, // 알 수 없는 값은 기본값 + {"braille1 is thin", "braille1", chart.StyleBraille, 1}, + {"braille2 is medium", "braille2", chart.StyleBraille, 2}, + {"braille3 is thick", "braille3", chart.StyleBraille, 3}, + {"box style", "box", chart.StyleBox, 1}, + {"unknown code falls back to braille2", "unknown", chart.StyleBraille, 2}, } - for _, c := range cases { - o := chartOptionsFor(c.code) - if o.Style != c.style || o.Thickness != c.thick || o.Interp != chart.InterpSmooth { - t.Fatalf("%s → %+v", c.code, o) - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := chartOptionsFor(tc.code) + if o.Style != tc.style || o.Thickness != tc.thick || o.Interp != chart.InterpSmooth { + t.Errorf("chartOptionsFor(%q) = %+v, want style %v thickness %d", tc.code, o, tc.style, tc.thick) + } + }) } } diff --git a/cmd/termtype/menu_test.go b/cmd/termtype/menu_test.go index b34b17d..8a61bbf 100644 --- a/cmd/termtype/menu_test.go +++ b/cmd/termtype/menu_test.go @@ -6,68 +6,89 @@ import ( "github.com/gdamore/tcell/v2" ) -func rkey(r rune) *tcell.EventKey { return tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone) } +// key and rkey live in settings_test.go, shared across this package's tests. func TestSortedThemesCozyFirst(t *testing.T) { names := sortedThemeNames() if len(names) < 3 || names[0] != "cozy" || names[1] != "log" { - t.Fatalf("theme order wrong: %v", names) + t.Fatalf("sortedThemeNames() = %v, want cozy, log, ...", 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) - } + t.Run("left from first wraps to last", func(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyLeft)) + if got, want := m.idx, len(m.themes)-1; got != want { + t.Errorf("idx = %d, want %d", got, want) + } + }) + t.Run("right from last wraps to first", func(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyLeft)) // move to last first + m.handleKey(key(tcell.KeyRight)) + if got, want := m.idx, 0; got != want { + t.Errorf("idx = %d, want %d", got, want) + } + }) } 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) - } + t.Run("down expands with selection on current theme", func(t *testing.T) { + m.handleKey(key(tcell.KeyDown)) + if !m.expanded || m.sel != m.idx { + t.Errorf("expanded=%v sel=%d idx=%d, want expanded=true and sel==idx", m.expanded, m.sel, m.idx) + } + }) + t.Run("enter picks the selection and collapses", func(t *testing.T) { + m.handleKey(key(tcell.KeyDown)) // move selection + m.handleKey(key(tcell.KeyEnter)) + if m.expanded || m.idx != 1 { + t.Errorf("expanded=%v idx=%d, want expanded=false and idx=1", m.expanded, m.idx) + } + }) } 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) - } + t.Run("esc while expanded collapses without quitting", func(t *testing.T) { + if act := m.handleKey(key(tcell.KeyEscape)); act != actNone || m.expanded { + t.Errorf("act = %v, expanded = %v, want actNone and collapsed", act, m.expanded) + } + }) + t.Run("esc while collapsed quits", func(t *testing.T) { + if act := m.handleKey(key(tcell.KeyEscape)); act != actQuit { + t.Errorf("act = %v, want actQuit", 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) + cases := []struct { + name string + k *tcell.EventKey + want menuAction + }{ + {"enter starts", key(tcell.KeyEnter), actStart}, + {"s opens settings", rkey('s'), actSettings}, + {"h opens history", rkey('h'), actHistory}, } - 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) + m := newMenuModel("cozy") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := m.handleKey(tc.k); got != tc.want { + t.Errorf("handleKey() = %v, want %v", got, tc.want) + } + }) } } 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) + if got, want := m.idx, len(names)-1; got != want { + t.Fatalf("idx = %d, want %d (saved theme not restored)", got, want) } } diff --git a/cmd/termtype/settings_test.go b/cmd/termtype/settings_test.go index 49bc9c3..4aeb43c 100644 --- a/cmd/termtype/settings_test.go +++ b/cmd/termtype/settings_test.go @@ -8,18 +8,27 @@ import ( "github.com/namest504/termtype/internal/store" ) +// key and rkey build synthetic tcell key events for driving handleKey in +// this package's tests. Shared across settings_test.go and menu_test.go. func key(k tcell.Key) *tcell.EventKey { return tcell.NewEventKey(k, 0, tcell.ModNone) } +func rkey(r rune) *tcell.EventKey { return tcell.NewEventKey(tcell.KeyRune, r, 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 got, want := gameModes[m.modeIdx].name, "Time Attack (30s)"; got != want { + t.Errorf("mode = %q, want %q", got, want) } - if textSources[m.srcIdx].code != "words" || languages[m.langIdx].code != "ko" { - t.Fatal("source/lang not restored") + if got, want := textSources[m.srcIdx].code, "words"; got != want { + t.Errorf("source = %q, want %q", got, want) } - if m.graphOn || chartStyles[m.styleIdx].code != "box" { - t.Fatal("graph/style not restored") + if got, want := languages[m.langIdx].code, "ko"; got != want { + t.Errorf("lang = %q, want %q", got, want) + } + if m.graphOn { + t.Error("graphOn = true, want false") + } + if got, want := chartStyles[m.styleIdx].code, "box"; got != want { + t.Errorf("style = %q, want %q", got, want) } } @@ -27,7 +36,7 @@ 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") + t.Fatal("right on mode row: changed = false, want true") } // row 4 = Style: Left twice (braille2 → braille1 → box) m.row = 4 @@ -35,22 +44,22 @@ func TestSettingsCycleAndApply(t *testing.T) { 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) + t.Fatalf("apply() = %+v, want Mode=ta15 Style=box Theme=cozy", 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) + if got, want := chartStyles[m.styleIdx].code, "braille2"; got != want { + t.Fatalf("fresh config style = %q, want %q", got, want) } // 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) + if got, want := cfg.Style, "braille2"; got != want { + t.Fatalf("after unrelated change, style = %q, want %q (preserved)", got, want) } } @@ -69,15 +78,15 @@ func TestUnknownStyleFallsBackToBraille2Consistently(t *testing.T) { } 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) + if got, want := chartStyles[m.styleIdx].code, "braille2"; got != want { + t.Fatalf("newSettingsModel(%q) style = %q, want %q", unknown, got, want) } 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) + if got, want := cfg.Style, "braille2"; got != want { + t.Fatalf("after unrelated change, style = %q, want %q", got, want) } } @@ -85,27 +94,32 @@ 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") + t.Fatal("language changed = true while Words selected, want false") } } func TestSettingsEscDone(t *testing.T) { m := newSettingsModel(store.Config{}) if _, done := m.handleKey(key(tcell.KeyEscape)); !done { - t.Fatal("esc should finish the screen") + t.Fatal("esc: done = false, want true") } } 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) - } + t.Run("up at top clamps to row 0", func(t *testing.T) { + m := newSettingsModel(store.Config{}) + m.handleKey(key(tcell.KeyUp)) + if got, want := m.row, 0; got != want { + t.Errorf("row = %d, want %d", got, want) + } + }) + t.Run("down past bottom clamps to last row", func(t *testing.T) { + m := newSettingsModel(store.Config{}) + for i := 0; i < 10; i++ { + m.handleKey(key(tcell.KeyDown)) + } + if got, want := m.row, settingsRows-1; got != want { + t.Errorf("row = %d, want %d", got, want) + } + }) } From bbbbc4edd570f5bb957e5c6eb4d3491c66f1d1b8 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:32:52 +0900 Subject: [PATCH 13/14] test: Add chart examples and benchmarks --- internal/chart/bench_test.go | 33 +++++++++++++++++++++++++++++++++ internal/chart/example_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 internal/chart/bench_test.go create mode 100644 internal/chart/example_test.go diff --git a/internal/chart/bench_test.go b/internal/chart/bench_test.go new file mode 100644 index 0000000..a8ce976 --- /dev/null +++ b/internal/chart/bench_test.go @@ -0,0 +1,33 @@ +package chart + +import "testing" + +var benchSeries = func() []float64 { + s := make([]float64, 60) + for i := range s { + s[i] = float64(30 + (i*7)%40) + } + return s +}() + +func BenchmarkRender(b *testing.B) { + opts := map[string]Options{ + "braille-1px": {Width: 64, Height: 10, Style: StyleBraille, Interp: InterpSmooth, Thickness: 1}, + "braille-3px": {Width: 64, Height: 10, Style: StyleBraille, Interp: InterpSmooth, Thickness: 3}, + "box": {Width: 64, Height: 10, Style: StyleBox, Interp: InterpSmooth}, + "ascii": {Width: 64, Height: 10, Style: StyleASCII}, + } + for name, o := range opts { + b.Run(name, func(b *testing.B) { + for b.Loop() { + Render(benchSeries, o) + } + }) + } +} + +func BenchmarkMonotoneCubic(b *testing.B) { + for b.Loop() { + monotoneCubic(benchSeries, 128) + } +} diff --git a/internal/chart/example_test.go b/internal/chart/example_test.go new file mode 100644 index 0000000..bf6e100 --- /dev/null +++ b/internal/chart/example_test.go @@ -0,0 +1,34 @@ +package chart_test + +import ( + "fmt" + "strings" + + "github.com/namest504/termtype/internal/chart" +) + +func ExampleSparkline() { + fmt.Println(chart.Sparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, false)) + // Output: ▁▂▃▄▅▆▇█ +} + +func ExampleRender() { + grid := chart.Render([]float64{5, 5}, chart.Options{ + Width: 12, Height: 4, Style: chart.StyleBox, + }) + var b strings.Builder + for _, row := range grid { + var line strings.Builder + for _, cell := range row { + line.WriteRune(cell.Rune) + } + b.WriteString(strings.TrimRight(line.String(), " ")) + b.WriteRune('\n') + } + fmt.Print(b.String()) + // Output: + // 5 ┤ + // 5 ┤ + // ┤─────── + // 5 ┤ +} From bb8aa3caf6d8425d800a8ff736f285142d6e9500 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 17:50:43 +0900 Subject: [PATCH 14/14] test: Make subtests independent and fail fast on starved loops --- cmd/termtype/loop_test.go | 9 +++++++-- cmd/termtype/main_test.go | 2 ++ cmd/termtype/menu_test.go | 11 ++++++++--- internal/chart/bench_test.go | 19 +++++++++++-------- internal/chart/interp_test.go | 2 +- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/cmd/termtype/loop_test.go b/cmd/termtype/loop_test.go index 6c856cf..f33e626 100644 --- a/cmd/termtype/loop_test.go +++ b/cmd/termtype/loop_test.go @@ -9,13 +9,18 @@ import ( ) // 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). +// events. The loops under test consume them in order; the channel is closed +// afterward so an over-consuming loop fails fast on a nil event instead of +// deadlocking. func feedKeys(keys ...*tcell.EventKey) chan tcell.Event { ch := make(chan tcell.Event, len(keys)) for _, k := range keys { ch <- k } + // Close the channel so a loop that over-consumes past the scripted keys + // receives a nil event and returns cleanly instead of deadlocking for + // the package's 10-minute timeout. + close(ch) return ch } diff --git a/cmd/termtype/main_test.go b/cmd/termtype/main_test.go index e72c1f7..f19882e 100644 --- a/cmd/termtype/main_test.go +++ b/cmd/termtype/main_test.go @@ -32,7 +32,9 @@ func TestChartOptionsFor(t *testing.T) { } func TestSummaryLine(t *testing.T) { + prev := ui.IsASCII() ui.SetASCII(false) + t.Cleanup(func() { ui.SetASCII(prev) }) cases := []struct { name string cfg store.Config diff --git a/cmd/termtype/menu_test.go b/cmd/termtype/menu_test.go index 8a61bbf..0e21f26 100644 --- a/cmd/termtype/menu_test.go +++ b/cmd/termtype/menu_test.go @@ -34,14 +34,16 @@ func TestCarouselWraps(t *testing.T) { } func TestExpandSelectCollapse(t *testing.T) { - m := newMenuModel("cozy") t.Run("down expands with selection on current theme", func(t *testing.T) { + m := newMenuModel("cozy") m.handleKey(key(tcell.KeyDown)) if !m.expanded || m.sel != m.idx { t.Errorf("expanded=%v sel=%d idx=%d, want expanded=true and sel==idx", m.expanded, m.sel, m.idx) } }) t.Run("enter picks the selection and collapses", func(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) // expand m.handleKey(key(tcell.KeyDown)) // move selection m.handleKey(key(tcell.KeyEnter)) if m.expanded || m.idx != 1 { @@ -51,14 +53,17 @@ func TestExpandSelectCollapse(t *testing.T) { } func TestExpandedEscCollapsesWithoutQuit(t *testing.T) { - m := newMenuModel("cozy") - m.handleKey(key(tcell.KeyDown)) t.Run("esc while expanded collapses without quitting", func(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) // expand if act := m.handleKey(key(tcell.KeyEscape)); act != actNone || m.expanded { t.Errorf("act = %v, expanded = %v, want actNone and collapsed", act, m.expanded) } }) t.Run("esc while collapsed quits", func(t *testing.T) { + m := newMenuModel("cozy") + m.handleKey(key(tcell.KeyDown)) // expand + m.handleKey(key(tcell.KeyEscape)) // collapse if act := m.handleKey(key(tcell.KeyEscape)); act != actQuit { t.Errorf("act = %v, want actQuit", act) } diff --git a/internal/chart/bench_test.go b/internal/chart/bench_test.go index a8ce976..ec05482 100644 --- a/internal/chart/bench_test.go +++ b/internal/chart/bench_test.go @@ -11,16 +11,19 @@ var benchSeries = func() []float64 { }() func BenchmarkRender(b *testing.B) { - opts := map[string]Options{ - "braille-1px": {Width: 64, Height: 10, Style: StyleBraille, Interp: InterpSmooth, Thickness: 1}, - "braille-3px": {Width: 64, Height: 10, Style: StyleBraille, Interp: InterpSmooth, Thickness: 3}, - "box": {Width: 64, Height: 10, Style: StyleBox, Interp: InterpSmooth}, - "ascii": {Width: 64, Height: 10, Style: StyleASCII}, + opts := []struct { + name string + opts Options + }{ + {"braille-1px", Options{Width: 64, Height: 10, Style: StyleBraille, Interp: InterpSmooth, Thickness: 1}}, + {"braille-3px", Options{Width: 64, Height: 10, Style: StyleBraille, Interp: InterpSmooth, Thickness: 3}}, + {"box", Options{Width: 64, Height: 10, Style: StyleBox, Interp: InterpSmooth}}, + {"ascii", Options{Width: 64, Height: 10, Style: StyleASCII}}, } - for name, o := range opts { - b.Run(name, func(b *testing.B) { + for _, tc := range opts { + b.Run(tc.name, func(b *testing.B) { for b.Loop() { - Render(benchSeries, o) + Render(benchSeries, tc.opts) } }) } diff --git a/internal/chart/interp_test.go b/internal/chart/interp_test.go index 26427d3..cf61030 100644 --- a/internal/chart/interp_test.go +++ b/internal/chart/interp_test.go @@ -49,7 +49,7 @@ func TestSampleSmoothFallsBackBelowThree(t *testing.T) { want := []float64{0, 5, 10} for i := range want { if got[i] != want[i] { - t.Errorf("2-sample smooth[%d] = %v, want %v", i, got, want[i]) + t.Errorf("2-sample smooth[%d] = %v, want %v", i, got[i], want[i]) } } }