gitFlow/internal/tui/tui_test.go
dimitar 3d389ac3e8 feat: M3 — TUI interaction (periodic scan, countdown, change markers)
Replace the static-scan boot in the TUI with a reactive scan loop anchored
on bubbletea.Tick and user-driven 'r' rescan.

Scan loop (tea.Tick instead of goroutine — no leak):
- Init returns a tick Cmd when --interval > 0; on each tick the model
  fires scanCmd and chains the next tick in the Update handler, producing
  a self-regulating interval loop that stops naturally on quit
- 'r' key triggers an immediate rescan when the model is not already
  loading, for on-demand refresh
- scanResultMsg handler records prevResult, computes status.Changed into a
  changed set (keyed by repo path), and updates lastScan
- scanErrorMsg stores the error text on the model (displayed in the status
  bar) without stopping the loop

Change detection in the view:
- Repos whose state changed since the previous scan get a ▲ prefix (yellow
  bold) in the repo list, distinguishing them from the > cursor

Status bar improvements:
- Countdown "next: 23s" when --interval is active, computed from
  time.Since(lastScan); "scanning…" shown during async scans
- Updated keybinding hints: r refresh, g/G home/end

Navigation additions:
- g/Home jumps to top, G/End jumps to bottom

Styles:
- Changed style (yellow bold foreground) for ▲ markers
- Styles struct now includes all five variants

Tests:
- Home/End keys navigate to first/last repo
- 'r' fires scanCmd when not loading
- ▲ marker appears for changed repos
- Interval tick fires scan when idle
- Countdown "next:" shown in status bar

Verified: go build, go vet, go test -race (12 packages), gofmt clean.
2026-08-02 09:10:56 +02:00

232 lines
6.2 KiB
Go

package tui
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbletea"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func newTestModel() Model {
return newTestModelWith(status.ScanResult{
ScannedAt: time.Now(),
ParentDir: "/tmp/test",
Repos: []status.RepoInfo{
{Path: "/tmp/test/a", Name: "a", Branch: "main", Status: status.StatusClean},
{Path: "/tmp/test/b", Name: "b", Branch: "feat/x", Status: status.StatusModified, ModifiedFiles: []string{"f.go"}},
{Path: "/tmp/test/c", Name: "c", Status: status.StatusError, Error: "boom"},
},
})
}
func newTestModelWith(result status.ScanResult) Model {
return Model{
app: nil,
cfg: &config.Config{Dir: "/tmp/test", Format: "table", Color: "auto", Theme: "dark", Workers: 4},
opts: presenter.Options{Color: presenter.ColorNever, Theme: presenter.ThemeDark},
result: result,
cursor: 0,
first: true,
styles: NewStyles(presenter.Options{Theme: presenter.ThemeDark}),
}
}
func TestModelNavigation(t *testing.T) {
m := newTestModel()
// Move down
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
if m2.(Model).cursor != 1 {
t.Errorf("cursor = %d after down, want 1", m2.(Model).cursor)
}
// Move down again
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyDown})
if m3.(Model).cursor != 2 {
t.Errorf("cursor = %d after down, want 2", m3.(Model).cursor)
}
// At bottom: cursor stays
m4, _ := m3.Update(tea.KeyMsg{Type: tea.KeyDown})
if m4.(Model).cursor != 2 {
t.Errorf("cursor = %d after down (at bottom), want 2", m4.(Model).cursor)
}
// Move up
m5, _ := m4.Update(tea.KeyMsg{Type: tea.KeyUp})
if m5.(Model).cursor != 1 {
t.Errorf("cursor = %d after up, want 1", m5.(Model).cursor)
}
// At top: cursor stays
m6, _ := m5.Update(tea.KeyMsg{Type: tea.KeyUp})
m6, _ = m6.Update(tea.KeyMsg{Type: tea.KeyUp})
if m6.(Model).cursor != 0 {
t.Errorf("cursor = %d after up (at top), want 0", m6.(Model).cursor)
}
}
func TestModelJKKeys(t *testing.T) {
m := newTestModel()
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
if m2.(Model).cursor != 1 {
t.Errorf("cursor = %d after j, want 1", m2.(Model).cursor)
}
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
if m3.(Model).cursor != 0 {
t.Errorf("cursor = %d after k, want 0", m3.(Model).cursor)
}
}
func TestModelDetailToggle(t *testing.T) {
m := newTestModel()
if m.detail {
t.Error("detail = true initially, want false")
}
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if !m2.(Model).detail {
t.Error("detail = false after Enter, want true")
}
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeySpace})
if m3.(Model).detail {
t.Error("detail = true after Space, want false")
}
}
func TestModelHomeEndKeys(t *testing.T) {
m := newTestModel()
// Start at 0, go to end with G
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
if m2.(Model).cursor != 2 {
t.Errorf("cursor = %d after G, want 2 (last)", m2.(Model).cursor)
}
// Go to home with g
m3, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
if m3.(Model).cursor != 0 {
t.Errorf("cursor = %d after g, want 0 (home)", m3.(Model).cursor)
}
}
func TestModelHelpToggle(t *testing.T) {
m := newTestModel()
if m.help {
t.Error("help = true initially, want false")
}
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
if !m2.(Model).help {
t.Error("help = false after ?, want true")
}
}
func TestModelRescanR(t *testing.T) {
m := newTestModel()
if m.loading {
t.Error("loading = true initially, want false")
}
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
if cmd == nil {
t.Error("'r' produced nil cmd, want scanCmd")
}
}
func TestModelChangedMarkers(t *testing.T) {
m := newTestModel()
m.first = false
m.changed = map[string]bool{"/tmp/test/b": true}
view := m.View()
if !strings.Contains(view, "▲") {
t.Errorf("view missing ▲ marker for changed repo:\n%s", view)
}
}
func TestModelIntervalTickInitiatesScan(t *testing.T) {
m := newTestModel()
m.interval = 10 * time.Second
m.first = false
m.loading = false
// A tick fires a scan when not already loading.
_ = m
_, cmd := m.Update(scanTickMsg{time.Now()})
if cmd == nil {
t.Error("scanTickMsg produced nil cmd, want scanCmd")
}
}
func TestModelIntervalShownInStatusBar(t *testing.T) {
m := newTestModel()
m.interval = 5 * time.Second
m.lastScan = time.Now()
view := m.View()
if !strings.Contains(view, "next:") {
t.Errorf("status bar missing countdown:\n%s", view)
}
}
func TestModelQuit(t *testing.T) {
m := newTestModel()
for _, key := range []tea.KeyType{tea.KeyCtrlC} {
_, cmd := m.Update(tea.KeyMsg{Type: key})
if cmd == nil {
t.Errorf("no quit cmd for key %v", key)
continue
}
// tea.Quit is a function that returns a tea.QuitMsg
if cmd() == nil {
t.Errorf("quit cmd produced nil msg")
}
}
// 'q' quit
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
if cmd == nil {
t.Error("no quit cmd for 'q'")
}
}
func TestModelViewRendersRepos(t *testing.T) {
m := newTestModel()
view := m.View()
for _, want := range []string{"a", "b", "c", "feat/x", "STATUS", "BRANCH"} {
if !strings.Contains(view, want) {
t.Errorf("view missing %q:\n%s", want, view)
}
}
}
func TestModelViewShowsHelp(t *testing.T) {
m := newTestModel()
m.help = true
view := m.View()
if !strings.Contains(view, "KEYBINDINGS") {
t.Errorf("help view missing KEYBINDINGS:\n%s", view)
}
}
func TestModelViewShowsDetail(t *testing.T) {
m := newTestModel()
m.cursor = 1 // select repo 'b'
m.detail = true
view := m.View()
if !strings.Contains(view, "modified:") || !strings.Contains(view, "f.go") {
t.Errorf("detail view missing file:\n%s", view)
}
}
func TestModelViewLoading(t *testing.T) {
m := newTestModel()
m.loading = true
view := m.View()
if !strings.Contains(view, "scanning") {
t.Errorf("loading view missing 'scanning':\n%s", view)
}
}
func TestModelViewEmpty(t *testing.T) {
m := newTestModelWith(status.ScanResult{Repos: nil})
view := m.View()
if !strings.Contains(view, "no repositories") {
t.Errorf("empty view missing 'no repositories':\n%s", view)
}
}