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") } }