package day import ( "bytes" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" "ticktock/internal/store" "ticktock/internal/tui/form" ) type fakeStore struct { days map[string][]store.Entry replaced []string // keys passed to SafeReplace updated []store.Entry // updated entry passed to SafeReplace } func (f *fakeStore) Day(date string) ([]store.Entry, error) { return f.days[date], nil } func (f *fakeStore) Add(store.Entry) error { return nil } func (f *fakeStore) Remove(string) error { return nil } func (f *fakeStore) SafeReplace(key string, updated, _ store.Entry) error { f.replaced = append(f.replaced, key) f.updated = append(f.updated, updated) return nil } func sample() []store.Entry { d := func(h, m int) time.Time { return time.Date(2026, 7, 7, h, m, 0, 0, time.Local) } return []store.Entry{ {Key: "2026-07-07-01", Start: d(9, 1), End: d(9, 51), Project: "ARCHER", Description: "Admin", Tags: []string{"emails"}, Duration: 50 * time.Minute}, {Key: "2026-07-07-02", Start: d(13, 0), End: d(14, 15), Project: "Acme", Description: "Review", Duration: 75 * time.Minute}, } } func TestTotals(t *testing.T) { pt, grand := Totals(sample()) if grand != 125*time.Minute { t.Fatalf("grand total = %v, want 2h5m", grand) } if len(pt) != 2 { t.Fatalf("want 2 project rows, got %d", len(pt)) } } func TestFmtDur(t *testing.T) { cases := map[time.Duration]string{ 50 * time.Minute: "50m", time.Hour + 37*time.Minute: "1h37m", 0: "0m", } for in, want := range cases { if got := fmtDur(in); got != want { t.Errorf("fmtDur(%v) = %q, want %q", in, got, want) } } } func TestFmtTockDur(t *testing.T) { cases := map[time.Duration]string{ 50*time.Minute + 35*time.Second: "50m35s", time.Hour + 37*time.Minute: "1h37m0s", 27*time.Minute + 9*time.Second: "27m9s", 2*time.Hour + 3*time.Second: "2h0m3s", } for in, want := range cases { if got := fmtTockDur(in); got != want { t.Errorf("fmtTockDur(%v) = %q, want %q", in, got, want) } } } func TestFmtRange(t *testing.T) { d := func(h, m int) time.Time { return time.Date(2026, 7, 7, h, m, 0, 0, time.Local) } ended := store.Entry{Start: d(9, 1), End: d(9, 51)} if got := fmtRange(ended); got != "09:01 - 09:51" { t.Errorf("ended range = %q, want %q", got, "09:01 - 09:51") } running := store.Entry{Start: d(9, 51)} // no End got := fmtRange(running) if !bytes.HasPrefix([]byte(got), []byte("09:51 - ")) { t.Errorf("running range = %q, want prefix %q", got, "09:51 - ") } if bytes.Contains([]byte(got), []byte("00:00")) { t.Errorf("running range should not render a zero end time: %q", got) } } func TestDayRendersTableAndTotals(t *testing.T) { fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}} tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}), teatest.WithInitialTermSize(140, 24)) teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("ARCHER")) && bytes.Contains(b, []byte("Acme")) && bytes.Contains(b, []byte("TOTAL")) }, teatest.WithDuration(2*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) } func TestApplyEditCallsSafeReplaceWithKey(t *testing.T) { fs := &fakeStore{days: map[string][]store.Entry{}} orig := sample()[0] // Key 2026-07-07-01 fv := formValues{Start: "09:05", End: "09:55", Project: "ARCHER", Desc: "Admin: done", Tags: "emails, sorted", Note: "inbox zero"} if err := applyEdit(fs, "2026-07-07", orig, fv); err != nil { t.Fatal(err) } if len(fs.replaced) != 1 || fs.replaced[0] != "2026-07-07-01" { t.Fatalf("SafeReplace keys = %v, want [2026-07-07-01]", fs.replaced) } } func TestBuildUpdatedParsesTimesAndTags(t *testing.T) { orig := sample()[0] up, err := buildUpdated("2026-07-07", orig, formValues{Start: "09:05", End: "09:55", Project: "ARCHER", Desc: "x", Tags: "a, b ,", Note: "n"}) if err != nil { t.Fatal(err) } if up.Start.Format("15:04") != "09:05" || up.End.Format("15:04") != "09:55" { t.Errorf("times not parsed: %v–%v", up.Start, up.End) } if len(up.Tags) != 2 || up.Tags[0] != "a" || up.Tags[1] != "b" { t.Errorf("tags = %v, want [a b]", up.Tags) } } // loaded builds a Model with entries already loaded and the cursor at row i. func loaded(entries []store.Entry, i int) Model { fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": entries}} m := New(fs, "2026-07-07", form.Suggest{}) tm, _ := m.Update(entriesMsg{entries: entries}) m = tm.(Model) m.tbl.SetCursor(i) return m } func TestEnterOnEndedRowOpensEditor(t *testing.T) { m := loaded(sample(), 0) // 2026-07-07-01, ended tm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = tm.(Model) if !m.editing { t.Fatal("enter on an ended row should open the editor") } if m.editKey != "2026-07-07-01" { t.Errorf("editKey = %q, want 2026-07-07-01", m.editKey) } if m.form == nil { t.Error("form should be constructed") } } func TestEnterOnRunningRowIsIgnored(t *testing.T) { d := func(h, mi int) time.Time { return time.Date(2026, 7, 7, h, mi, 0, 0, time.Local) } entries := []store.Entry{ {Key: "2026-07-07-01", Start: d(9, 1), End: d(9, 51), Project: "ARCHER", Description: "Admin", Duration: 50 * time.Minute}, {Key: "2026-07-07-02", Start: d(9, 51), Project: "ARCHER", // no End => running Description: "Standup"}, } m := loaded(entries, 1) // cursor on the running row tm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = tm.(Model) if m.editing { t.Fatal("enter on a running row must not open the editor") } } func TestDateNavShiftsAndReloads(t *testing.T) { m := loaded(sample(), 0) tm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) m = tm.(Model) if m.date != "2026-07-06" { t.Errorf("left => date %q, want 2026-07-06", m.date) } if cmd == nil { t.Error("left should return a reload command") } tm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRight}) if got := tm.(Model).date; got != "2026-07-07" { t.Errorf("right => date %q, want 2026-07-07", got) } } // TestEditFieldReachesStore drives the real Bubble Tea loop: open the editor, // retype the start field, submit, and assert the *typed* value reaches // SafeReplace. Guards the value-receiver copy bug where huh wrote edits into a // stale Model copy and applyEdit re-saved the unchanged original. func TestEditFieldReachesStore(t *testing.T) { fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}} tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}), teatest.WithInitialTermSize(140, 24)) // Wait for the table, then open the editor on row 0 (2026-07-07-01). teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("ARCHER")) }, teatest.WithDuration(2*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Wait for the huh form (its field titles render). teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("start (HH:MM)")) }, teatest.WithDuration(2*time.Second)) // Start is prefilled "09:01"; clear it and type "08:00". for i := 0; i < 6; i++ { tm.Send(tea.KeyMsg{Type: tea.KeyBackspace}) } for _, r := range "08:00" { tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } // Advance through the 6 fields and submit (enter completes on the last). for i := 0; i < 6; i++ { tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) } teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("updated 2026-07-07-01")) }, teatest.WithDuration(2*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) if len(fs.updated) != 1 { t.Fatalf("SafeReplace calls = %d, want 1", len(fs.updated)) } if got := fs.updated[0].Start.Format("15:04"); got != "08:00" { t.Errorf("stored start = %q, want 08:00 (typed edit did not reach the store)", got) } } // TestCtrlSSubmitsFromAnyField retypes the start field then submits with Ctrl+S // WITHOUT tabbing to the last field, and asserts the typed value is stored. func TestCtrlSSubmitsFromAnyField(t *testing.T) { fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}} tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}), teatest.WithInitialTermSize(140, 24)) teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("ARCHER")) }, teatest.WithDuration(2*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("start (HH:MM)")) }, teatest.WithDuration(2*time.Second)) for i := 0; i < 6; i++ { tm.Send(tea.KeyMsg{Type: tea.KeyBackspace}) } for _, r := range "08:00" { tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } // Submit from the first field — no tabbing through the rest. tm.Send(tea.KeyMsg{Type: tea.KeyCtrlS}) teatest.WaitFor(t, tm.Output(), func(b []byte) bool { return bytes.Contains(b, []byte("updated 2026-07-07-01")) }, teatest.WithDuration(2*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) if len(fs.updated) != 1 { t.Fatalf("SafeReplace calls = %d, want 1", len(fs.updated)) } if got := fs.updated[0].Start.Format("15:04"); got != "08:00" { t.Errorf("stored start = %q, want 08:00", got) } } func TestEmptyDayRendersNoEntries(t *testing.T) { m := loaded(nil, 0) view := m.View() if !bytes.Contains([]byte(view), []byte("no entries")) { t.Errorf("empty day view missing 'no entries':\n%s", view) } if !bytes.Contains([]byte(view), []byte("TOTAL 0m")) { t.Errorf("empty day view missing 'TOTAL 0m':\n%s", view) } }