Files
pitodo/app_test.go
T
2026-11-04 17:18:09 +01:00

243 lines
7.6 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"todoapp/internal/todos"
)
func testKey(m model, key tea.KeyMsg) model {
updated, _ := m.Update(key)
return updated.(model)
}
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
func typedKey(r rune) tea.KeyMsg {
if r == ' ' {
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}
}
return runeKey(r)
}
func TestNewModelWithoutTodos(t *testing.T) {
project := t.TempDir()
app, err := newModel(project)
if err != nil {
t.Fatal(err)
}
if app.todosDir != filepath.Join(project, ".pi", "todos") || len(app.items) != 0 {
t.Fatalf("unexpected model: %+v", app)
}
if !strings.Contains(app.View(), "No todos yet") {
t.Fatalf("missing empty state: %s", app.View())
}
}
func TestNewModelWithTodosAndSearch(t *testing.T) {
project := t.TempDir()
dir := todos.Dir(project)
if _, err := todos.Create(dir, todos.Todo{Title: "Implement feature", Tags: []string{"go"}, Body: "# Details\n\nBody text."}); err != nil {
t.Fatal(err)
}
if _, err := todos.Create(dir, todos.Todo{Title: "Celebrate", Status: "closed"}); err != nil {
t.Fatal(err)
}
app, err := newModel(project)
if err != nil || len(app.items) != 2 {
t.Fatalf("unexpected result: %+v, %v", app, err)
}
if !strings.Contains(app.View(), "Implement feature") || !strings.Contains(app.View(), "Celebrate") {
t.Fatalf("list missing titles: %s", app.View())
}
app = testKey(app, runeKey('/'))
for _, r := range "celebrate" {
app = testKey(app, runeKey(r))
}
if len(app.filtered()) != 1 || !strings.Contains(app.View(), "Celebrate") || strings.Contains(app.View(), "Implement feature") {
t.Fatalf("search failed: %s", app.View())
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnter})
if !app.detail || !strings.Contains(app.View(), "No description") {
t.Fatalf("detail missing: %s", app.View())
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
if app.detail || app.query != "" {
t.Fatalf("could not return and clear search: %+v", app)
}
}
func TestDetailScrollingAndNavigation(t *testing.T) {
app := model{width: 40, height: 12, items: []todos.Todo{
{ID: "deadbeef", Title: "First", Body: "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n"},
{ID: "cafebabe", Title: "Second", Body: "# Another body"},
}}
app = testKey(app, tea.KeyMsg{Type: tea.KeyDown})
if app.selected != 1 {
t.Fatalf("selection did not move: %d", app.selected)
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyUp})
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnter})
if !strings.Contains(app.View(), "line 1") || strings.Contains(app.View(), "line 8") {
t.Fatalf("expected paged detail: %s", app.View())
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyPgDown})
if app.scroll == 0 || !strings.Contains(app.View(), "line 2") {
t.Fatalf("detail did not scroll: %s", app.View())
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnd})
if !strings.Contains(app.View(), "line 8") {
t.Fatalf("detail cannot reach final line: %s", app.View())
}
}
func TestRefreshReadsNewTodos(t *testing.T) {
project := t.TempDir()
app, err := newModel(project)
if err != nil {
t.Fatal(err)
}
if _, err := todos.Create(todos.Dir(project), todos.Todo{Title: "New"}); err != nil {
t.Fatal(err)
}
app = testKey(app, runeKey('r'))
if len(app.items) != 1 || !strings.Contains(app.View(), "New") {
t.Fatalf("refresh missed todo: %s", app.View())
}
}
func TestCreateEditAndStatusFlows(t *testing.T) {
project := t.TempDir()
app, err := newModel(project)
if err != nil {
t.Fatal(err)
}
app = testKey(app, runeKey('n'))
for _, r := range "New todo" {
app = testKey(app, typedKey(r))
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyTab})
for _, r := range "qa, go" {
app = testKey(app, typedKey(r))
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyTab})
for _, r := range "First line" {
app = testKey(app, typedKey(r))
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnter})
for _, r := range "Second" {
app = testKey(app, runeKey(r))
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyCtrlS})
if app.editor != nil || len(app.items) != 1 || app.items[0].Title != "New todo" || app.items[0].Body != "First line\nSecond\n" || len(app.items[0].Tags) != 2 {
t.Fatalf("create failed: %+v", app)
}
id := app.items[0].ID
app = testKey(app, runeKey('/'))
for _, r := range "New todo" {
app = testKey(app, typedKey(r))
}
if app.query != "New todo" || len(app.filtered()) != 1 {
t.Fatalf("search with spaces failed: %q", app.query)
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
app = testKey(app, runeKey('e'))
app = testKey(app, tea.KeyMsg{Type: tea.KeyCtrlU})
for _, r := range "Edited" {
app = testKey(app, runeKey(r))
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyCtrlS})
if app.items[0].Title != "Edited" || app.items[0].ID != id {
t.Fatalf("edit failed: %+v", app.items)
}
app = testKey(app, runeKey('o'))
if app.confirm != "" || app.items[0].Status != "open" {
t.Fatal("reopen on open todo should be a no-op")
}
app = testKey(app, runeKey('x'))
if app.confirm != "close" || app.items[0].Status != "open" {
t.Fatal("close should require confirmation")
}
app = testKey(app, runeKey('n'))
if app.confirm != "" || app.items[0].Status != "open" {
t.Fatal("cancel changed status")
}
app = testKey(app, runeKey('x'))
app = testKey(app, runeKey('y'))
if app.items[0].Status != "closed" {
t.Fatalf("close failed: %+v", app.items)
}
app = testKey(app, runeKey('o'))
app = testKey(app, runeKey('y'))
if app.items[0].Status != "open" {
t.Fatalf("reopen failed: %+v", app.items)
}
}
func TestDiscardNeedsConfirmation(t *testing.T) {
app, err := newModel(t.TempDir())
if err != nil {
t.Fatal(err)
}
app = testKey(app, runeKey('n'))
app = testKey(app, runeKey('a'))
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
if app.confirm != "discard" || app.editor == nil {
t.Fatal("unsaved changes were discarded without confirmation")
}
app = testKey(app, runeKey('n'))
if app.editor == nil || app.confirm != "" {
t.Fatal("cancel should return to editor")
}
app = testKey(app, tea.KeyMsg{Type: tea.KeyEsc})
app = testKey(app, runeKey('y'))
if app.editor != nil || len(app.items) != 0 {
t.Fatal("discard wrote a todo")
}
}
func TestMalformedFileDoesNotHideGoodTodos(t *testing.T) {
project := t.TempDir()
dir := todos.Dir(project)
if _, err := todos.Create(dir, todos.Todo{Title: "Good"}); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte("not JSON"), 0600); err != nil {
t.Fatal(err)
}
app, err := newModel(project)
if err != nil || len(app.items) != 1 || len(app.warnings) != 1 || !strings.Contains(app.View(), "Good") || !strings.Contains(app.View(), "Warning") {
t.Fatalf("unexpected malformed handling: %+v, %v", app, err)
}
}
func TestInvalidProjectAndTodoPath(t *testing.T) {
project := t.TempDir()
if _, err := newModel(filepath.Join(project, "missing")); err == nil || !strings.Contains(err.Error(), "project directory") {
t.Fatalf("expected missing project error, got %v", err)
}
if err := os.Mkdir(filepath.Join(project, ".pi"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(project, ".pi", "todos"), nil, 0600); err != nil {
t.Fatal(err)
}
if _, err := newModel(project); err == nil || !strings.Contains(err.Error(), "load todos") {
t.Fatalf("expected todo directory error, got %v", err)
}
}
func TestQuitKey(t *testing.T) {
app := model{}
_, cmd := app.Update(runeKey('q'))
if cmd == nil || cmd() != tea.Quit() {
t.Fatal("q should quit")
}
}