From da1818a73de736dffd1580137352c32bea81be8c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:34:22 +0200 Subject: i18n: rank and colour vocabulary tables --- internal/i18n/golden_test.go | 23 +++++++++++++++++++ internal/i18n/i18n.go | 17 ++++++++++++-- internal/i18n/lang/en.ini | 16 +++++++++++++ internal/i18n/lang/pl.ini | 16 +++++++++++++ internal/i18n/vocab_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 internal/i18n/vocab_test.go (limited to 'internal/i18n') diff --git a/internal/i18n/golden_test.go b/internal/i18n/golden_test.go index 5a29097..b3ad125 100644 --- a/internal/i18n/golden_test.go +++ b/internal/i18n/golden_test.go @@ -23,6 +23,17 @@ var enUI = UI{ "aklamacja": "Acclamation", "ewangelia": "Gospel", }, + Rank: map[string]string{ + "solemnity": "solemnity", "feast": "feast", "memorial": "memorial", + "optional": "optional memorial", "ferial": "feria", + "class-1": "I class", "class-2": "II class", + "class-3": "III class", "class-4": "IV class", + "commemoration": "commemoration", + }, + Colour: map[string]string{ + "white": "white", "red": "red", "green": "green", + "violet": "violet", "rose": "rose", "black": "black", + }, FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit", Loading: "loading…", NoReadingsFor: "no readings for ", @@ -111,6 +122,18 @@ var plUI = UI{ "aklamacja": "Aklamacja", "ewangelia": "Ewangelia", }, + Rank: map[string]string{ + "solemnity": "uroczystość", "feast": "święto", + "memorial": "wspomnienie obowiązkowe", "optional": "wspomnienie dowolne", + "ferial": "dzień powszedni", + "class-1": "I klasy", "class-2": "II klasy", + "class-3": "III klasy", "class-4": "IV klasy", + "commemoration": "komemoracja", + }, + Colour: map[string]string{ + "white": "biały", "red": "czerwony", "green": "zielony", + "violet": "fioletowy", "rose": "różowy", "black": "czarny", + }, FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście", Loading: "ładowanie…", NoReadingsFor: "brak czytań na ", diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index fece4a8..3928da8 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -15,14 +15,27 @@ type UI struct { // version code (wuj, vul, grb, drb). Version map[string]string - // PartLabel names each modern-lectionary section's heading label word, - // keyed by liturgy.Section.PartID (pierwsze_czytanie, psalm, + // PartLabel names each lectionary section's heading label word, keyed by + // liturgy.Section.PartID -- the modern IDs (pierwsze_czytanie, psalm, // drugie_czytanie, aklamacja, ewangelia). The pl entries are the exact // prefixes niedziela.pl's scraped headings carry, used by // render.LocalizeHeading to recognise and swap the label word while // keeping the citation untouched. PartLabel map[string]string + // Rank names each liturgical rank, keyed by the string form of + // calendar.Rank. Two vocabularies share the map: the Ordinary Form's + // ferial/optional/memorial/feast/solemnity and the 1962 form's + // class-1..class-4/commemoration. Rendered by the CLI's day header and + // by the dlectio app's day banner and calendar rows. + Rank map[string]string + + // Colour names each normalized liturgical colour, keyed by the string form + // of calendar.Colour (white, red, green, violet, rose, black). Rendered by + // the CLI's day header only -- the dlectio app draws a coloured swatch + // instead and deliberately shows no colour word. + Colour map[string]string + // 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). diff --git a/internal/i18n/lang/en.ini b/internal/i18n/lang/en.ini index 8444a4d..12adf7c 100644 --- a/internal/i18n/lang/en.ini +++ b/internal/i18n/lang/en.ini @@ -7,6 +7,22 @@ part_label.drugie_czytanie = 2nd reading part_label.ewangelia = Gospel part_label.pierwsze_czytanie = 1st reading part_label.psalm = Psalm +rank.class-1 = I class +rank.class-2 = II class +rank.class-3 = III class +rank.class-4 = IV class +rank.commemoration = commemoration +rank.feast = feast +rank.ferial = feria +rank.memorial = memorial +rank.optional = optional memorial +rank.solemnity = solemnity +colour.black = black +colour.green = green +colour.red = red +colour.rose = rose +colour.violet = violet +colour.white = white footer_keys = tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit loading = loading… no_readings_for = "no readings for " diff --git a/internal/i18n/lang/pl.ini b/internal/i18n/lang/pl.ini index 305c4c9..3521718 100644 --- a/internal/i18n/lang/pl.ini +++ b/internal/i18n/lang/pl.ini @@ -7,6 +7,22 @@ part_label.drugie_czytanie = 2. czytanie part_label.ewangelia = Ewangelia part_label.pierwsze_czytanie = 1. czytanie part_label.psalm = Psalm +rank.class-1 = I klasy +rank.class-2 = II klasy +rank.class-3 = III klasy +rank.class-4 = IV klasy +rank.commemoration = komemoracja +rank.feast = święto +rank.ferial = dzień powszedni +rank.memorial = wspomnienie obowiązkowe +rank.optional = wspomnienie dowolne +rank.solemnity = uroczystość +colour.black = czarny +colour.green = zielony +colour.red = czerwony +colour.rose = różowy +colour.violet = fioletowy +colour.white = biały footer_keys = tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście loading = ładowanie… no_readings_for = "brak czytań na " diff --git a/internal/i18n/vocab_test.go b/internal/i18n/vocab_test.go new file mode 100644 index 0000000..73115bb --- /dev/null +++ b/internal/i18n/vocab_test.go @@ -0,0 +1,54 @@ +package i18n + +import ( + "testing" + + "github.com/lukaszkasprzak/lectio/internal/calendar" +) + +// Every rank and colour the calendar can emit must have a word in every shipped +// language. This is the guard against adding a constant later and silently +// rendering a raw key like "class-2" in the UI. +func TestVocabularyCoversEveryConstant(t *testing.T) { + ranks := []calendar.Rank{ + calendar.RankFerial, calendar.RankOptional, calendar.RankMemorial, + calendar.RankFeast, calendar.RankSolemnity, + calendar.RankClass1, calendar.RankClass2, calendar.RankClass3, + calendar.RankClass4, calendar.RankCommemoration, + } + colours := []calendar.Colour{ + calendar.White, calendar.Red, calendar.Green, + calendar.Violet, calendar.Rose, calendar.Black, + } + for _, lang := range []string{"en", "pl"} { + ui := Get(lang) + for _, r := range ranks { + if ui.Rank[string(r)] == "" { + t.Errorf("%s: no rank word for %q", lang, r) + } + } + for _, c := range colours { + if ui.Colour[string(c)] == "" { + t.Errorf("%s: no colour word for %q", lang, c) + } + } + } +} + +// The English rank words must not change: lectio's CLI has printed these since +// before the table existed, and English users' output should be untouched. +func TestEnglishRankWordingUnchanged(t *testing.T) { + want := map[string]string{ + "solemnity": "solemnity", "feast": "feast", "memorial": "memorial", + "optional": "optional memorial", "ferial": "feria", + "class-1": "I class", "class-2": "II class", + "class-3": "III class", "class-4": "IV class", + "commemoration": "commemoration", + } + ui := Get("en") + for k, v := range want { + if got := ui.Rank[k]; got != v { + t.Errorf("rank[%q] = %q, want %q", k, got, v) + } + } +} -- cgit v1.3 From 14c4540d3f06299bf65d74ea864ae7c6c4c33f72 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:39:49 +0200 Subject: i18n: drop the colour word table, it has no consumer --- internal/i18n/golden_test.go | 8 -------- internal/i18n/i18n.go | 6 ------ internal/i18n/lang/en.ini | 6 ------ internal/i18n/lang/pl.ini | 6 ------ internal/i18n/vocab_test.go | 11 +---------- 5 files changed, 1 insertion(+), 36 deletions(-) (limited to 'internal/i18n') diff --git a/internal/i18n/golden_test.go b/internal/i18n/golden_test.go index b3ad125..d07d20a 100644 --- a/internal/i18n/golden_test.go +++ b/internal/i18n/golden_test.go @@ -30,10 +30,6 @@ var enUI = UI{ "class-3": "III class", "class-4": "IV class", "commemoration": "commemoration", }, - Colour: map[string]string{ - "white": "white", "red": "red", "green": "green", - "violet": "violet", "rose": "rose", "black": "black", - }, FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit", Loading: "loading…", NoReadingsFor: "no readings for ", @@ -130,10 +126,6 @@ var plUI = UI{ "class-3": "III klasy", "class-4": "IV klasy", "commemoration": "komemoracja", }, - Colour: map[string]string{ - "white": "biały", "red": "czerwony", "green": "zielony", - "violet": "fioletowy", "rose": "różowy", "black": "czarny", - }, FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście", Loading: "ładowanie…", NoReadingsFor: "brak czytań na ", diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 3928da8..356cc66 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -30,12 +30,6 @@ type UI struct { // by the dlectio app's day banner and calendar rows. Rank map[string]string - // Colour names each normalized liturgical colour, keyed by the string form - // of calendar.Colour (white, red, green, violet, rose, black). Rendered by - // the CLI's day header only -- the dlectio app draws a coloured swatch - // instead and deliberately shows no colour word. - Colour map[string]string - // 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). diff --git a/internal/i18n/lang/en.ini b/internal/i18n/lang/en.ini index 12adf7c..a2b53f3 100644 --- a/internal/i18n/lang/en.ini +++ b/internal/i18n/lang/en.ini @@ -17,12 +17,6 @@ rank.ferial = feria rank.memorial = memorial rank.optional = optional memorial rank.solemnity = solemnity -colour.black = black -colour.green = green -colour.red = red -colour.rose = rose -colour.violet = violet -colour.white = white footer_keys = tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit loading = loading… no_readings_for = "no readings for " diff --git a/internal/i18n/lang/pl.ini b/internal/i18n/lang/pl.ini index 3521718..d1817f9 100644 --- a/internal/i18n/lang/pl.ini +++ b/internal/i18n/lang/pl.ini @@ -17,12 +17,6 @@ rank.ferial = dzień powszedni rank.memorial = wspomnienie obowiązkowe rank.optional = wspomnienie dowolne rank.solemnity = uroczystość -colour.black = czarny -colour.green = zielony -colour.red = czerwony -colour.rose = różowy -colour.violet = fioletowy -colour.white = biały footer_keys = tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście loading = ładowanie… no_readings_for = "brak czytań na " diff --git a/internal/i18n/vocab_test.go b/internal/i18n/vocab_test.go index 73115bb..d25533a 100644 --- a/internal/i18n/vocab_test.go +++ b/internal/i18n/vocab_test.go @@ -6,7 +6,7 @@ import ( "github.com/lukaszkasprzak/lectio/internal/calendar" ) -// Every rank and colour the calendar can emit must have a word in every shipped +// Every rank the calendar can emit must have a word in every shipped // language. This is the guard against adding a constant later and silently // rendering a raw key like "class-2" in the UI. func TestVocabularyCoversEveryConstant(t *testing.T) { @@ -16,10 +16,6 @@ func TestVocabularyCoversEveryConstant(t *testing.T) { calendar.RankClass1, calendar.RankClass2, calendar.RankClass3, calendar.RankClass4, calendar.RankCommemoration, } - colours := []calendar.Colour{ - calendar.White, calendar.Red, calendar.Green, - calendar.Violet, calendar.Rose, calendar.Black, - } for _, lang := range []string{"en", "pl"} { ui := Get(lang) for _, r := range ranks { @@ -27,11 +23,6 @@ func TestVocabularyCoversEveryConstant(t *testing.T) { t.Errorf("%s: no rank word for %q", lang, r) } } - for _, c := range colours { - if ui.Colour[string(c)] == "" { - t.Errorf("%s: no colour word for %q", lang, c) - } - } } } -- cgit v1.3 From f94ceaae997bce2168a5ab9e64a0c3afeafe0cf6 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:50:26 +0200 Subject: i18n: correct rank doc comments and pin the Polish wording --- internal/i18n/i18n.go | 7 +++++-- internal/i18n/vocab_test.go | 22 ++++++++++++++++++++++ internal/liturgy/section.go | 6 +++++- internal/readings/offline.go | 7 ++++--- 4 files changed, 36 insertions(+), 6 deletions(-) (limited to 'internal/i18n') diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 356cc66..6785705 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -26,8 +26,11 @@ type UI struct { // Rank names each liturgical rank, keyed by the string form of // calendar.Rank. Two vocabularies share the map: the Ordinary Form's // ferial/optional/memorial/feast/solemnity and the 1962 form's - // class-1..class-4/commemoration. Rendered by the CLI's day header and - // by the dlectio app's day banner and calendar rows. + // class-1..class-4/commemoration. The English values are hand-synced to + // agree with internal/cli/liturgy.go's own rankLabel() switch, but the + // CLI does not read this map -- it renders rank independently, and the + // two are kept in sync by hand. The actual consumers of this map are the + // dlectio app's day banner and calendar rows. Rank map[string]string // TUI keybar and status messages. NoReadingsFor and ErrorPrefix each diff --git a/internal/i18n/vocab_test.go b/internal/i18n/vocab_test.go index d25533a..bcd3464 100644 --- a/internal/i18n/vocab_test.go +++ b/internal/i18n/vocab_test.go @@ -43,3 +43,25 @@ func TestEnglishRankWordingUnchanged(t *testing.T) { } } } + +// The Polish rank words must be exactly this spelling, diacritics included: +// TestVocabularyCoversEveryConstant only checks non-emptiness, so a typo +// (e.g. "uroczystosc" for "uroczystość", or a wrong diacritic) would pass +// silently without this pin. Polish correctness is the whole point of this +// project. +func TestPolishRankWordingUnchanged(t *testing.T) { + want := map[string]string{ + "solemnity": "uroczystość", "feast": "święto", + "memorial": "wspomnienie obowiązkowe", "optional": "wspomnienie dowolne", + "ferial": "dzień powszedni", + "class-1": "I klasy", "class-2": "II klasy", + "class-3": "III klasy", "class-4": "IV klasy", + "commemoration": "komemoracja", + } + ui := Get("pl") + for k, v := range want { + if got := ui.Rank[k]; got != v { + t.Errorf("rank[%q] = %q, want %q", k, got, v) + } + } +} diff --git a/internal/liturgy/section.go b/internal/liturgy/section.go index 485a45f..baf2b9d 100644 --- a/internal/liturgy/section.go +++ b/internal/liturgy/section.go @@ -36,6 +36,10 @@ type DayInfo struct { // Rank is the normalized liturgical rank of the observed celebration: // the Ordinary Form's "ferial", "optional", "memorial", "feast", // "solemnity", or the 1962 form's "class-1".."class-4", - // "commemoration". Empty when the day carries no rank. + // "commemoration". The engine always supplies a rank: an unset or + // unrecognised rank on a sanctoral celebration defaults to "ferial" + // (calendar.buildCelebration), and every temporal-day constructor sets + // one explicitly. Consumers must not treat an empty Rank as meaning "no + // celebration". Rank string } diff --git a/internal/readings/offline.go b/internal/readings/offline.go index 2f96c14..59a03fa 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -109,9 +109,10 @@ func sectionsFor(rs []calendar.Reading, form, lang, siglaLang string, tbl *bible return out } -// dayInfo builds the header (celebration name, liturgical colour) for the -// computed day. Season is left empty: the celebration name already carries the -// temporal identity for temporal days, and the header is a nice-to-have. +// dayInfo builds the header (celebration name, liturgical colour, rank) for +// the computed day. Season is left empty: the celebration name already +// carries the temporal identity for temporal days, and the header is a +// nice-to-have. func dayInfo(cfg config.Config, day calendar.LiturgicalDay) liturgy.DayInfo { return liturgy.DayInfo{ Name: celebrationName(cfg, day.Observed), -- cgit v1.3 From e3d551ebb8f6ac2bf1a7027991a4222e83309a39 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:51:54 +0200 Subject: readings: 1962 part labels become i18n data; export PartIDs --- internal/i18n/golden_test.go | 4 ++++ internal/i18n/lang/en.ini | 2 ++ internal/i18n/lang/pl.ini | 2 ++ internal/readings/offline.go | 50 +++++++++++++++++++++++++++++---------- internal/readings/partids_test.go | 45 +++++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 13 deletions(-) (limited to 'internal/i18n') diff --git a/internal/i18n/golden_test.go b/internal/i18n/golden_test.go index d07d20a..b32a259 100644 --- a/internal/i18n/golden_test.go +++ b/internal/i18n/golden_test.go @@ -22,6 +22,8 @@ var enUI = UI{ "drugie_czytanie": "2nd reading", "aklamacja": "Acclamation", "ewangelia": "Gospel", + "epistola": "Lesson", + "evangelium": "Gospel", }, Rank: map[string]string{ "solemnity": "solemnity", "feast": "feast", "memorial": "memorial", @@ -117,6 +119,8 @@ var plUI = UI{ "drugie_czytanie": "2. czytanie", "aklamacja": "Aklamacja", "ewangelia": "Ewangelia", + "epistola": "Lekcja", + "evangelium": "Ewangelia", }, Rank: map[string]string{ "solemnity": "uroczystość", "feast": "święto", diff --git a/internal/i18n/lang/en.ini b/internal/i18n/lang/en.ini index a2b53f3..60a5f32 100644 --- a/internal/i18n/lang/en.ini +++ b/internal/i18n/lang/en.ini @@ -4,6 +4,8 @@ version.vul = Vulgate (Latin) version.wuj = Wujek (Polish) part_label.aklamacja = Acclamation part_label.drugie_czytanie = 2nd reading +part_label.epistola = Lesson +part_label.evangelium = Gospel part_label.ewangelia = Gospel part_label.pierwsze_czytanie = 1st reading part_label.psalm = Psalm diff --git a/internal/i18n/lang/pl.ini b/internal/i18n/lang/pl.ini index d1817f9..5991938 100644 --- a/internal/i18n/lang/pl.ini +++ b/internal/i18n/lang/pl.ini @@ -4,6 +4,8 @@ version.vul = Wulgata (lac.) version.wuj = Wujek (pol.) part_label.aklamacja = Aklamacja part_label.drugie_czytanie = 2. czytanie +part_label.epistola = Lekcja +part_label.evangelium = Ewangelia part_label.ewangelia = Ewangelia part_label.pierwsze_czytanie = 1. czytanie part_label.psalm = Psalm diff --git a/internal/readings/offline.go b/internal/readings/offline.go index 59a03fa..c2c276b 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -8,6 +8,7 @@ import ( "github.com/lukaszkasprzak/lectio/internal/caldata" "github.com/lukaszkasprzak/lectio/internal/calendar" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/naming" ) @@ -60,24 +61,47 @@ var ofPart = map[string]struct{ id, heading string }{ "gospel": {"ewangelia", "Ewangelia"}, } -// efPartHeading gives the traditional (1962) section's heading per UI language; -// the EF has only an epistle/lesson and a gospel. Unlike the OF headings these -// are not translated downstream, so they are set in the target language here. +// efPartHeading gives the traditional (1962) section's ID and heading per UI +// language; the EF has only an epistle/lesson and a gospel. The label words are +// i18n data, like the modern ones. Unlike the OF headings these are not +// translated downstream (render.LocalizeHeading only handles modern IDs), so +// they are resolved in the target language here. func efPartHeading(part, lang string) (id, heading string) { - pl := lang == "pl" switch part { case "first": - if pl { - return "epistola", "Lekcja" - } - return "epistola", "Lesson" + id = "epistola" case "gospel": - if pl { - return "evangelium", "Ewangelia" - } - return "evangelium", "Gospel" + id = "evangelium" + default: + return "", "" + } + heading = i18n.Get(lang).PartLabel[id] + if heading == "" { + heading = id + } + return id, heading +} + +// ofPartOrder and efPartOrder are the display orders of each lectionary's +// sections. They are the single source of truth for which part IDs exist. +var ( + ofPartOrder = []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} + efPartOrder = []string{"epistola", "evangelium"} +) + +// PartIDs returns the part IDs the given lectionary can emit, in display order. +// lect takes config.Config.Lectionary's values: "new" or "traditional". An +// unknown lectionary returns nil. Callers that build per-part UI (the dlectio +// app's reading filters) must derive their list from this rather than +// hardcoding IDs. +func PartIDs(lect string) []string { + switch lect { + case "new": + return append([]string(nil), ofPartOrder...) + case "traditional": + return append([]string(nil), efPartOrder...) } - return "", "" + return nil } // sectionsFor turns computed readings into render-ready sections, tagging each diff --git a/internal/readings/partids_test.go b/internal/readings/partids_test.go index 68f5ec0..1ba9373 100644 --- a/internal/readings/partids_test.go +++ b/internal/readings/partids_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/i18n" ) // The day header must carry the rank, not just name and colour: the app's @@ -28,3 +29,47 @@ func TestDayInfoCarriesRank(t *testing.T) { } } } + +// PartIDs must list exactly the IDs the engine can emit, in display order. +// The app derives its "show readings" checkboxes from this; when it hardcoded +// them instead, seven of the nine 1962 IDs were wrong and the epistle's +// checkbox did nothing. +func TestPartIDs(t *testing.T) { + wantNew := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} + wantOld := []string{"epistola", "evangelium"} + if got := PartIDs("new"); !equalSlice(got, wantNew) { + t.Errorf("PartIDs(new) = %v, want %v", got, wantNew) + } + if got := PartIDs("traditional"); !equalSlice(got, wantOld) { + t.Errorf("PartIDs(traditional) = %v, want %v", got, wantOld) + } + if got := PartIDs("nonsense"); len(got) != 0 { + t.Errorf("PartIDs(nonsense) = %v, want empty", got) + } +} + +// Every ID PartIDs lists must have a label in every shipped language, +// otherwise a checkbox would render a raw ID like "epistola". +func TestEveryPartIDHasLabels(t *testing.T) { + for _, lect := range []string{"new", "traditional"} { + for _, id := range PartIDs(lect) { + for _, lang := range []string{"en", "pl"} { + if i18n.Get(lang).PartLabel[id] == "" { + t.Errorf("%s/%s: no label for %q", lect, lang, id) + } + } + } + } +} + +func equalSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} -- cgit v1.3 From 70c504148ca9ddf7fdb31162f5f8da7e118fca45 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 4 Aug 2026 00:05:55 +0200 Subject: i18n: pin the Polish part labels; cross-reference the part-ID orders --- internal/i18n/vocab_test.go | 31 +++++++++++++++++++++++++++++++ internal/readings/offline.go | 9 +++++++++ internal/render/render.go | 8 ++++++++ 3 files changed, 48 insertions(+) (limited to 'internal/i18n') diff --git a/internal/i18n/vocab_test.go b/internal/i18n/vocab_test.go index bcd3464..97c09d6 100644 --- a/internal/i18n/vocab_test.go +++ b/internal/i18n/vocab_test.go @@ -65,3 +65,34 @@ func TestPolishRankWordingUnchanged(t *testing.T) { } } } + +// The Polish part labels must be exactly this spelling, diacritics included, +// and no others. This asserts against plUI directly, NOT i18n.Get("pl"): +// Get overlays the embedded English baseline first and then the Polish file +// on top (lang.go's Get), so a label present in English and missing only +// from plUI.PartLabel would resolve through Get("pl") to the identical +// English word rather than "" -- invisible to +// internal/readings/partids_test.go's TestEveryPartIDHasLabels, which only +// checks Get(lang) for non-emptiness. plUI is the unmerged golden source +// with no fallback, so a missing entry is genuinely absent there, which is +// the only way to catch this. The exact key-count check guards against a +// future addition to plUI.PartLabel slipping in unpinned. +func TestPolishPartLabelWordingUnchanged(t *testing.T) { + want := map[string]string{ + "pierwsze_czytanie": "1. czytanie", + "psalm": "Psalm", + "drugie_czytanie": "2. czytanie", + "aklamacja": "Aklamacja", + "ewangelia": "Ewangelia", + "epistola": "Lekcja", + "evangelium": "Ewangelia", + } + if len(plUI.PartLabel) != len(want) { + t.Fatalf("plUI.PartLabel has %d keys, want %d: %v", len(plUI.PartLabel), len(want), plUI.PartLabel) + } + for k, v := range want { + if got, ok := plUI.PartLabel[k]; !ok || got != v { + t.Errorf("plUI.PartLabel[%q] = %q (present=%v), want %q", k, got, ok, v) + } + } +} diff --git a/internal/readings/offline.go b/internal/readings/offline.go index c2c276b..608abd8 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -84,6 +84,15 @@ func efPartHeading(part, lang string) (id, heading string) { // ofPartOrder and efPartOrder are the display orders of each lectionary's // sections. They are the single source of truth for which part IDs exist. +// +// ofPartOrder's five modern IDs must remain the same *set* as +// internal/render/render.go's modernPartOrder, which lists them in a +// different, deliberate order (prefix-match determinism for +// render.LocalizeHeading, unrelated to display order). Nothing enforces +// that agreement mechanically -- internal/readings and internal/render do +// not import each other (adding a cross-package test would create a new +// dependency edge that does not exist today) -- so if a sixth modern part +// is ever added here, add it to modernPartOrder too, by hand. var ( ofPartOrder = []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} efPartOrder = []string{"epistola", "evangelium"} diff --git a/internal/render/render.go b/internal/render/render.go index c744aac..3437396 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -29,6 +29,14 @@ func versionLabel(version, lang string) string { // 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. +// +// Its *set* of five IDs must match internal/readings/offline.go's +// ofPartOrder, which lists the same modern IDs in a different, deliberate +// order (display order, unrelated to this package's prefix-match need). +// Nothing enforces that agreement mechanically -- internal/render and +// internal/readings do not import each other (adding a cross-package test +// would create a new dependency edge that does not exist today) -- so if a +// sixth modern part is ever added to ofPartOrder, add it here too, by hand. var modernPartOrder = []string{"pierwsze_czytanie", "drugie_czytanie", "psalm", "aklamacja", "ewangelia"} // LocalizeHeading swaps a modern (niedziela.pl) section heading's leading -- cgit v1.3 From 4855ef2a667b7ea71092e0582db5aff1838a0d63 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 5 Aug 2026 22:24:53 +0200 Subject: calendar/readings: report Sunday as its own display rank, not solemnity The Ordinary Form's Table of Liturgical Days treats Sunday as its own category, never a solemnity. temporal.go's sundayDay() already marks these days distinctly from named solemnities (Class stays unset, unlike solemn()'s ClassLord); internal/readings/offline.go now reads that existing signal to relabel the REPORTED rank to "sunday" for ordinary and privileged-season Sundays alike, leaving Celebration.Rank, ofRankOrder and all precedence untouched. Named solemnities landing on a Sunday (Easter, Pentecost, ...) and feasts of the Lord (Holy Family) keep reporting their own rank. The 1962 form is untouched. Added calendar.RankSunday (display-only, deliberately excluded from ofRankOrder), the i18n Sunday/niedziela words, and a test pinning the exact dates from the original bug report. --- internal/calendar/types.go | 12 ++++++++++ internal/i18n/golden_test.go | 4 ++-- internal/i18n/lang/en.ini | 1 + internal/i18n/lang/pl.ini | 1 + internal/i18n/vocab_test.go | 6 ++--- internal/readings/offline.go | 48 +++++++++++++++++++++++++++++++++++++- internal/readings/readings_test.go | 37 +++++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 6 deletions(-) (limited to 'internal/i18n') diff --git a/internal/calendar/types.go b/internal/calendar/types.go index 298e49d..6d828f1 100644 --- a/internal/calendar/types.go +++ b/internal/calendar/types.go @@ -17,6 +17,18 @@ const ( RankFeast Rank = "feast" // RankSolemnity Rank = "solemnity" // + // RankSunday is a DISPLAY-ONLY rank: in the 1969 Universal Norms' Table of + // Liturgical Days, Sunday is its own category, never a solemnity (Sundays + // of Christmas Time/Ordinary Time are band 6, below feasts of the Lord; + // Sundays of Advent/Lent/Easter are band 2, above solemnities -- a flat + // Rank cannot express both bands, and this constant does not try to). + // Nothing in this package ever assigns it to Celebration.Rank, and it MUST + // NOT be added to ofRankOrder below: ofRankOrder's default case (0, + // ferial-level) would make every Sunday lose every precedence contest. + // Callers that want it derive it themselves from the already-computed, + // unchanged Celebration (see internal/readings/offline.go's displayRank). + RankSunday Rank = "sunday" + // Extraordinary Form ranks (1960 Code of Rubrics): I-IV class + commemoration. RankClass1 Rank = "class-1" RankClass2 Rank = "class-2" diff --git a/internal/i18n/golden_test.go b/internal/i18n/golden_test.go index b32a259..d06d63b 100644 --- a/internal/i18n/golden_test.go +++ b/internal/i18n/golden_test.go @@ -27,7 +27,7 @@ var enUI = UI{ }, Rank: map[string]string{ "solemnity": "solemnity", "feast": "feast", "memorial": "memorial", - "optional": "optional memorial", "ferial": "feria", + "optional": "optional memorial", "ferial": "feria", "sunday": "Sunday", "class-1": "I class", "class-2": "II class", "class-3": "III class", "class-4": "IV class", "commemoration": "commemoration", @@ -125,7 +125,7 @@ var plUI = UI{ Rank: map[string]string{ "solemnity": "uroczystość", "feast": "święto", "memorial": "wspomnienie obowiązkowe", "optional": "wspomnienie dowolne", - "ferial": "dzień powszedni", + "ferial": "dzień powszedni", "sunday": "niedziela", "class-1": "I klasy", "class-2": "II klasy", "class-3": "III klasy", "class-4": "IV klasy", "commemoration": "komemoracja", diff --git a/internal/i18n/lang/en.ini b/internal/i18n/lang/en.ini index 60a5f32..4abd5d1 100644 --- a/internal/i18n/lang/en.ini +++ b/internal/i18n/lang/en.ini @@ -19,6 +19,7 @@ rank.ferial = feria rank.memorial = memorial rank.optional = optional memorial rank.solemnity = solemnity +rank.sunday = Sunday footer_keys = tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom q quit loading = loading… no_readings_for = "no readings for " diff --git a/internal/i18n/lang/pl.ini b/internal/i18n/lang/pl.ini index 5991938..4ae4ebf 100644 --- a/internal/i18n/lang/pl.ini +++ b/internal/i18n/lang/pl.ini @@ -19,6 +19,7 @@ rank.ferial = dzień powszedni rank.memorial = wspomnienie obowiązkowe rank.optional = wspomnienie dowolne rank.solemnity = uroczystość +rank.sunday = niedziela footer_keys = tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół q wyjście loading = ładowanie… no_readings_for = "brak czytań na " diff --git a/internal/i18n/vocab_test.go b/internal/i18n/vocab_test.go index 97c09d6..d0fd603 100644 --- a/internal/i18n/vocab_test.go +++ b/internal/i18n/vocab_test.go @@ -12,7 +12,7 @@ import ( func TestVocabularyCoversEveryConstant(t *testing.T) { ranks := []calendar.Rank{ calendar.RankFerial, calendar.RankOptional, calendar.RankMemorial, - calendar.RankFeast, calendar.RankSolemnity, + calendar.RankFeast, calendar.RankSolemnity, calendar.RankSunday, calendar.RankClass1, calendar.RankClass2, calendar.RankClass3, calendar.RankClass4, calendar.RankCommemoration, } @@ -31,7 +31,7 @@ func TestVocabularyCoversEveryConstant(t *testing.T) { func TestEnglishRankWordingUnchanged(t *testing.T) { want := map[string]string{ "solemnity": "solemnity", "feast": "feast", "memorial": "memorial", - "optional": "optional memorial", "ferial": "feria", + "optional": "optional memorial", "ferial": "feria", "sunday": "Sunday", "class-1": "I class", "class-2": "II class", "class-3": "III class", "class-4": "IV class", "commemoration": "commemoration", @@ -53,7 +53,7 @@ func TestPolishRankWordingUnchanged(t *testing.T) { want := map[string]string{ "solemnity": "uroczystość", "feast": "święto", "memorial": "wspomnienie obowiązkowe", "optional": "wspomnienie dowolne", - "ferial": "dzień powszedni", + "ferial": "dzień powszedni", "sunday": "niedziela", "class-1": "I klasy", "class-2": "II klasy", "class-3": "III klasy", "class-4": "IV klasy", "commemoration": "komemoracja", diff --git a/internal/readings/offline.go b/internal/readings/offline.go index be7c430..b2c16df 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -162,10 +162,56 @@ func dayInfo(cfg config.Config, day calendar.LiturgicalDay) liturgy.DayInfo { return liturgy.DayInfo{ Name: celebrationName(cfg, day.Observed), Colour: string(day.Colour), - Rank: string(day.Observed.Rank), + Rank: displayRank(cfg, day), } } +// displayRank is the Ordinary Form's REPORTED rank for the observed +// celebration -- it never changes calendar.Celebration.Rank (the engine's own +// field, which drives precedence via ofRankOrder); it only relabels what is +// handed to a caller here, after the engine has already finished computing. +// +// In the 1969 Universal Norms' Table of Liturgical Days, Sunday is its own +// category, not a solemnity. The calendar engine's temporal.go builds every +// "Nth Sunday of " day (Ordinary Time, Advent, Lent, the Easter +// season, Christmas time, and Palm Sunday) with sundayDay(), which sets +// Rank=solemnity but leaves Class at its zero value (calendar.ClassNone) -- +// solemnity is a placeholder there, not a real classification. A genuinely +// NAMED solemnity of the Lord that happens to fall on a Sunday (Easter Sunday +// itself, Pentecost, Ascension/Corpus Christi when transferred, Trinity, +// Christ the King, Christmas, Epiphany) is built by solemn(), which does set +// Class=ClassLord, and must keep reporting "solemnity"; a feast of the Lord +// (Holy Family, Baptism of the Lord) is Rank=feast already and is untouched. +// +// Investigated first: temporal.go's own Sunday bool (sundayDay's +// `Sunday: !priv`) looked like the natural signal, but it does not reach +// calendar.LiturgicalDay at all (LiturgicalDay carries no such field, only +// the aggregate ObservedBand), and even if plumbed through it would be the +// wrong signal here -- by its own doc comment it marks only the band-6 +// Christmas-time/Ordinary-time Sundays, not the band-2 Advent/Lent/Easter +// Sundays this change must ALSO relabel (the 1st Sunday of Advent from the +// original bug report is band 2, Privileged=true, Sunday=false). Using +// Celebration.Class instead of that flag, or of temporalDay.Privileged, +// covers exactly the sundayDay()-built set in both bands without adding any +// new field: RankSolemnity + Layer=="temporal" + Class!=ClassLord occurs only +// from sundayDay(), and (checked against every call site in temporal.go) only +// ever on an actual Sunday, so the weekday check below is defensive, not +// load-bearing. +// +// The Extraordinary Form (1962) has no such category and is untouched: this +// only ever fires when the modern form is selected. +func displayRank(cfg config.Config, day calendar.LiturgicalDay) string { + obs := day.Observed + if cfg.Selection().Form != "old" && + day.Weekday == time.Sunday && + obs.Layer == "temporal" && + obs.Rank == calendar.RankSolemnity && + obs.Class != calendar.ClassLord { + return string(calendar.RankSunday) + } + return string(obs.Rank) +} + // celebrationName is the observed celebration's name in the UI language, // resolved by naming.CelebrationName (name. -> English -> Latin -> // humanized slug). An empty result (unnamed feria) omits the header line. diff --git a/internal/readings/readings_test.go b/internal/readings/readings_test.go index 143b43e..1674d85 100644 --- a/internal/readings/readings_test.go +++ b/internal/readings/readings_test.go @@ -99,6 +99,43 @@ func TestLoadModern(t *testing.T) { } } +// TestSundayRankIsDisplayOnly guards the Ordinary Form's Sunday display rank. +// Sunday is its own category in the 1969 Universal Norms' Table of Liturgical +// Days -- never a solemnity -- so an ordinary "Nth Sunday of " temporal +// day (Ordinary Time, Advent, Lent, the Easter season, Christmas time; this +// also covers Palm Sunday) must report "sunday". A NAMED solemnity of the Lord +// that happens to fall on a Sunday (Easter Sunday itself, Pentecost) is a +// genuine solemnity and must keep reporting "solemnity"; a feast of the Lord +// (Holy Family) must keep reporting "feast". This is display-only: it must +// never touch the engine's internal precedence, so the 1962 form (which has no +// such Sunday category -- Sundays report their class exactly as before) is +// asserted unchanged too. Dates are the ones from the original bug report, +// plus the Easter/Pentecost/Holy Family dates they imply for the same +// liturgical year. +func TestSundayRankIsDisplayOnly(t *testing.T) { + cases := []struct { + date, lect, wantRank, why string + }{ + {"2026-08-09", "new", "sunday", "19th Sunday in Ordinary Time"}, + {"2026-11-29", "new", "sunday", "1st Sunday of Advent"}, + {"2027-03-28", "new", "solemnity", "Easter Sunday itself"}, + {"2027-05-16", "new", "solemnity", "Pentecost"}, + {"2026-12-27", "new", "feast", "Holy Family"}, + {"2026-08-09", "traditional", "class-2", "1962 Sunday after Pentecost"}, + {"2026-11-29", "traditional", "class-1", "1962 1st Sunday of Advent"}, + } + for _, c := range cases { + cfg := config.Config{Lectionary: c.lect} + _, info, err := Load(cfg, Options{Date: c.date, All: true}) + if err != nil { + t.Fatalf("%s (%s, %s): Load: %v", c.date, c.lect, c.why, err) + } + if info.Rank != c.wantRank { + t.Errorf("%s (%s, %s): Rank = %q, want %q (name=%q)", c.date, c.lect, c.why, info.Rank, c.wantRank, info.Name) + } + } +} + // TestLoadTraditional computes the Extraordinary Form day offline: it never // needs the network, and yields the EF epistle+gospel with a header name. func TestLoadTraditional(t *testing.T) { -- cgit v1.3