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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
107 changes: 107 additions & 0 deletions cmd/termtype/draw_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
127 changes: 127 additions & 0 deletions cmd/termtype/loop_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading