From 3d3ad4ba90c4d7dea4730ef129f8c9e065f05d6e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:40:30 +0200 Subject: liturgy: carry the observed rank on DayInfo --- internal/readings/partids_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 internal/readings/partids_test.go (limited to 'internal/readings/partids_test.go') diff --git a/internal/readings/partids_test.go b/internal/readings/partids_test.go new file mode 100644 index 0000000..68f5ec0 --- /dev/null +++ b/internal/readings/partids_test.go @@ -0,0 +1,30 @@ +package readings + +import ( + "testing" + + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// The day header must carry the rank, not just name and colour: the app's +// calendar rows and the CLI's day line both display it. +func TestDayInfoCarriesRank(t *testing.T) { + cases := []struct { + date, lect, wantRank string + }{ + // 2026-08-01 is the memorial of St Alphonsus Liguori in the OF. + {"2026-08-01", "new", "memorial"}, + // 2026-12-25 is a solemnity. + {"2026-12-25", "new", "solemnity"}, + } + for _, c := range cases { + cfg := config.Config{UILanguage: "en", Lectionary: c.lect, All: true} + _, info, err := Load(cfg, Options{Date: c.date, All: true}) + if err != nil { + t.Fatalf("%s: load: %v", c.date, err) + } + if info.Rank != c.wantRank { + t.Errorf("%s: rank = %q, want %q", c.date, info.Rank, c.wantRank) + } + } +} -- 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/readings/partids_test.go') 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 eb4a02205f5b3b972c44b854d5becbbd0b522981 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 5 Aug 2026 14:10:34 +0200 Subject: readings: PartIDs(new) stops declaring aklamacja, which the engine never emits caldata.go:42 parses only first/psalm/second/gospel out of the lectionary data, so no OF reading ever carries Part == "acclamation". PartIDs("new") listed aklamacja anyway, so the app built a checkbox from it that filters an ID that never appears -- a dead control, same defect class the app previously shipped for the whole 1962 form. ofPartOrder stays the full five-ID set: it also drives render.LocalizeHeading's label matching, where a scraped heading can still read "Aklamacja" even though this engine's own readings never produce that section. PartIDs now draws from a new, narrower ofEmittedPartOrder instead. Rewrote TestPartLabelsMatchesWhatTheEngineEmits's OF half: it compared PartLabels("of") to a hand-copied duplicate of ofPartOrder, asserting a declaration against itself, which cannot fail for this class of bug. It now sweeps Days over calendar year 2026 and asserts PartLabels("of") matches the observed part-ID set exactly (0.5s). Confirmed red against the pre-fix code, green after. --- internal/readings/offline.go | 14 ++++++++++- internal/readings/partids_test.go | 2 +- mobile/mobile_test.go | 49 ++++++++++++++++++++++++++++++--------- 3 files changed, 52 insertions(+), 13 deletions(-) (limited to 'internal/readings/partids_test.go') diff --git a/internal/readings/offline.go b/internal/readings/offline.go index 608abd8..be7c430 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -98,6 +98,18 @@ var ( efPartOrder = []string{"epistola", "evangelium"} ) +// ofEmittedPartOrder is the subset of ofPartOrder the offline engine can +// actually produce, in display order. It excludes "aklamacja": +// internal/caldata/caldata.go:42 parses only "first", "psalm", "second" and +// "gospel" out of the lectionary data, so no computed OF reading ever carries +// Part == "acclamation" and ofPart's "aklamacja" mapping above is never +// reached. ofPartOrder stays the full five-ID set on purpose -- it also +// drives render.LocalizeHeading's *label* matching, where a scraped heading +// can still read "Aklamacja" even though this engine's own readings never +// produce that section -- so PartIDs, which promises IDs an app can filter +// on, needs this narrower list rather than reusing or shrinking ofPartOrder. +var ofEmittedPartOrder = []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "ewangelia"} + // 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 @@ -106,7 +118,7 @@ var ( func PartIDs(lect string) []string { switch lect { case "new": - return append([]string(nil), ofPartOrder...) + return append([]string(nil), ofEmittedPartOrder...) case "traditional": return append([]string(nil), efPartOrder...) } diff --git a/internal/readings/partids_test.go b/internal/readings/partids_test.go index 1ba9373..fdbd816 100644 --- a/internal/readings/partids_test.go +++ b/internal/readings/partids_test.go @@ -35,7 +35,7 @@ func TestDayInfoCarriesRank(t *testing.T) { // 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"} + wantNew := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "ewangelia"} wantOld := []string{"epistola", "evangelium"} if got := PartIDs("new"); !equalSlice(got, wantNew) { t.Errorf("PartIDs(new) = %v, want %v", got, wantNew) diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index cf75b7f..85de500 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -152,23 +152,50 @@ func TestPartLabelsMatchesWhatTheEngineEmits(t *testing.T) { t.Errorf("ef[1] = %v, want evangelium/Ewangelia", ef[1]) } + // OF: derive the expected set from what Days actually emits over a full + // year (2026 -- a full Sunday cycle, so second readings appear too), + // rather than hand-copying the production list, which asserts a + // declaration against itself and can never fail for this class of bug + // (that is exactly how the app came to render a checkbox -- aklamacja -- + // that filters an ID the engine never emits). + observed := map[string]bool{} + s := Days("2026-01-01", 365, "of", "pl") + var days []map[string]any + if err := json.Unmarshal([]byte(s), &days); err != nil { + t.Fatalf("Days returned invalid JSON: %v", err) + } + for _, d := range days { + parts, _ := d["parts"].([]any) + for _, p := range parts { + part, _ := p.(map[string]any) + if id, ok := part["part"].(string); ok { + observed[id] = true + } + } + } + if len(observed) == 0 { + t.Fatal("swept zero part IDs from Days over 2026 -- the sweep is broken, not necessarily the engine") + } + var of []map[string]any if err := json.Unmarshal([]byte(PartLabels("of", "pl")), &of); err != nil { t.Fatalf("invalid JSON: %v", err) } - if len(of) != 5 { - t.Fatalf("of: got %d labels, want 5: %v", len(of), of) - } - // aklamacja was missing from the app's hardcoded list. - var seen []string + got := map[string]bool{} for _, e := range of { - seen = append(seen, e["part"].(string)) + got[e["part"].(string)] = true + } + if len(got) != len(of) { + t.Fatalf("PartLabels(of) lists a part ID more than once: %v", of) + } + for id := range observed { + if !got[id] { + t.Errorf("Days emits part %q somewhere in 2026 but PartLabels(of) does not list it: %v", id, of) + } } - want := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} - for i := range want { - if seen[i] != want[i] { - t.Errorf("of order = %v, want %v", seen, want) - break + for id := range got { + if !observed[id] { + t.Errorf("PartLabels(of) lists part %q but Days never emits it anywhere in 2026: %v", id, of) } } } -- cgit v1.3