package cli import ( "bytes" "strings" "testing" "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) func TestHelp(t *testing.T) { var out, errb bytes.Buffer code := Run([]string{"help"}, nil, &out, &errb) if code != 0 || !strings.Contains(out.String(), "lectio") { t.Errorf("help code=%d out=%q", code, out.String()) } } func TestHelpFlag(t *testing.T) { var out, errb bytes.Buffer code := Run([]string{"-h"}, nil, &out, &errb) if code != 0 || !strings.Contains(out.String(), "lectio") { t.Errorf("-h code=%d out=%q", code, out.String()) } } func TestUnexpectedPositional(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"bogus"}, nil, &out, &errb); code != 2 { t.Errorf("unexpected positional code=%d want 2 (stderr=%q)", code, errb.String()) } } func TestVersion(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"--version"}, nil, &out, &errb); code != 0 { t.Errorf("version code=%d", code) } } // TestBadDate exercises the usage-error path for a malformed date: a token // that doesn't match dateRe's YYYY-MM-DD shape is left as an unrecognised // positional argument once flag parsing is done, which is a usage error. func TestBadDate(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer if code := Run([]string{"13-13-13"}, nil, &out, &errb); code != 2 { t.Errorf("bad date code=%d want 2 (stderr=%q)", code, errb.String()) } } // TestTwoDatesIsAmbiguous exercises extractDate's ambiguity check: two // dateRe-shaped tokens can't both be the positional date. func TestTwoDatesIsAmbiguous(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer if code := Run([]string{"2026-07-22", "2026-07-23"}, nil, &out, &errb); code != 2 { t.Errorf("two dates code=%d want 2 (stderr=%q)", code, errb.String()) } } // TestUnknownVersion exercises -b/--bible's version-validation usage-error // path: the version code is checked against the known corpus codes before // any reading is resolved. func TestUnknownVersion(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer if code := Run([]string{"-b", "zzz"}, nil, &out, &errb); code != 2 { t.Errorf("unknown version code=%d want 2 (stderr=%q)", code, errb.String()) } } // TestLectionaryTradMapsToTraditional exercises -l/--lectionary's trad -> // traditional normalisation. It goes through extractDate/flag parsing and // validation only (no network): a bogus date lets it exit before any fetch // is attempted, but only after the -l value has already been validated and // mapped, which is what this test checks indirectly via the exit code (a // bad --lectionary would exit 2 for a different reason, so this alone // wouldn't distinguish the two -- see TestLectionaryBogus for that side). // Exercised directly against parseFlags below. func TestLectionaryTradMapsToTraditional(t *testing.T) { lectionary, err := normalizeLectionary("trad") if err != nil { t.Fatalf("normalizeLectionary(trad): %v", err) } if lectionary != "traditional" { t.Errorf("normalizeLectionary(trad) = %q, want traditional", lectionary) } } func TestLectionaryBogus(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer if code := Run([]string{"-l", "bogus"}, nil, &out, &errb); code != 2 { t.Errorf("bogus lectionary code=%d want 2 (stderr=%q)", code, errb.String()) } if _, err := normalizeLectionary("bogus"); err == nil { t.Error("normalizeLectionary(bogus): want error, got nil") } } // TestBannerForLang checks bannerFor's wording follows lang: pl reproduces // the pre-i18n Polish banner exactly ("Ewangelia na D" / "Czytania na D"), // en gives "Gospel for D" / "Readings for D". func TestBannerForLang(t *testing.T) { cases := []struct { lang string all bool want string }{ {"pl", false, "Ewangelia na 2026-07-22"}, {"pl", true, "Czytania na 2026-07-22"}, {"en", false, "Gospel for 2026-07-22"}, {"en", true, "Readings for 2026-07-22"}, } for _, c := range cases { if got := bannerFor(c.lang, c.all, "2026-07-22"); got != c.want { t.Errorf("bannerFor(%q, %v, ...) = %q, want %q", c.lang, c.all, got, c.want) } } } // TestDayInfoHeaderShown exercises fetchAndPrint's day-info header end to // end (via Run, computed offline): the day's celebration name appears above // the banner for the default (non-raw) render, and is entirely absent from // --raw output, which stays text-only for piping. func TestDayInfoHeaderShown(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) const name = "Saint Mary Magdalene" // universal sanctoral, authored in English var out, errb bytes.Buffer if code := Run([]string{"2026-07-22"}, nil, &out, &errb); code != 0 { t.Fatalf("Run code=%d stderr=%q", code, errb.String()) } if !strings.Contains(out.String(), name) { t.Errorf("stdout missing day-info header: %q", out.String()) } out.Reset() errb.Reset() if code := Run([]string{"2026-07-22", "-r"}, nil, &out, &errb); code != 0 { t.Fatalf("Run --raw code=%d stderr=%q", code, errb.String()) } if strings.Contains(out.String(), name) { t.Errorf("--raw stdout should omit the day-info header: %q", out.String()) } } // TestDateTokenAnyPosition exercises extractDate directly: the date token // is found regardless of where it appears among other flags. func TestDateTokenAnyPosition(t *testing.T) { cases := [][]string{ {"-a", "2026-07-22"}, {"2026-07-22", "-a"}, {"-a", "2026-07-22", "-r"}, } for _, args := range cases { date, rest, err := extractDate(args) if err != nil { t.Fatalf("extractDate(%v): %v", args, err) } if date != "2026-07-22" { t.Errorf("extractDate(%v) date = %q, want 2026-07-22", args, date) } for _, r := range rest { if r == "2026-07-22" { t.Errorf("extractDate(%v) rest = %v still contains the date", args, rest) } } } } // TestExtractDateDefaultsToday checks that omitting a date token leaves // today's date and passes every token through untouched. func TestExtractDateDefaultsToday(t *testing.T) { date, rest, err := extractDate([]string{"-a", "-r"}) if err != nil { t.Fatalf("extractDate: %v", err) } if date != today() { t.Errorf("extractDate date = %q, want today() = %q", date, today()) } if len(rest) != 2 || rest[0] != "-a" || rest[1] != "-r" { t.Errorf("extractDate rest = %v, want [-a -r]", rest) } } func TestRefSingle(t *testing.T) { var out, errb bytes.Buffer code := Run([]string{"-p", "Jn 3:16", "-b", "vul"}, nil, &out, &errb) if code != 0 { t.Fatalf("ref code=%d stderr=%q", code, errb.String()) } if s := out.String(); !strings.Contains(s, "3:16") { t.Errorf("ref output missing verse 3:16:\n%s", s) } } func TestRefNotFound(t *testing.T) { var out, errb bytes.Buffer // An unknown book -> ParseRef fails to resolve it in the dialect -> exit 2. if code := Run([]string{"-p", "Zzz 9:9", "-b", "vul"}, nil, &out, &errb); code != 2 { t.Errorf("ref unknown-book code=%d want 2 (stderr=%q)", code, errb.String()) } } func TestRunListEnglish(t *testing.T) { tbl, _ := bible.LoadBookTable(nil) var out bytes.Buffer if code := runList(tbl, "en", &out); code != 0 { t.Fatalf("runList en code=%d", code) } if s := out.String(); !strings.Contains(s, "Jn") || !strings.Contains(s, "John") { t.Errorf("en list missing Jn/John:\n%s", s) } } func TestRunListPolish(t *testing.T) { tbl, _ := bible.LoadBookTable(nil) var out bytes.Buffer runList(tbl, "pl", &out) if s := out.String(); !strings.Contains(s, "Jana") || !strings.Contains(s, "Rodzaju") { t.Errorf("pl list missing Jana/Rodzaju:\n%s", s) } } func TestListFlag(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"--list"}, nil, &out, &errb); code != 0 { t.Fatalf("--list code=%d stderr=%q", code, errb.String()) } if n := strings.Count(strings.TrimSpace(out.String()), "\n") + 1; n < 73 { t.Errorf("--list printed %d lines, want >=73", n) } } func TestListSurfacesUserCorpus(t *testing.T) { bible.SetUserCorporaDir("../bible/testdata/corpora") t.Cleanup(func() { bible.SetUserCorporaDir("") }) tbl, _ := bible.LoadBookTable(nil) var out bytes.Buffer if code := runList(tbl, "en", &out); code != 0 { t.Fatalf("runList code=%d", code) } if s := out.String(); !strings.Contains(s, "good") || !strings.Contains(s, "Good Test Corpus") { t.Errorf("--list did not surface the user fixture corpus:\n%s", s) } } func TestRefEnglishDialect(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"-p", "Jn 3:16", "-b", "vul"}, nil, &out, &errb); code != 0 { t.Fatalf("ref code=%d stderr=%q", code, errb.String()) } if !strings.Contains(out.String(), "3:16") { t.Errorf("ref output missing 3:16:\n%s", out.String()) } } func TestRefRejectsPolishAbbrevInEnglish(t *testing.T) { var out, errb bytes.Buffer // English dialect (default): the Polish abbrev "Łk" must not resolve. if code := Run([]string{"-p", "Łk 3:16", "-b", "vul"}, nil, &out, &errb); code != 2 { t.Errorf("Łk in en dialect code=%d want 2 (stderr=%q)", code, errb.String()) } } func TestRefRejectsBT(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"-p", "Jn 3:16", "-b", "bt"}, nil, &out, &errb); code != 2 { t.Errorf("ref -b bt code=%d want 2 (stderr=%q)", code, errb.String()) } } func TestRefCompare(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"-p", "Jn 3:16", "-c", "vul,drb"}, nil, &out, &errb); code != 0 { t.Fatalf("ref compare code=%d stderr=%q", code, errb.String()) } if !strings.Contains(out.String(), "3:16") { t.Errorf("ref compare missing verse:\n%s", out.String()) } } func TestRandVerse(t *testing.T) { // Random content varies, but structure/exit codes are deterministic. var out, errb bytes.Buffer if code := Run([]string{"--rand-v", "-b", "vul"}, nil, &out, &errb); code != 0 { t.Fatalf("rand-v code=%d stderr=%q", code, errb.String()) } // en dialect (default config): the reference line carries a colon. if s := out.String(); strings.TrimSpace(s) == "" || !strings.Contains(s, ":") { t.Errorf("rand-v output = %q", s) } } func TestRandChapter(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"--rand-ch", "-b", "wuj"}, nil, &out, &errb); code != 0 { t.Fatalf("rand-ch code=%d stderr=%q", code, errb.String()) } if strings.TrimSpace(out.String()) == "" { t.Errorf("rand-ch produced no output") } } func TestRandRejectsBT(t *testing.T) { // bt is no longer a valid version (its niedziela.pl corpus was retired), so // --rand -b bt is a usage error, same as -p -b bt (see TestRefRejectsBT). var out, errb bytes.Buffer if code := Run([]string{"--rand-v", "-b", "bt"}, nil, &out, &errb); code != 2 { t.Errorf("rand -b bt code=%d want 2 (stderr=%q)", code, errb.String()) } } func TestRandRejectsUnknownVersion(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"--rand-v", "-b", "xyz"}, nil, &out, &errb); code != 2 { t.Errorf("rand -b xyz code=%d want 2 (stderr=%q)", code, errb.String()) } } func TestGospelCitationHelpers(t *testing.T) { secs := []liturgy.Section{ {Heading: "1. czytanie (Iz 1, 1)", PartID: "pierwsze_czytanie"}, {Heading: "Ewangelia (Mt 7, 1-5)", PartID: "ewangelia"}, } sec, ok := gospelSection(secs) if !ok || sec.PartID != "ewangelia" { t.Fatalf("gospelSection=%+v ok=%v", sec, ok) } if c := gospelCitation(sec); c != "Mt 7, 1-5" { t.Errorf("citation from heading = %q", c) } // Citation field is preferred over the heading. s2 := liturgy.Section{Heading: "Ewangelia (Mt 7, 1-5)", Citation: "Mt 7, 1-5. 12", PartID: "ewangelia"} if c := gospelCitation(s2); c != "Mt 7, 1-5. 12" { t.Errorf("citation prefers Citation field, got %q", c) } // Empty -> ("", false). if _, ok := gospelSection(nil); ok { t.Error("gospelSection(nil) should be !ok") } } // TestCitation exercises --citation end to end offline: the daily-readings // engine computes the day's gospel with no network/cache, and the reference is // printed in the configured sigla dialect. func TestCitation(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer if code := Run([]string{"--citation", "2026-07-22"}, nil, &out, &errb); code != 0 { t.Fatalf("citation code=%d stderr=%q", code, errb.String()) } // Default config resolves to the English dialect (sigla_style auto + // ui_language en); 2026-07-22 is St Mary Magdalene, gospel John 20:1-2,11-18, // rendered as the English sigla "Jn 20:...". if s := strings.TrimSpace(out.String()); !strings.Contains(s, "Jn 20:") { t.Errorf("citation = %q, want English-dialect gospel ref 'Jn 20:...'", s) } } // TestWeek exercises --week offline: seven days of gospel references starting // at the given date, one line each, computed by the offline engine. func TestWeek(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer if code := Run([]string{"--week", "2026-07-22"}, nil, &out, &errb); code != 0 { t.Fatalf("week code=%d stderr=%q", code, errb.String()) } lines := strings.Split(strings.TrimSpace(out.String()), "\n") if len(lines) != 7 { t.Fatalf("week printed %d lines, want 7:\n%s", len(lines), out.String()) } if !strings.HasPrefix(lines[0], "2026-07-22") || !strings.Contains(lines[0], "Jn 20:") { t.Errorf("week[0] = %q", lines[0]) } }