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) } // requireCorpus skips the test when corpus `code` is not embedded in this // build. The optional corpora (wuj, drb, grb) compile in only with // `-tags fullbible` (or when dropped into the user corpora dir); mirrors the // helper in internal/bible so these tests run under fullbible and skip -- not // fail -- on the default vul-only build. func requireCorpus(t *testing.T, code string) { t.Helper() if _, ok := bible.Meta(code); !ok { t.Skipf("corpus %q not embedded; build with -tags fullbible", code) } } 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 TestReaderBookmarkTagFilter(t *testing.T) { m := enReader(t) m.marks = []bookmarks.Bookmark{ {ID: "1", Book: "John", Chapter: 3, Verse: 16, Tags: []string{"faith"}}, {ID: "2", Book: "Psalms", Chapter: 23, Verse: 1, Tags: []string{"work", "prayer"}}, {ID: "3", Book: "Matthew", Chapter: 5, Verse: 3, Tags: []string{"prayer"}}, } m.mode = modeBookmarks if got := len(m.visMarks()); got != 3 { t.Fatalf("unfiltered visible = %d, want 3", got) } // "/" enters filter input; typing narrows to the two 'prayer' bookmarks. m = key(m, runes("/")) if !m.markFiltering { t.Fatal(`"/" did not enter filter input`) } for _, c := range []string{"p", "r", "a", "y"} { m = key(m, runes(c)) } if got := len(m.visMarks()); got != 2 { t.Fatalf(`filter "pray" visible = %d, want 2 (Psalms, Matthew)`, got) } // Enter keeps the filter and leaves input mode. m = key(m, tea.KeyMsg{Type: tea.KeyEnter}) if m.markFiltering || m.markFilter != "pray" { t.Fatalf("after enter: filtering=%v filter=%q", m.markFiltering, m.markFilter) } // Case-insensitive substring: "WORK" matches the 'work' tag (1 bookmark). m.markFilter = "WORK" if got := len(m.visMarks()); got != 1 { t.Fatalf(`filter "WORK" visible = %d, want 1`, got) } // Esc clears an active filter, back to all. m.markFilter = "work" m.markFiltering = false m = key(m, tea.KeyMsg{Type: tea.KeyEsc}) if m.markFilter != "" { t.Fatalf("esc did not clear filter, got %q", m.markFilter) } if got := len(m.visMarks()); got != 3 { t.Fatalf("after clearing filter, visible = %d, want 3", got) } } 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")) // Fuzzy filtering and entering read mode come from the book table and the // model's state machine -- independent of any corpus being embedded. 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") } // The reader's first version is wuj (see enReader): its verses and chapter // header need the optional wuj corpus embedded. t.Run("wuj verses", func(t *testing.T) { requireCorpus(t, "wuj") 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) { // Whole-test guard: the reader's first version is wuj (see enReader), so the // very first read-mode step below indexes m.chapters[m.chapPos] -- which // panics on an empty chapter list when wuj is not embedded. The chapter // stepping and version cycling that follow are all a wuj reading session. requireCorpus(t, "wuj") 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) { // The whole flow reads and bookmarks verses of John from wuj (the reader's // first version); "m" only opens the verse picker when len(verses)>0, so // the optional wuj corpus must be embedded. requireCorpus(t, "wuj") 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) { // Reads John from wuj (the reader's first version); "c" only opens the // chapter-jump prompt when the book has >1 chapter loaded, so the optional // wuj corpus must be embedded. requireCorpus(t, "wuj") 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) { // Persisting/restoring a reading position needs real verses (savePlace // no-ops when len(verses)==0); the reader's first version is wuj, so the // optional wuj corpus must be embedded. requireCorpus(t, "wuj") 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()) } }