package calendar_test import ( "encoding/json" "os" "sort" "strings" "testing" "time" "github.com/lukaszkasprzak/lectio/internal/caldata" "github.com/lukaszkasprzak/lectio/internal/calendar" ) // efOracleDay is one day of missalemeum's per-date proper API, as captured in // sources/snapshot.tar.gz (missalemeum/en/YYYY-MM-DD.json, 2026-01-01 .. // 2027-12-31, 730 days) and flattened by scripts/build-oracle-ef.sh. // // Three things about this data that will otherwise cost hours (see that // script's own header for the full account): // // 1. ID looks like "sancti:MM-DD:rank:colour", but its embedded rank is the // rank of the PROPERS USED that day, not the day's own rank -- e.g. // 2026-01-02 is a class-4 feria carrying id "sancti:01-01:1:w" because it // reuses the Circumcision's propers. Kept here for provenance/debugging // ONLY. Rank and Colour comparisons below use Rank/Colours, never ID. // 2. Colours is an array -- 14 of 730 days carry two values (Gaudete/Laetare // "pv", Palm Sunday "rv", Good Friday "bv", Holy Saturday "vw") -- so the // comparison is membership, not equality. // 3. A two-colour value on a weekday can be an artifact of proper reuse (a // feria inside Gaudete/Laetare week reusing the Sunday's own propers) // rather than a claim about that weekday's own colour. No special-casing // is needed for this: lectio's single ferial colour (violet) is already a // member of the reused set (e.g. ["p","v"]), so a plain membership check // absorbs it. type efOracleDay struct { ID string `json:"id"` Tempora string `json:"tempora"` Title string `json:"title"` Rank int `json:"rank"` Colours []string `json:"colours"` } // efColourLetters maps missalemeum's single-letter colour codes to lectio's // Colour vocabulary (scripts/gen-sanctoral-ef.go's colourWord table, same // mapping, kept in sync deliberately rather than imported across a // package/build-tag boundary). var efColourLetters = map[string]calendar.Colour{ "w": calendar.White, "r": calendar.Red, "g": calendar.Green, "v": calendar.Violet, "b": calendar.Black, "p": calendar.Rose, } // efRankNumbers maps missalemeum's 1-4 rank number to lectio's EF Rank // vocabulary. missalemeum has no separate "commemoration" rank number -- // RankCommemoration is a lectio-side concept for a losing sanctoral // candidate, never the oracle's claim about the OBSERVED day. var efRankNumbers = map[int]calendar.Rank{ 1: calendar.RankClass1, 2: calendar.RankClass2, 3: calendar.RankClass3, 4: calendar.RankClass4, } // efSeasonFromString maps a missalemeum tempora/title phrase (for a day in the // given month) to lectio's EF season. Order matters. Two position-dependent // subtleties: the Whit-octave weekdays ("Monday after Pentecost") are still // Paschaltide, so only "Sunday after Pentecost" is Time after Pentecost; and the // resumed Sundays after Epiphany (celebrated in autumn) sit within Time after // Pentecost, so an "after Epiphany" phrase in the second half of the year maps // there, not to Time after Epiphany. func efSeasonFromString(s string, month int) calendar.Season { s = strings.ToLower(s) // missalemeum's own EN-locale text still carries the occasional Latin // ligature (e.g. "Feria V after Sexagesimæ") -- confirmed in the // committed snapshot: "Sexagesimæ" appears 10 times, "Sexagesima" (no // ligature) only 4, and the ligatured form matched no case below, // silently mapping those 10 days to season "" (skipped from coverage // entirely, not merely a season miss). Normalising here fixes every // current and future ligature in one place, not just this one word. s = strings.ReplaceAll(s, "æ", "ae") has := func(subs ...string) bool { for _, sub := range subs { if strings.Contains(s, sub) { return true } } return false } switch { case has("vigil of christmas"): // last day of Advent return calendar.Advent case has("advent"): return calendar.Advent case has("sunday after pentecost"), has("trinity", "christ the king", "corpus christi"): return calendar.TimeAfterPentecost case has("after epiphany", "epiphany"): if month >= 6 { // resumed Epiphany Sundays, celebrated within Time after Pentecost return calendar.TimeAfterPentecost } return calendar.TimeAfterEpiphany case has("passion", "palm", "holy week", "maundy", "good friday", "holy saturday"): return calendar.Passiontide case has("septuagesima", "sexagesima", "quinquagesima"): return calendar.Septuagesima case has("ash wednesday", "lent"): return calendar.Lent case has("pentecost", "ascension", "rogation", "after easter", "easter", "low sunday", "paschal", "whit"): return calendar.Easter_ // incl. the Whit octave (Mon-Sat after Pentecost) case has("christmas", "nativity", "circumcision", "holy name", "octave day of christ"): return calendar.Christmas default: return "" // unknown phrase -> skip this day } } // efAllow is one cited allow-list entry: a genuine, defensible divergence // between lectio's EF engine and the missalemeum oracle on Rank and/or // Colour, kept out of the pass/fail count deliberately rather than silently // skipped. Every entry names the date(s) it covers, which field(s) it // excuses, and why -- with an RG citation where the divergence is a rubric // question, or a plain "not built" note where it is a documented scope gap // (see internal/calendar/precedence_ef.go and temporal_ef.go's own doc // comments, and colitur's rules-register.md, for the fuller accounts). type efAllow struct { date string // "" = pattern-matched by match(), see below match func(date string, day time.Time, oracle efOracleDay) bool field string // "rank", "colour", or "rank+colour" reason string } var efAllowList = []efAllow{ { // RG 124, "De coloribus paramentorum", section C "De colore rubro", // both photographic scans word for word: 124(b) gives red to // "Sanctorum Apostolorum et Evangelistarum, in eorum die natalicio, // excepto festo S. Ioannis (27 decembris)", and 124(e) to "Sanctorum // Martyrum, quorum colitur aut martyrium". Red is for Apostles, // Evangelists and Martyrs; a Confessor, Doctor or non-martyr Virgin is // white. // // missalemeum has these INVERTED IN BOTH DIRECTIONS -- ten confessors // and virgins in red, and two actual martyrs (Apollinaris and // Josaphat, both Bishop and Martyr) in white. Both directions failing // is why this is read as a systematic upstream error rather than a // judgement call: a rule applied backwards, not a series of // individual mistakes. // // colitur adjudicated the same fourteen slugs against the scans (its // C18 carries the per-slug reasoning, its M21 the oracle side, verdict // colitur). Twelve are corrected here; conversion-of-st-paul and // chair-of-st-peter already agreed. match: func(date string, day time.Time, o efOracleDay) bool { switch date[5:] { case "06-12", "06-18", "06-19", "07-12", "07-18", "07-20", "07-23", "07-29", "08-02", "08-28", "08-30", "11-14": return true } return false }, field: "colour", reason: "RG 124: red is for Apostles, Evangelists and Martyrs; missalemeum has ten confessors/virgins red and two martyrs white, inverted in both directions (colitur C18/M21)", }, { // The 1962 Missal's own universal CALENDARIUM ranks both of these as // bare III-class feasts, with no commemoration rubric: "S. Ubaldi Ep. // et Conf., III classis" (16 May) and "S. Didaci Conf., III classis" // (13 November). Each also has its own Mass entry in the Proprium, // taking its readings from a Common with only its Oratio proper -- // which a bare commemoration never has. missalemeum ranks both as // commemorations, so on these dates it leaves the day unimpeded and // shows a feria or the Saturday BVM Office; lectio now shows the feast. // // This entry MUST precede the BVM-Saturday entry below. When 16 May or // 13 November falls on a Saturday, that entry's predicate (Saturday + // an oracle title naming the B.V.M.) also matches, and would absorb // this divergence under a reason that is no longer true of it -- it // says "both engines agree only that the day is unimpeded", and the // whole point here is that the day IS impeded, by a feast missalemeum // does not rank as one. // // Found by auditing all 290 of colitur's fixed-date sanctoral entries // against that calendarium; these two were the only status defects. // colitur allow-list C38/M30, rules-register.md 6.25. match: func(date string, day time.Time, o efOracleDay) bool { md := date[5:] return (md == "05-16" || md == "11-13") && !strings.Contains(strings.ToLower(o.Title), "ubald") && !strings.Contains(strings.ToLower(o.Title), "didac") }, field: "rank+colour", reason: "Missal calendarium ranks St Ubaldus (16 May) and St Didacus (13 Nov) III classis, not commemorations; missalemeum keeps them as commemorations and so leaves the day unimpeded (colitur C38/M30)", }, { // RG 128, "De colore violaceo": violet is used for vigils of II and III // class outside Paschaltide. The Vigil of the Assumption (14 August, II // class) and the Vigil of St Lawrence (9 August, III class) both // qualify. missalemeum shows white and red respectively -- values // lectio carried too, and colitur inherited from lectio, all three // descending from Divinum Officium's data files. The rule's sole // exception is named in the same place and is not either of these: the // Ascension's vigil, the one II-class vigil INSIDE Paschaltide, which // is white and is untouched. // // colitur allow-list C37/M29, rules-register.md 6.24. // NOT on a Sunday. RG 33 omits a II/III-class vigil whenever its date // falls on one, so lectio shows the Sunday and the divergence there is // missalemeum's own RG 33 gap -- already allow-listed further down, // with its own citation. Without this guard, 2026-08-09 (a Sunday whose // oracle title still reads "Vigil of St. Lawrence") matches on the // title alone and is stolen from that entry, reported under a colour // rule that has nothing to do with it. Same failure this list's first // entry exists to prevent, in the opposite direction. match: func(date string, day time.Time, o efOracleDay) bool { md := date[5:] return (md == "08-09" || md == "08-14") && day.Weekday() != time.Sunday && strings.Contains(strings.ToLower(o.Title), "vigil") }, field: "colour", reason: "RG 128: violet for vigils of II and III class outside Paschaltide; missalemeum shows white/red (colitur C37/M29)", }, { // RG 91 entry 27, "Officium sanctae Mariae in sabbato": the votive // BVM Office on an otherwise-unoccupied IV-class Saturday. Not built // in lectio (temporal_ef.go constructs only a bare ferial slug on // these Saturdays; the vocabulary comment in colitur's own register // documents the same gap on its side). missalemeum's rank/colour for // these Saturdays reflects the BVM Office (usually white); lectio's // reflects the plain per-annum feria (green, class-4). A real, // scoped, pre-existing gap -- not one of the seven defects, and not // attempted here (a Plan-scale feature, per the sibling project's own // parking of the identical item). match: func(date string, day time.Time, o efOracleDay) bool { return day.Weekday() == time.Saturday && strings.Contains(strings.ToLower(o.Title), "b. v. m") }, field: "rank+colour", reason: "RG 91 entry 27 (BVM Office on Saturday) is not built in lectio; both engines agree only that the day is unimpeded", }, { // RG 72-73 place 6-13 January within Christmastide itself ("a I // Vesperis Nativitatis Domini usque ad diem 13 ianuarii inclusive"); // RG 119(a) backs the same boundary from the white-colour side // ("...usque ad expletum tempus Epiphaniae"). lectio's efSeason // (temporal_ef.go) instead starts "time after Epiphany" on 6 January, // so a plain feria in this window gets green/TimeAfterEpiphany // instead of white/Christmastide. This is a real, PRE-EXISTING season- // boundary divergence -- colitur's own rules-register.md documents // the identical gap on its side (§3c item 1) as deliberate and // resolved in the OTHER engine's favour, not one of the seven named // defects, and not touched here: efSeason is a season-boundary // function with much wider reach than any of the seven, and changing // it is out of this task's scope. match: func(date string, day time.Time, o efOracleDay) bool { return day.Month() == time.January && day.Day() >= 6 && day.Day() <= 13 }, field: "colour", reason: "RG 72-73/RG 119(a): 6-13 Jan is Christmastide, not lectio's time-after-Epiphany reading (efSeason, not one of the seven defects; see colitur rules-register.md §3c item 1)", }, { // RG 33: "Vigilia II aut III classis penitus omittitur, si occurrat in // dominica quavis..." -- a II/III-class vigil is ENTIRELY OMITTED on // any Sunday. 9 August 2026 is a Sunday; the Vigil of St Lawrence (III // class) must vanish and the Sunday itself be observed -- which is // exactly what lectio does (see cmd/lectio-ef-dump: 2026-08-09 // observed is the II-class Sunday, the vigil demoted to a losing // candidate). missalemeum's OWN data shows the vigil still winning // that Sunday, an RG 33 gap on missalemeum's side, not lectio's -- // already identified and adjudicated in the primary source's favour // by the sibling project (rules-register.md §6, "the romanus-vs- // vigil-of-st-lawrence 9 August Sunday tie-break", M1). date: "2026-08-09", field: "rank+colour", reason: "RG 33: a II/III-class vigil is omitted on any Sunday; missalemeum itself shows an RG 33 gap here (colitur rules-register.md §6, M1) -- lectio's Sunday-observed answer is the Missal-correct one", }, { // OPEN, not adjudicated by any primary text found so far. RG 91's // plain table gives St Joseph (19 March, I class) the day outright // over an ordinary III-class Friday of Passion Week -- no RG 96 // collision requiring a transfer -- yet missalemeum shows Joseph // entirely displaced in 2027 specifically (the Friday's own office // observed, the Seven Sorrows commemorated instead). The sibling // project's much deeper primary-source pass over the SAME question // left it explicitly open (colitur rules-register.md §6, M13: "three // possibilities, none confirmed") rather than guess at an unfound // rubric. Not attempted here either, for the same reason -- adjudicating // it would mean finding a citation that a more thorough primary-source // audit did not. date: "2027-03-19", field: "rank+colour", reason: "unresolved even by primary-source review (colitur rules-register.md §6, M13); not adjudicated here either way", }, } func efAllowed(date string, day time.Time, oracle efOracleDay, field string) (bool, string) { for _, a := range efAllowList { if a.date != "" && a.date != date { continue } if a.match != nil && !a.match(date, day, oracle) { continue } if a.field == field || a.field == "rank+colour" { return true, a.reason } } return false, "" } // TestOracleEF diffs the EF engine against missalemeum (Divinum Officium data) // over 730 days (2026-01-01 .. 2027-12-31). Season, Rank and Colour are all // asserted strictly (membership for Colour, since missalemeum's colour is an // array); a prior version of this test asserted Season only, which is exactly // why several precedence/colour defects (see precedence_ef.go, temporal_ef.go) // shipped without ever failing a test. func TestOracleEF(t *testing.T) { raw, err := os.ReadFile("testdata/oracle-ef.json") if err != nil { t.Skip("EF oracle snapshot missing; run scripts/build-oracle-ef.sh") } var oracle map[string]efOracleDay if err := json.Unmarshal(raw, &oracle); err != nil { t.Fatal(err) } sel := calendar.DefaultSelection() sel.Form = "old" layers := []calendar.Layer{caldata.Tridentine()} dates := make([]string, 0, len(oracle)) for d := range oracle { dates = append(dates, d) } sort.Strings(dates) var seasonMiss, rankMiss, colourMiss, rankAllowed, colourAllowed, skipped, total int shownSeason, shownRank, shownColour := 0, 0, 0 for _, date := range dates { od := oracle[date] src := od.Tempora if src == "" { src = od.Title } day, _ := time.Parse("2006-01-02", date) want := efSeasonFromString(src, int(day.Month())) total++ got := calendar.Compute(day.UTC(), sel, layers) // An unrecognised season phrase skips the SEASON comparison only -- // it must not also skip Rank/Colour for that day. A `continue` here // once did exactly that, silently zeroing oracle coverage for every // day whose phrase efSeasonFromString doesn't recognise (Holy // Thursday -- "maundy"/"holy week" don't match "Holy Thursday" // itself; the six September Ember days -- no "ember" case exists at // all, only Advent/Lent Ember days pass by an incidental substring // match on "advent"/"lent"). Reverting the Holy Thursday colour fix // (temporal_ef.go) left this test green under the old `continue`; // it does not under this one. if want == "" { skipped++ } else if got.Season != want { seasonMiss++ if shownSeason < 40 { t.Errorf("%s: EF season got %q want %q (from %q)", date, got.Season, want, src) shownSeason++ } } wantRank, rankKnown := efRankNumbers[od.Rank] if rankKnown && got.Observed.Rank != wantRank { if ok, reason := efAllowed(date, day, od, "rank"); ok { rankAllowed++ t.Logf("[allow-listed] %s: EF rank got %q want %q — %s", date, got.Observed.Rank, wantRank, reason) } else { rankMiss++ if shownRank < 40 { t.Errorf("%s: EF rank got %q want %q (oracle rank %d, title %q)", date, got.Observed.Rank, wantRank, od.Rank, od.Title) shownRank++ } } } colourOK := false for _, c := range od.Colours { if efColourLetters[c] == got.Colour { colourOK = true break } } // Membership alone cannot catch a Rose regression: violet is a // member of every rose/violet pair by construction (Gaudete/ // Laetare), so "got violet, want one of [rose violet]" passes even // if RG 131's Rose support (temporal_ef.go) were reverted entirely. // On the day the rose/violet pair actually names -- oracle rank 1, // the Sunday itself, not a weekday reusing its propers (which // carries a lower rank and is deliberately membership-only, this // file's own doc comment item 3) -- a colour set containing rose // demands rose specifically, not merely "some member". if colourOK && od.Rank == 1 && got.Colour != calendar.Rose { for _, c := range od.Colours { if c == "p" { colourOK = false break } } } if !colourOK && len(od.Colours) > 0 { if ok, reason := efAllowed(date, day, od, "colour"); ok { colourAllowed++ t.Logf("[allow-listed] %s: EF colour got %q want one of %v — %s", date, got.Colour, od.Colours, reason) } else { colourMiss++ if shownColour < 40 { t.Errorf("%s: EF colour got %q want one of %v (title %q)", date, got.Colour, od.Colours, od.Title) shownColour++ } } } } t.Logf("EF oracle: %d checked, %d skipped(unmapped), %d season mismatches, "+ "%d rank mismatches (%d allow-listed), %d colour mismatches (%d allow-listed)", total, skipped, seasonMiss, rankMiss, rankAllowed, colourMiss, colourAllowed) if seasonMiss > 0 { t.Fatalf("%d/%d EF season mismatches vs missalemeum — temporalEF is wrong", seasonMiss, total) } if rankMiss > 0 { t.Fatalf("%d/%d EF rank mismatches vs missalemeum (not allow-listed)", rankMiss, total) } if colourMiss > 0 { t.Fatalf("%d/%d EF colour mismatches vs missalemeum (not allow-listed)", colourMiss, total) } } // TestEFLectionaryKeysAreReachableSlugs asserts that every section name in the // EF temporal lectionary is a slug the calendar can actually compute. A key // nothing ever matches is dead data: the reading sits in the file, the lookup // misses it, and the day silently falls back to the preceding Sunday's Mass — // which looks like a plausible answer, so nothing downstream notices. // // This is not hypothetical. Three sections were filed as ef-lent-1-{wednesday, // friday,saturday} while temporal_ef.go's efEmberSlug computes // ef-lent-ember-{wed,fri,sat}, so the Lenten Ember days served Lent I Sunday's // Mass for as long as the data existed. The keys were stale: they predate Lent // being added to efEmberSlug, and scripts/genlect.go — which keys off // day.Observed.Slug — would already write the correct names if re-run. // // It survived every existing test because the wrong answer is a real Mass, and // it survived cross-checking against colitur (the sibling OCaml engine) // because colitur had bootstrapped the same wrong keys from this very file. // Two engines agreeing is not evidence when one was seeded from the other. func TestEFLectionaryKeysAreReachableSlugs(t *testing.T) { raw, err := os.ReadFile("../caldata/tridentine-lectionary.ini") if err != nil { t.Fatalf("read lectionary: %v", err) } keys := map[string]bool{} for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { keys[line[1:len(line)-1]] = true } } if len(keys) == 0 { t.Fatal("no sections parsed from the lectionary") } sel := calendar.DefaultSelection() sel.Form = "old" layers := []calendar.Layer{caldata.Tridentine()} // Twelve years. Every key in the shipped file is reached within five (the // movable ones recur annually; the Ember and vigil days are the only // awkward cases and they occur every year too), so this is a bit over // double the margin actually needed. Widening it costs test time linearly // and buys nothing measurable — 46 years took 91s, this takes a quarter of // that — but narrowing below five would start reporting live keys as dead. seen := map[string]bool{} for d := time.Date(2005, 1, 1, 0, 0, 0, 0, time.UTC); d.Year() <= 2016; d = d.AddDate(0, 0, 1) { seen[calendar.Compute(d, sel, layers).Observed.Slug] = true } var dead []string for k := range keys { if !seen[k] { dead = append(dead, k) } } sort.Strings(dead) if len(dead) > 0 { t.Errorf("lectionary keys no computed slug ever matches (dead data, the day "+ "silently falls back to the preceding Sunday): %v", dead) } }