diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 12 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 21 | ||||
| -rw-r--r-- | internal/i18n/i18n.go | 117 | ||||
| -rw-r--r-- | internal/i18n/i18n_test.go | 72 | ||||
| -rw-r--r-- | internal/render/render.go | 62 | ||||
| -rw-r--r-- | internal/render/render_test.go | 56 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 43 | ||||
| -rw-r--r-- | internal/web/render.go | 3 | ||||
| -rw-r--r-- | internal/web/render_test.go | 16 | ||||
| -rw-r--r-- | internal/web/server.go | 12 | ||||
| -rw-r--r-- | internal/web/server_test.go | 47 | ||||
| -rw-r--r-- | internal/web/templates/index.html | 2 |
12 files changed, 387 insertions, 76 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b660878..a13f2f1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -289,17 +289,19 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width return 0 } -// bannerFor builds the "<Gospel|Readings> — DATE" banner: the "readings" -// word when every part is shown, "gospel" for the gospel-only default, both -// localised via i18n.Get(lang) (lang="pl" reproduces ewangelia.py's original -// Polish wording, just with "—" in place of "na"). +// bannerFor builds the "<Gospel|Readings> <connective> DATE" banner: the +// "readings" word when every part is shown, "gospel" for the gospel-only +// default, and the connective between word and date, all localised via +// i18n.Get(lang) -- lang="pl" reproduces ewangelia.py's original Polish +// wording exactly ("Ewangelia na DATE" / "Czytania na DATE"); lang="en" +// gives "Gospel for DATE" / "Readings for DATE". func bannerFor(lang string, all bool, date string) string { ui := i18n.Get(lang) word := ui.BannerGospel if all { word = ui.BannerReadings } - return word + " — " + date + return word + " " + ui.BannerConnective + " " + date } // renderSection formats one section as its heading (unless raw) followed by diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index de9220a..d0c8791 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -105,6 +105,27 @@ func TestLangBogus(t *testing.T) { } } +// 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) + } + } +} + // TestDateTokenAnyPosition exercises extractDate directly: the date token // is found regardless of where it appears among other flags. func TestDateTokenAnyPosition(t *testing.T) { diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index a5cb77b..4d034a5 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -19,17 +19,38 @@ type UI struct { // keeping the citation untouched. PartLabel map[string]string - // TUI keybar and status messages. + // TUI keybar and status messages. NoReadingsFor and ErrorPrefix each + // carry their own single trailing space (the TUI concatenates a date or + // error string directly onto them, no separator added at the call site). FooterKeys, Loading, NoReadingsFor, ErrorPrefix, ErrorHint string - // CLI banner label words: the banner is "<word> — <date>". - BannerGospel, BannerReadings string + // CLI banner label words and connective: the banner is + // "<word> <BannerConnective> <date>" (e.g. "Gospel for 2026-07-22" / + // "Ewangelia na 2026-07-22"). + BannerGospel, BannerReadings, BannerConnective string // Web control labels. Lectionary, OptModern, OptTraditional string Parts, OptGospel, OptAll string Layout, OptHorizontal, OptColumns string OptInterlinear, Theme, Mono string + + // NoInterlinearVerses is the web interlinear pane's note when no + // requested version could be interleaved for a section. + NoInterlinearVerses string + + // NoReadingsDay is the web error fragment shown when readings.Load + // returns no sections at all for the requested date. + NoReadingsDay string + + // NoVersion/NoVersionPartial/NoReference/NoReferenceErr are + // render.GatherVersion/GatherVerses' lookup/citation-failure blocks, + // rendered verbatim by cli/tui/web. NoVersion takes the version code; + // NoVersionPartial takes the version code and the comma-joined missing + // references; NoReferenceErr takes the underlying error (pl keeps %w so + // it still wraps, en uses %v -- the verb only controls + // errors.Unwrap-ability, both format identically via Error()). + NoVersion, NoVersionPartial, NoReference, NoReferenceErr string } // Get returns lang's chrome string set, falling back to English for @@ -56,25 +77,32 @@ var enUI = UI{ "aklamacja": "Acclamation", "ewangelia": "Gospel", }, - FooterKeys: "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit", - Loading: "loading…", - NoReadingsFor: "no readings for ", - ErrorPrefix: "error: ", - ErrorHint: "change date (←/→) or refresh (r)", - BannerGospel: "Gospel", - BannerReadings: "Readings", - Lectionary: "lectionary", - OptModern: "modern", - OptTraditional: "traditional", - Parts: "parts", - OptGospel: "Gospel", - OptAll: "all parts", - Layout: "layout", - OptHorizontal: "horizontal", - OptColumns: "columns", - OptInterlinear: "interlinear", - Theme: "theme", - Mono: "mono", + FooterKeys: "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit", + Loading: "loading…", + NoReadingsFor: "no readings for ", + ErrorPrefix: "error: ", + ErrorHint: "change date (←/→) or refresh (r)", + BannerGospel: "Gospel", + BannerReadings: "Readings", + BannerConnective: "for", + Lectionary: "lectionary", + OptModern: "modern", + OptTraditional: "traditional", + Parts: "parts", + OptGospel: "Gospel", + OptAll: "all parts", + Layout: "layout", + OptHorizontal: "horizontal", + OptColumns: "columns", + OptInterlinear: "interlinear", + Theme: "theme", + Mono: "mono", + NoInterlinearVerses: "(no verses to align)", + NoReadingsDay: "no readings for this day", + NoVersion: "(not in %s)", + NoVersionPartial: "(not in %s: %s)", + NoReference: "(no reference)", + NoReferenceErr: "(no reference: %v)", } var plUI = UI{ @@ -92,23 +120,30 @@ var plUI = UI{ "aklamacja": "Aklamacja", "ewangelia": "Ewangelia", }, - FooterKeys: "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście", - Loading: "ładowanie…", - NoReadingsFor: "brak czytań na ", - ErrorPrefix: "błąd: ", - ErrorHint: "zmień datę (←/→) lub odśwież (r)", - BannerGospel: "Ewangelia", - BannerReadings: "Czytania", - Lectionary: "lekcjonarz", - OptModern: "nowy", - OptTraditional: "tradycyjny", - Parts: "zakres", - OptGospel: "Ewangelia", - OptAll: "wszystkie części", - Layout: "układ", - OptHorizontal: "poziomo", - OptColumns: "kolumny", - OptInterlinear: "interlinearnie", - Theme: "motyw", - Mono: "mono", + FooterKeys: "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście", + Loading: "ładowanie…", + NoReadingsFor: "brak czytań na ", + ErrorPrefix: "błąd: ", + ErrorHint: "zmień datę (←/→) lub odśwież (r)", + BannerGospel: "Ewangelia", + BannerReadings: "Czytania", + BannerConnective: "na", + Lectionary: "lekcjonarz", + OptModern: "nowy", + OptTraditional: "tradycyjny", + Parts: "zakres", + OptGospel: "Ewangelia", + OptAll: "wszystkie części", + Layout: "układ", + OptHorizontal: "poziomo", + OptColumns: "kolumny", + OptInterlinear: "interlinearnie", + Theme: "motyw", + Mono: "mono", + NoInterlinearVerses: "(brak wersetów do zestawienia interlinearnego)", + NoReadingsDay: "brak czytań na ten dzień", + NoVersion: "(brak w „%s”)", + NoVersionPartial: "(brak w „%s”: %s)", + NoReference: "(brak odwołania)", + NoReferenceErr: "(brak odwołania: %w)", } diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index 8c6f137..59771a8 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -1,6 +1,9 @@ package i18n -import "testing" +import ( + "strings" + "testing" +) func TestGetTheme(t *testing.T) { if got := Get("pl").Theme; got != "motyw" { @@ -56,12 +59,21 @@ func TestTUIStrings(t *testing.T) { if en.Loading != "loading…" || pl.Loading != "ładowanie…" { t.Errorf("Loading en=%q pl=%q", en.Loading, pl.Loading) } - if en.NoReadingsFor != "no readings for " || pl.NoReadingsFor != "brak czytań na " { + if en.NoReadingsFor != "no readings for " || pl.NoReadingsFor != "brak czytań na " { t.Errorf("NoReadingsFor en=%q pl=%q", en.NoReadingsFor, pl.NoReadingsFor) } - if en.ErrorPrefix != "error: " || pl.ErrorPrefix != "błąd: " { + if en.ErrorPrefix != "error: " || pl.ErrorPrefix != "błąd: " { t.Errorf("ErrorPrefix en=%q pl=%q", en.ErrorPrefix, pl.ErrorPrefix) } + // Both carry exactly one trailing space: the TUI concatenates a date or + // error string directly onto them with no separator of its own (see + // internal/tui bodyLines: ui.NoReadingsFor+m.date, ui.ErrorPrefix+err). + if strings.HasSuffix(en.NoReadingsFor, " ") || strings.HasSuffix(pl.NoReadingsFor, " ") { + t.Errorf("NoReadingsFor has 2+ trailing spaces: en=%q pl=%q", en.NoReadingsFor, pl.NoReadingsFor) + } + if strings.HasSuffix(en.ErrorPrefix, " ") || strings.HasSuffix(pl.ErrorPrefix, " ") { + t.Errorf("ErrorPrefix has 2+ trailing spaces: en=%q pl=%q", en.ErrorPrefix, pl.ErrorPrefix) + } if en.ErrorHint != "change date (←/→) or refresh (r)" || pl.ErrorHint != "zmień datę (←/→) lub odśwież (r)" { t.Errorf("ErrorHint en=%q pl=%q", en.ErrorHint, pl.ErrorHint) } @@ -76,6 +88,60 @@ func TestBannerWords(t *testing.T) { if en.BannerReadings != "Readings" || pl.BannerReadings != "Czytania" { t.Errorf("BannerReadings en=%q pl=%q", en.BannerReadings, pl.BannerReadings) } + if en.BannerConnective != "for" || pl.BannerConnective != "na" { + t.Errorf("BannerConnective en=%q pl=%q", en.BannerConnective, pl.BannerConnective) + } +} + +// TestWebFragmentMessages covers the two web-only messages (interlinear +// "no verses" note, "no readings at all for this day" error fragment) that +// were unconditionally Polish before this fix. +func TestWebFragmentMessages(t *testing.T) { + en := Get("en") + pl := Get("pl") + if en.NoInterlinearVerses != "(no verses to align)" { + t.Errorf("en.NoInterlinearVerses = %q", en.NoInterlinearVerses) + } + if pl.NoInterlinearVerses != "(brak wersetów do zestawienia interlinearnego)" { + t.Errorf("pl.NoInterlinearVerses = %q", pl.NoInterlinearVerses) + } + if en.NoReadingsDay != "no readings for this day" { + t.Errorf("en.NoReadingsDay = %q", en.NoReadingsDay) + } + if pl.NoReadingsDay != "brak czytań na ten dzień" { + t.Errorf("pl.NoReadingsDay = %q", pl.NoReadingsDay) + } +} + +// TestLookupFailureMessages covers render.GatherVersion/GatherVerses/ +// resolveRef's four lookup/citation-failure templates. +func TestLookupFailureMessages(t *testing.T) { + en := Get("en") + pl := Get("pl") + + cases := []struct { + name, en, pl string + }{ + {"NoVersion", en.NoVersion, pl.NoVersion}, + {"NoVersionPartial", en.NoVersionPartial, pl.NoVersionPartial}, + {"NoReference", en.NoReference, pl.NoReference}, + {"NoReferenceErr", en.NoReferenceErr, pl.NoReferenceErr}, + } + want := map[string][2]string{ + "NoVersion": {"(not in %s)", "(brak w „%s”)"}, + "NoVersionPartial": {"(not in %s: %s)", "(brak w „%s”: %s)"}, + "NoReference": {"(no reference)", "(brak odwołania)"}, + "NoReferenceErr": {"(no reference: %v)", "(brak odwołania: %w)"}, + } + for _, c := range cases { + w := want[c.name] + if c.en != w[0] { + t.Errorf("en.%s = %q, want %q", c.name, c.en, w[0]) + } + if c.pl != w[1] { + t.Errorf("pl.%s = %q, want %q", c.name, c.pl, w[1]) + } + } } func TestWebControlLabels(t *testing.T) { diff --git a/internal/render/render.go b/internal/render/render.go index c5a2ab0..6bccc65 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -24,28 +24,45 @@ func versionLabel(version, lang string) string { return version } +// modernPartOrder is the fixed, deterministic order LocalizeHeading tries +// the known modern (niedziela.pl) pl part labels in -- a plain slice rather +// than ranging over i18n.UI.PartLabel (a map, so Go randomises its +// iteration order). None of the five labels is a prefix of another, so the +// order never changes which one matches, only determinism. +var modernPartOrder = []string{"pierwsze_czytanie", "drugie_czytanie", "psalm", "aklamacja", "ewangelia"} + // LocalizeHeading swaps a modern (niedziela.pl) section heading's leading // label word for its lang translation, keeping the rest of the heading (the // parenthetical citation, exactly as scraped) untouched: e.g. -// "Ewangelia (J 20, 1. 11-18)" with partID "ewangelia" and lang "en" becomes -// "Gospel (J 20, 1. 11-18)". It only ever localises the label word, never -// the citation or the verse text. +// "Ewangelia (J 20, 1. 11-18)" with lang "en" becomes "Gospel (J 20, 1. +// 11-18)". It only ever localises the label word, never the citation or the +// verse text. +// +// It matches against heading's own leading text, not partID: a split +// (two-reading) feast day scrapes PartID="drugie_czytanie" onto a heading +// that niedziela.pl still literally titles "1. czytanie ..." (its own +// numbering quirk carries over from the undivided day), so looking up the +// Polish label by partID and checking heading's prefix against only that +// one label misses it. Instead every known pl label (modernPartOrder) is +// tried against heading in turn; partID itself is unused -- kept in the +// signature for callers, which all have it in hand already (sec.PartID). // -// It is a safe no-op (returns heading unchanged) unless all of: lang is -// "en", partID names a known modern-lectionary part (see -// internal/i18n.UI.PartLabel), and heading actually starts with that part's -// Polish label -- which excludes traditional (missalemeum) headings, already -// in the requested language, and anything unrecognised. +// It is a safe no-op (returns heading unchanged) unless lang is "en" and +// heading actually starts with one of the known pl labels -- which excludes +// traditional (missalemeum) headings, already in the requested language, +// and anything unrecognised. func LocalizeHeading(heading, partID, lang string) string { if lang != "en" { return heading } - plLabel, ok := i18n.Get("pl").PartLabel[partID] - if !ok || !strings.HasPrefix(heading, plLabel) { - return heading + plUI, enUI := i18n.Get("pl"), i18n.Get("en") + for _, id := range modernPartOrder { + plLabel := plUI.PartLabel[id] + if strings.HasPrefix(heading, plLabel) { + return enUI.PartLabel[id] + heading[len(plLabel):] + } } - enLabel := i18n.Get(lang).PartLabel[partID] - return enLabel + heading[len(plLabel):] + return heading } // versionSystem maps a bible version to the Psalter system bible.ToEnglishRef @@ -83,14 +100,14 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) return label, gatherPL(sec) } - ref, err := resolveRef(version, sec, lectionary) + ref, err := resolveRef(version, sec, lectionary, lang) if err != nil { return label, []string{err.Error()} } verses, missing := bible.Lookup(version, ref) if len(verses) == 0 { - return label, []string{fmt.Sprintf("(brak w „%s”)", version)} + return label, []string{fmt.Sprintf(i18n.Get(lang).NoVersion, version)} } blocks = make([]string, 0, len(verses)) @@ -98,7 +115,7 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) blocks = append(blocks, fmt.Sprintf("%d:%d %s", v.Chapter, v.Verse, v.Text)) } if len(missing) > 0 { - blocks = append(blocks, fmt.Sprintf("(brak w „%s”: %s)", version, strings.Join(missing, ", "))) + blocks = append(blocks, fmt.Sprintf(i18n.Get(lang).NoVersionPartial, version, strings.Join(missing, ", "))) } return label, blocks } @@ -108,8 +125,11 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) // converted to English/kjv-style via bible.ToEnglishRef when // lectionary=="new" (a "traditional" citation is already English-style and // used as-is). Shared by GatherVersion and GatherVerses so both apply the -// exact same resolution. -func resolveRef(version string, sec liturgy.Section, lectionary string) (string, error) { +// exact same resolution. lang selects the wording of the two failure +// messages it can return (see internal/i18n.UI.NoReference/NoReferenceErr); +// it never affects which reference is resolved. +func resolveRef(version string, sec liturgy.Section, lectionary, lang string) (string, error) { + ui := i18n.Get(lang) citation := sec.Citation if citation == "" { if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { @@ -117,7 +137,7 @@ func resolveRef(version string, sec liturgy.Section, lectionary string) (string, } } if citation == "" { - return "", fmt.Errorf("(brak odwołania)") + return "", fmt.Errorf("%s", ui.NoReference) } if lectionary != "new" { @@ -125,7 +145,7 @@ func resolveRef(version string, sec liturgy.Section, lectionary string) (string, } ref, err := bible.ToEnglishRef(citation, system(version)) if err != nil { - return "", fmt.Errorf("(brak odwołania: %w)", err) + return "", fmt.Errorf(ui.NoReferenceErr, err) } return ref, nil } @@ -141,7 +161,7 @@ func GatherVerses(version string, sec liturgy.Section, lectionary, lang string) return label, nil, false } - ref, err := resolveRef(version, sec, lectionary) + ref, err := resolveRef(version, sec, lectionary, lang) if err != nil { return label, nil, false } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index b016d6b..affaaf8 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -76,6 +76,56 @@ func TestCompareLabelLang(t *testing.T) { } } +// TestGatherVersionNoVersionLang checks that the "(not in %s)" block +// (bible.Lookup finding nothing at all) follows lang -- pl reproduces the +// original Polish wording exactly, en uses internal/i18n's English wording. +func TestGatherVersionNoVersionLang(t *testing.T) { + sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"} + + _, blocks := GatherVersion("zzz", sec, "new", "pl") + if len(blocks) == 0 || blocks[0] != `(brak w „zzz”)` { + t.Errorf(`GatherVersion(..., "pl") blocks = %v, want [(brak w „zzz”)]`, blocks) + } + + _, blocks = GatherVersion("zzz", sec, "new", "en") + if len(blocks) == 0 || blocks[0] != "(not in zzz)" { + t.Errorf(`GatherVersion(..., "en") blocks = %v, want [(not in zzz)]`, blocks) + } +} + +// TestGatherVersionNoReferenceLang checks the "(no reference)" block (no +// citation resolvable at all) follows lang. +func TestGatherVersionNoReferenceLang(t *testing.T) { + sec := liturgy.Section{Heading: "Bez odwołania"} + + _, blocks := GatherVersion("wuj", sec, "new", "pl") + if len(blocks) == 0 || blocks[0] != "(brak odwołania)" { + t.Errorf(`GatherVersion(..., "pl") blocks = %v, want [(brak odwołania)]`, blocks) + } + + _, blocks = GatherVersion("wuj", sec, "new", "en") + if len(blocks) == 0 || blocks[0] != "(no reference)" { + t.Errorf(`GatherVersion(..., "en") blocks = %v, want [(no reference)]`, blocks) + } +} + +// TestGatherVersionNoReferenceErrLang checks the "(no reference: ...)" block +// (a citation that bible.ToEnglishRef fails to convert, e.g. an unrecognised +// book) follows lang. +func TestGatherVersionNoReferenceErrLang(t *testing.T) { + sec := liturgy.Section{Citation: "Xyz 1, 1-2"} + + _, blocks := GatherVersion("wuj", sec, "new", "pl") + if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "(brak odwołania: ") { + t.Errorf(`GatherVersion(..., "pl") blocks = %v, want prefix "(brak odwołania: "`, blocks) + } + + _, blocks = GatherVersion("wuj", sec, "new", "en") + if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "(no reference: ") { + t.Errorf(`GatherVersion(..., "en") blocks = %v, want prefix "(no reference: "`, blocks) + } +} + func TestOfflineVersions(t *testing.T) { got := OfflineVersions([]string{"pl", "wuj", "vul"}) for _, v := range got { @@ -143,6 +193,12 @@ func TestLocalizeHeading(t *testing.T) { {"unknown partID unchanged", "Coś innego (X 1)", "", "en", "Coś innego (X 1)"}, {"already-English heading unchanged (traditional lectionary, no pl prefix to match)", "Gospel (Luke 7:36-50)", "ewangelia", "en", "Gospel (Luke 7:36-50)"}, {"prefix mismatch unchanged", "Nieoczekiwany tytuł (J 1)", "ewangelia", "en", "Nieoczekiwany tytuł (J 1)"}, + // Split (two-reading) feast day: niedziela.pl scrapes + // PartID="drugie_czytanie" onto a heading that still literally + // starts with "1. czytanie" (its own numbering quirk). Matching + // must follow the heading's actual text, not partID's label, so + // this localises to "1st reading", not staying Polish. + {"drugie_czytanie partID with a 1. czytanie heading (split feast day)", "1. czytanie (Dz 2, 14. 22-33)", "drugie_czytanie", "en", "1st reading (Dz 2, 14. 22-33)"}, } for _, c := range cases { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index baa5c05..60b473b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "strings" "testing" @@ -51,6 +52,48 @@ func TestBodyLinesLocalisesMessages(t *testing.T) { } } +// TestBodyLinesSingleSpaceConcatenation is a regression test for the +// i18n-migration whitespace bug: i18n.UI.NoReadingsFor/ErrorPrefix must +// carry exactly one trailing space, since bodyLines concatenates the date/ +// error string directly onto them with no separator of its own -- the +// pre-i18n TUI code produced "błąd: " + err / "brak czytań na " + date (one +// space), and the i18n fields regressed to two. +func TestBodyLinesSingleSpaceConcatenation(t *testing.T) { + enEmpty := Model{cfg: config.Config{UILanguage: "en"}, date: "2026-07-22"} + lines := enEmpty.bodyLines(80) + if len(lines) == 0 || !strings.Contains(lines[0], "no readings for 2026-07-22") { + t.Errorf("en no-readings bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "no readings for 2026-07-22") + } + if strings.Contains(lines[0], "for 2026") { + t.Errorf("en no-readings bodyLines[0] = %q, has 2+ spaces before the date", lines[0]) + } + + plEmpty := Model{cfg: config.Config{UILanguage: "pl"}, date: "2026-07-22"} + lines = plEmpty.bodyLines(80) + if len(lines) == 0 || !strings.Contains(lines[0], "brak czytań na 2026-07-22") { + t.Errorf("pl no-readings bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "brak czytań na 2026-07-22") + } + + errText := "boom" + enErr := Model{cfg: config.Config{UILanguage: "en"}, err: errors.New(errText)} + lines = enErr.bodyLines(80) + if len(lines) == 0 || !strings.Contains(lines[0], "error: "+errText) { + t.Errorf("en error bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "error: "+errText) + } + if strings.Contains(lines[0], "error: ") { + t.Errorf("en error bodyLines[0] = %q, has 2+ spaces after \"error:\"", lines[0]) + } + + plErr := Model{cfg: config.Config{UILanguage: "pl"}, err: errors.New(errText)} + lines = plErr.bodyLines(80) + if len(lines) == 0 || !strings.Contains(lines[0], "błąd: "+errText) { + t.Errorf("pl error bodyLines[0] = %q, want it to contain %q (single space)", lines[0], "błąd: "+errText) + } + if strings.Contains(lines[0], "błąd: ") { + t.Errorf("pl error bodyLines[0] = %q, has 2+ spaces after \"błąd:\"", lines[0]) + } +} + // TestBodyLinesLocalisesHeading checks that a modern-lectionary section // heading's label word follows cfg.UILanguage while the citation stays // exactly as scraped (render.LocalizeHeading, brief §3b). diff --git a/internal/web/render.go b/internal/web/render.go index ca5bb89..208fc59 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/render" ) @@ -203,7 +204,7 @@ func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary } if len(sets) == 0 { - view.Note = "(brak wersetów do zestawienia interlinearnego)" + view.Note = i18n.Get(lang).NoInterlinearVerses views = append(views, view) continue } diff --git a/internal/web/render_test.go b/internal/web/render_test.go index ea44ed2..494cdcd 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -106,6 +106,22 @@ func TestRenderReadingsInterlinearNoVersifiedNote(t *testing.T) { if strings.Contains(html, "ilverse") { t.Errorf("expected no ilverse blocks when nothing resolves: %q", html) } + if !strings.Contains(html, "brak wersetów do zestawienia interlinearnego") { + t.Errorf("pl no-verses note missing/wrong: %q", html) + } +} + +// TestRenderReadingsInterlinearNoVersifiedNoteLang checks the interlinear +// "no verses to align" note (finding §1) follows lang, not always Polish. +func TestRenderReadingsInterlinearNoVersifiedNoteLang(t *testing.T) { + secs := []liturgy.Section{{Heading: "Bez odwołania", PartID: "ewangelia"}} + html := string(RenderReadings(secs, []string{"wuj"}, "new", "interlinear", "en")) + if !strings.Contains(html, "(no verses to align)") { + t.Errorf("en no-verses note missing/wrong: %q", html) + } + if strings.Contains(html, "brak wersetów") { + t.Errorf("en output should not carry the Polish no-verses note: %q", html) + } } // TestRenderReadingsLocalizesEN checks that lang="en" localises both the diff --git a/internal/web/server.go b/internal/web/server.go index 3b7102c..4c13576 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -188,6 +188,8 @@ type indexData struct { // set from i18n.Get(cfg.UILanguage) -- index.html references its // fields (e.g. {{.L.Lectionary}}) instead of hardcoded Polish text. L i18n.UI + // Lang is cfg.UILanguage, rendered into <html lang="...">. + Lang string } type versionOpt struct { @@ -245,6 +247,7 @@ func indexHandler(cfg config.Config) http.HandlerFunc { Mono: queryBool(r, "mono", cfg.WebMono), Reading: reading, L: i18n.Get(cfg.UILanguage), + Lang: cfg.UILanguage, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -269,15 +272,16 @@ func readingsHandler(cfg config.Config) http.HandlerFunc { } // renderOrError returns RenderReadings' fragment, or (on a readings.Load -// error) a small escaped error paragraph -- readings.Load errors are -// expected in normal operation (an unpublished date, no network while -// online, ...) so the pane should show them, not 500. +// error, or no sections at all for the date) a small escaped error +// paragraph, localised via lang -- readings.Load errors are expected in +// normal operation (an unpublished date, no network while online, ...) so +// the pane should show them, not 500. func renderOrError(secs []liturgy.Section, versions []string, lectionary, display, lang string, err error) template.HTML { if err != nil { return template.HTML(`<p class="error">` + template.HTMLEscapeString(err.Error()) + `</p>`) } if len(secs) == 0 { - return template.HTML(`<p class="error">brak czytań na ten dzień</p>`) + return template.HTML(`<p class="error">` + template.HTMLEscapeString(i18n.Get(lang).NoReadingsDay) + `</p>`) } return RenderReadings(secs, versions, lectionary, display, lang) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 25d21f4..32ee412 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -227,6 +227,53 @@ func TestServer(t *testing.T) { }) } +// TestRenderOrErrorNoSectionsLang checks the "no readings at all for this +// day" fragment (finding §1) follows lang instead of always being Polish. +func TestRenderOrErrorNoSectionsLang(t *testing.T) { + html := string(renderOrError(nil, nil, "new", "horizontal", "en", nil)) + if !strings.Contains(html, "no readings for this day") { + t.Errorf("en renderOrError(no secs) = %q, want it to contain %q", html, "no readings for this day") + } + if strings.Contains(html, "czyta") { + t.Errorf("en renderOrError(no secs) should not carry Polish wording: %q", html) + } + + html = string(renderOrError(nil, nil, "new", "horizontal", "pl", nil)) + if !strings.Contains(html, "brak czytań na ten dzień") { + t.Errorf("pl renderOrError(no secs) = %q, want it to contain %q", html, "brak czytań na ten dzień") + } +} + +// TestIndexHTMLLangAttribute checks index.html's <html lang="..."> follows +// cfg.UILanguage (finding §6) instead of being hardcoded "pl". +func TestIndexHTMLLangAttribute(t *testing.T) { + html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") + if err != nil { + t.Fatal(err) + } + fixtureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(html) + })) + defer fixtureServer.Close() + liturgy.SetBaseURL(fixtureServer.URL + "/liturgia/%s/Ewangelia") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + cfg := config.Default() + cfg.UILanguage = "en" + rec := httptest.NewRecorder() + NewServer(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil)) + if !strings.Contains(rec.Body.String(), `<html lang="en">`) { + t.Errorf(`en index page missing <html lang="en">: %q`, rec.Body.String()[:min(400, rec.Body.Len())]) + } + + cfg.UILanguage = "pl" + rec = httptest.NewRecorder() + NewServer(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil)) + if !strings.Contains(rec.Body.String(), `<html lang="pl">`) { + t.Errorf(`pl index page missing <html lang="pl">: %q`, rec.Body.String()[:min(400, rec.Body.Len())]) + } +} + // TestChooseListener exercises chooseListener's port-selection logic // directly (no HTTP serving): port==0 prefers defaultWebPort (1099) and // falls back to a free OS port when 1099 is taken, and a non-zero port is diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 8c9fd86..780224f 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -11,7 +11,7 @@ included too; they override "date" via hx-vals. The theme <select> has no server round trip: it swaps the #theme <link>'s href directly. */}} <!doctype html> -<html lang="pl"> +<html lang="{{.Lang}}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> |
