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 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) } }