package grid import ( "os" "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "ticktock/internal/activity" "ticktock/internal/config" "ticktock/internal/store" "ticktock/internal/tui/form" ) type fakeStore struct { entries []store.Entry added []store.Entry removed []string repl []store.Entry } func (f *fakeStore) Day(string) ([]store.Entry, error) { return f.entries, nil } func (f *fakeStore) Add(e store.Entry) error { f.added = append(f.added, e); return nil } func (f *fakeStore) Remove(k string) error { f.removed = append(f.removed, k); return nil } func (f *fakeStore) SafeReplace(k string, u, o store.Entry) error { f.repl = append(f.repl, u) return nil } func cfg() config.Config { return config.Default() } func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } func special(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } // loaded returns a model that has already ingested its entries. func loaded(f *fakeStore) Model { m := New(f, cfg(), "2026-07-07", form.Suggest{}) nm, _ := m.Update(entriesMsg{entries: f.entries}) return nm.(Model) } func TestColumnToggle(t *testing.T) { m := loaded(&fakeStore{}) if m.col != ColActivity { t.Fatalf("default column should be ACTIVITY") } nm, _ := m.Update(special(tea.KeyRight)) if nm.(Model).col != ColLogged { t.Errorf("right should switch to LOGGED") } nm, _ = nm.(Model).Update(special(tea.KeyLeft)) if nm.(Model).col != ColActivity { t.Errorf("left should switch back to ACTIVITY") } } func TestCursorMovesWithinBounds(t *testing.T) { m := loaded(&fakeStore{}) up, _ := m.Update(special(tea.KeyUp)) if up.(Model).cursor != 0 { t.Errorf("cursor should clamp at 0 going up") } dn, _ := m.Update(special(tea.KeyDown)) if dn.(Model).cursor != 1 { t.Errorf("down should move to row 1") } } func TestSpaceAnchorsThenExtends(t *testing.T) { m := loaded(&fakeStore{}) // space anchors at row 0 (ACTIVITY column) nm, _ := m.Update(key(" ")) mm := nm.(Model) if mm.anchor == nil || *mm.anchor != 0 { t.Fatalf("space should anchor at cursor 0") } nm, _ = mm.Update(special(tea.KeyDown)) mm = nm.(Model) lo, hi, active := mm.selectionRange() if !active || lo != 0 || hi != 1 { t.Errorf("range after down = %d..%d active=%v, want 0..1 true", lo, hi, active) } } func TestEscClearsSelection(t *testing.T) { m := loaded(&fakeStore{}) nm, _ := m.Update(key(" ")) nm, _ = nm.(Model).Update(special(tea.KeyEsc)) if _, _, active := nm.(Model).selectionRange(); active { t.Errorf("esc should clear selection") } } func TestDayNavReloads(t *testing.T) { f := &fakeStore{} m := loaded(f) nm, cmd := m.Update(key("p")) if nm.(Model).date != "2026-07-06" { t.Errorf("p should go to previous day, got %s", nm.(Model).date) } if cmd == nil { t.Errorf("day nav should issue a reload cmd") } // n goes forward: from 2026-07-06 back to 2026-07-07 nm, _ = nm.(Model).Update(key("n")) if nm.(Model).date != "2026-07-07" { t.Errorf("n should go to next day, got %s", nm.(Model).date) } nm, _ = nm.(Model).Update(key("t")) // t jumps to today; just assert it changed away from 2026-07-07 if nm.(Model).date == "2026-07-06" { t.Errorf("t should jump to today") } _ = time.Now } func TestViewRendersColumns(t *testing.T) { f := &fakeStore{entries: []store.Entry{{ Key: "2026-07-07-01", Start: at(8, 0), End: at(8, 30), Description: "standup", Duration: 30 * 60 * 1e9, }}} out := loaded(f).View() if !strings.Contains(out, "TIME") || !strings.Contains(out, "LOGGED") { t.Errorf("view missing column headers:\n%s", out) } if !strings.Contains(out, "standup") { t.Errorf("view missing logged label:\n%s", out) } } func TestEntriesMsgPaintsActivityLane(t *testing.T) { m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{}) segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}} events := []activity.Event{{Start: at(9, 0), End: at(9, 30), Title: "Standup", Calendar: "Archer"}} nm, _ := m.Update(entriesMsg{segs: segs, events: events}) got := nm.(Model) if len(got.activity) != got.grid.Rows { t.Fatalf("activity lane not painted: %d cells for %d rows", len(got.activity), got.grid.Rows) } if got.activity[0].Kind != CellLabel || got.activity[0].Act != ActActive { t.Errorf("row0 = %+v, want active label", got.activity[0]) } view := got.View() if !strings.Contains(view, "notes") { t.Errorf("view should render the activity label, got:\n%s", view) } if !strings.Contains(view, "▪ Standup") { t.Errorf("view should render the calendar label, got:\n%s", view) } } func TestSelectionBandStillOverridesActivity(t *testing.T) { m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{}) segs := []activity.Segment{{Start: at(7, 0), End: at(8, 0), App: "Trace", Title: "notes"}} nm, _ := m.Update(entriesMsg{segs: segs}) got := nm.(Model) a := 0 got.anchor = &a got.cursor = 1 view := got.View() if !strings.Contains(view, "▓") { t.Errorf("selection band must still render over the activity lane:\n%s", view) } if strings.Contains(view, "notes") { t.Errorf("banded rows should not leak the activity label:\n%s", view) } } func TestLoadFetchesActivityFromSource(t *testing.T) { dir := t.TempDir() line := `{"start": "2026-07-07T07:10:00", "end": "2026-07-07T07:40:00", "secs": 1800, "app": "Trace", "title": "notes", "url": "", "domain": "", "idle": false}` + "\n" if err := os.WriteFile(filepath.Join(dir, "activity-2026-07-07.jsonl"), []byte(line), 0o644); err != nil { t.Fatal(err) } m := New(&fakeStore{}, cfg(), "2026-07-07", form.Suggest{}). WithActivity(ActivitySource{DataDir: dir}) msg := m.load()() em, ok := msg.(entriesMsg) if !ok { t.Fatalf("load returned %T", msg) } if len(em.segs) != 1 || em.segs[0].App != "Trace" { t.Fatalf("load should fetch the day's segments, got %+v", em.segs) } } func TestVerticalViewportScrollsAndKeepsHeader(t *testing.T) { m := loaded(&fakeStore{}) // Short window: height 12 → visibleRows = 12 - chromeRows(7) = 5. nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) top := nm.(Model).View() if !strings.Contains(top, "ACTIVITY") { t.Fatalf("header must always render:\n%s", top) } if !strings.Contains(top, "07:00") { t.Fatalf("first slot should be visible at top:\n%s", top) } if strings.Contains(top, "20:30") { t.Fatalf("last slot should be scrolled out of a short window:\n%s", top) } // Drive the cursor to the bottom; the window must follow it. cur := nm for i := 0; i < 40; i++ { cur, _ = cur.(Model).Update(special(tea.KeyDown)) } bot := cur.(Model).View() if !strings.Contains(bot, "ACTIVITY") { t.Fatalf("header must stay visible after scrolling:\n%s", bot) } if !strings.Contains(bot, "20:30") { t.Fatalf("bottom slot should be visible after scrolling down:\n%s", bot) } if strings.Contains(bot, "07:00") { t.Fatalf("top slot should be scrolled off when at the bottom:\n%s", bot) } }