package tui import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" ) func enReader(t *testing.T) ReaderModel { t.Helper() t.Setenv("XDG_DATA_HOME", t.TempDir()) // isolate bookmarks + last-place files tbl, err := bible.LoadBookTable(nil) if err != nil { t.Fatal(err) } cfg := config.Default() cfg.UILanguage = "en" cfg.SiglaStyle = "english" cfg.Versions = []string{"bt", "wuj", "vul", "grb", "drb"} return NewReader(cfg, tbl, bookmarks.Open()) } func key(m ReaderModel, k tea.KeyMsg) ReaderModel { nm, _ := m.Update(k) return nm.(ReaderModel) } func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } func win(m ReaderModel, w, h int) ReaderModel { nm, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) return nm.(ReaderModel) } func TestReaderInit(t *testing.T) { m := enReader(t) if m.mode != modePick { t.Errorf("mode = %v want modePick", m.mode) } if len(m.matches) != len(m.books) || len(m.books) != 73 { t.Errorf("matches=%d books=%d", len(m.matches), len(m.books)) } if len(m.versions) == 0 || m.versions[0] == "bt" { t.Errorf("versions = %v (bt must be excluded, non-empty)", m.versions) } } func TestReaderFilterAndOpen(t *testing.T) { m := enReader(t) nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = nm.(ReaderModel) m = key(m, runes("jn")) if len(m.matches) == 0 || m.books[m.matches[0]].Canonical != "John" { t.Fatalf("filter 'jn' top = %v", m.books[m.matches[m.pickSel]]) } m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.mode != modeRead { t.Fatalf("did not enter read mode") } if m.books[m.bookIdx].Canonical != "John" || len(m.verses) == 0 { t.Errorf("opened book=%q verses=%d", m.books[m.bookIdx].Canonical, len(m.verses)) } if !strings.Contains(m.View(), "1") { // chapter 1 header t.Errorf("read view missing chapter:\n%s", m.View()) } } func TestReaderChapterAndVersion(t *testing.T) { m := enReader(t) nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) m = nm.(ReaderModel) m = key(m, runes("jn")) m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) firstChap := m.chapters[m.chapPos] m = key(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) // next chapter if m.chapters[m.chapPos] <= firstChap { t.Errorf("chapter did not advance: %d -> %d", firstChap, m.chapters[m.chapPos]) } v0 := m.verIdx m = key(m, tea.KeyMsg{Type: tea.KeyTab}) if m.verIdx == v0 { t.Errorf("version did not cycle") } // esc returns to picker m = key(m, tea.KeyMsg{Type: tea.KeyEsc}) if m.mode != modePick { t.Errorf("esc did not return to picker") } } func TestReaderFuzzyScore(t *testing.T) { if s, ok := fuzzyScore("gen", "Gen Genesis"); !ok || s < 500 { t.Errorf("substring score = %d,%v", s, ok) } if _, ok := fuzzyScore("xyz", "Gen Genesis"); ok { t.Error("xyz should not match Genesis") } // subsequence: g..s..s across the string if _, ok := fuzzyScore("gss", "Gen Genesis"); !ok { t.Error("gss should subsequence-match") } } func TestReaderBookmarkFlow(t *testing.T) { m := enReader(t) m = win(m, 80, 24) m = key(m, runes("jn")) m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // open John 1 if m.mode != modeRead { t.Fatal("did not enter reading") } // mark: m -> pick a verse -> note box -> enter m = key(m, runes("m")) if m.mode != modeMarkVerse { t.Fatal("m did not enter the verse picker") } m = key(m, runes("j")) // move the cursor down to John 1:2 m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.mode != modeMark { t.Fatal("enter did not open the note box") } m = key(m, runes("hi")) // note field m = key(m, tea.KeyMsg{Type: tea.KeyTab}) // switch to tags field m = key(m, runes("grace")) // tags field m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // save if m.mode != modeRead { t.Fatal("enter did not save + return to reading") } // list: b m = key(m, runes("b")) if m.mode != modeBookmarks || len(m.marks) != 1 { t.Fatalf("bookmarks mode=%v n=%d", m.mode, len(m.marks)) } if m.marks[0].Book != "John" || m.marks[0].Verse != 2 || m.marks[0].Note != "hi" || len(m.marks[0].Tags) != 1 || m.marks[0].Tags[0] != "grace" { t.Errorf("mark = %+v (want John 1:2 note hi tag grace)", m.marks[0]) } if !strings.Contains(m.View(), "John") { t.Errorf("bookmarks view missing John:\n%s", m.View()) } // open the bookmark m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.mode != modeRead || m.books[m.bookIdx].Canonical != "John" { t.Errorf("jump failed: mode=%v book=%q", m.mode, m.books[m.bookIdx].Canonical) } // delete via list, with confirmation m = key(m, runes("b")) m = key(m, runes("d")) if !m.confirmDelete { t.Fatal("d did not ask for confirmation") } m = key(m, runes("n")) // cancel if m.confirmDelete || len(m.marks) != 1 { t.Fatalf("n did not cancel (confirm=%v n=%d)", m.confirmDelete, len(m.marks)) } m = key(m, runes("d")) m = key(m, runes("y")) // confirm if len(m.marks) != 0 { t.Errorf("delete failed, %d remaining", len(m.marks)) } } func TestReaderChapterJump(t *testing.T) { m := enReader(t) m = win(m, 80, 24) m = key(m, runes("jn")) m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // open John 1 (21 chapters) if m.mode != modeRead { t.Fatal("did not enter reading") } // c opens the jump prompt m = key(m, runes("c")) if m.mode != modeChapterJump { t.Fatalf("c did not open the chapter-jump prompt (mode=%v)", m.mode) } if hi := m.chapters[len(m.chapters)-1]; hi != 21 { t.Fatalf("John should have 21 chapters, got %d", hi) } if v := m.View(); !strings.Contains(v, "1-21") || !strings.Contains(v, "go to chapter") { t.Errorf("jump prompt missing range/label:\n%s", v) } // non-digit keys are ignored; only digits accumulate m = key(m, runes("x1")) m = key(m, runes("4")) if m.chapJumpBuf != "14" { t.Fatalf("buffer = %q want 14 (non-digits filtered)", m.chapJumpBuf) } m = key(m, tea.KeyMsg{Type: tea.KeyBackspace}) m = key(m, runes("2")) // -> "12" m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.mode != modeRead { t.Fatalf("enter did not return to reading (mode=%v)", m.mode) } if m.currentChapter() != 12 { t.Errorf("jumped to chapter %d want 12", m.currentChapter()) } if m.scroll != 0 { t.Errorf("jump should reset scroll, got %d", m.scroll) } // out-of-range clamps to the last chapter m = key(m, runes("c")) m = key(m, runes("999")) m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.currentChapter() != 21 { t.Errorf("999 should clamp to 21, got %d", m.currentChapter()) } // esc cancels without moving m = key(m, runes("c")) m = key(m, runes("3")) m = key(m, tea.KeyMsg{Type: tea.KeyEsc}) if m.mode != modeRead { t.Fatalf("esc did not return to reading (mode=%v)", m.mode) } if m.currentChapter() != 21 { t.Errorf("esc changed chapter to %d want 21", m.currentChapter()) } } func TestReaderRemembersPlace(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_DATA_HOME", dir) tbl, _ := bible.LoadBookTable(nil) cfg := config.Default() cfg.UILanguage = "en" cfg.SiglaStyle = "english" cfg.Versions = []string{"wuj", "vul", "grb", "drb"} m := NewReader(cfg, tbl, bookmarks.Open()) m = win(m, 80, 24) m = key(m, runes("jn")) m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) // John 1 (openBook saves place) m = key(m, runes("n")) // -> John 2 (chapterStep saves place) m2 := NewReader(cfg, tbl, bookmarks.Open()) if m2.mode != modeRead { t.Fatalf("did not restore reading mode") } if m2.books[m2.bookIdx].Canonical != "John" { t.Errorf("restored book = %q want John", m2.books[m2.bookIdx].Canonical) } if m2.currentChapter() != 2 { t.Errorf("restored chapter = %d want 2", m2.currentChapter()) } }