aboutsummaryrefslogtreecommitdiff
path: root/internal/readings
diff options
context:
space:
mode:
Diffstat (limited to 'internal/readings')
-rw-r--r--internal/readings/offline.go125
-rw-r--r--internal/readings/partids_test.go75
-rw-r--r--internal/readings/readings_test.go37
3 files changed, 221 insertions, 16 deletions
diff --git a/internal/readings/offline.go b/internal/readings/offline.go
index d0b8942..b2c16df 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,68 @@ 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.
+//
+// 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"}
+)
+
+// 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
+// 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), ofEmittedPartOrder...)
+ case "traditional":
+ return append([]string(nil), efPartOrder...)
}
- return "", ""
+ return nil
}
// sectionsFor turns computed readings into render-ready sections, tagging each
@@ -109,14 +154,62 @@ 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),
Colour: string(day.Colour),
+ 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 <season>" 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,
diff --git a/internal/readings/partids_test.go b/internal/readings/partids_test.go
new file mode 100644
index 0000000..fdbd816
--- /dev/null
+++ b/internal/readings/partids_test.go
@@ -0,0 +1,75 @@
+package readings
+
+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
+// 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)
+ }
+ }
+}
+
+// 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", "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
+}
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 <season>" 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) {