diff --git a/TODO.md b/TODO.md index bd7c328..0d017da 100644 --- a/TODO.md +++ b/TODO.md @@ -48,6 +48,12 @@ across every hour and content type in this app. - The bulk Vulgate (Latin) + Douay-Rheims (English) scripture import into `src/data/scripture/` — long-term project, explicitly deferred; Matins scripture citations resolve to `missing` text until it lands. + - Vespers Magnificat antiphon: after the 2026-08-30 sweep plus the same-day O-Antiphon + follow-up (see "O Antiphons (Dec 17-23) added" below), only two narrow gaps remain — + Advent 4's own Sunday landing on Dec 24 specifically (outside the O-Antiphon range), and + `marian-saturday` (moot in practice — its own Vespers is never actually reachable, a + following Sunday always claims First Vespers first). Neither resolves from the static + reference files alone; would need a live-CGI query. - Lauds/Vespers duplex-majus+ Common-category psalmody: 10 of 24 `SaintRecord.common` categories authored (covering 19 ids via aliases) for Vespers, 9 of 24 (18 ids) for Lauds — see "Lauds/Vespers duplex-majus+ Common-category psalmody" below and its follow-up entry. @@ -4096,3 +4102,110 @@ rather than a drop. Regression test added (`tests/hours/compline.test.ts`, 2026- Assumption, Duplex I. classis) confirming the short-form text (Kyrie/Pater/"Dómine, exáudi") is present and the fuller exchanges ("Benedíctus es, Dómine.../Miserére nostri...") are absent. `npm test` (543 passed), `tsc --noEmit`, and `npm run build` all pass. + +### Vespers Magnificat antiphon — whole-calendar temporal-cycle gap closed, 52/54 (2026-08-30) + +User flagged 2026-08-29's Vespers was missing its Magnificat antiphon. Root cause turned out to +be systematic, not date-specific: `getMagnificatAntiphon` (`hours/resolve-common.ts`) looks up +`${winner.id}-magnificat-antiphon` for a temporal winner, but of the 57 temporal ids with an +authored Lauds `-benedictus-antiphon.yml`, only 3 (`holy-name-of-jesus`, `immaculate-heart-of-mary`, +`circumcision`) had a matching Vespers `-magnificat-antiphon.yml` — every ordinary Sunday/named-feast +in the temporal cycle (all 24 `post-pentecost-NN` weeks, Advent, post-Epiphany, pre-Lent, Lent, +Passion/Palm Sunday, the whole Easter season, Pentecost, Christmas-octave Sunday, Christ the King) +had its Lauds antiphon but not its Vespers one, apparently missed whenever the Lauds sweep pulled +`[Ant 2]` from the reference engine's Tempora files but never followed up with `[Ant 3]`. + +Sourced from the same static reference-engine files already used for the Benedictus sweep +(`../divinum-officium-reference/web/www/horas/{Latin,English}/Tempora/*.txt`), same occasion/date +as each id's own `-benedictus-antiphon.yml` sibling (its header comment already records the clean +verified date) — no live CGI needed, the Magnificat antiphon is baked into each static file's own +`[Ant 3]` block (sometimes an alias, e.g. `@:Ant 3_` or `@:Ant 1`, always resolved to its real text +before use, and always the *default*/Tridentine variant, never a `(rubrica cisterciensis...)` one). +`post-pentecost-01`..`23` map straight to `Tempora/PentNN-0.txt`; `post-pentecost-24` (the fixed +last Sunday) does *not* map to `Pent24-0` — cross-checked against its own already-authored +Benedictus antiphon and confirmed it actually reuses the leftover `post-epiphany-6` formulary +(`Tempora/Epi6-0.txt`), the classic "Sundays remaining over are supplied by the omitted +post-Epiphany Sundays" rubric, already implicit in this app's own resumed-Epiphany-Sunday overflow +logic. `post-epiphany-1` doesn't map to `Epi1-0.txt` (that file is Holy Family Sunday content in +the reference engine, a title mismatch against the already-authored Benedictus text) but to +`Epi1-0a.txt` ("Dominica infra Octavam Epiphaniæ"). `christ-the-king` (not a Tempora entry at all, +a 1925 addition) sourced from `Sancti/10-DU.txt` instead, matching its own Benedictus antiphon's +already-verified occasion. + +**Two ids skipped, not fabricated**: `advent-4` (`Tempora/Adv4-0.txt` has no standalone `[Ant 3]` +at all — only `[Ant Vespera 3] @:Ant Laudes`, aliasing the whole 5-antiphon Lauds set rather than +one text, and which single entry the live engine actually picks isn't determinable from the static +file alone) and `marian-saturday` (the votive "Sanctæ Mariæ Sabbato" office's Magnificat antiphon +isn't in any static Tempora/Sancti/Commune file found — likely only resolvable via a live CGI +query, same as how its own Benedictus antiphon was originally sourced). + +### O Antiphons (Dec 17-23) added — advent-4's own gap effectively closed (2026-08-30) + +Follow-up the same day, prompted by a user report that 2026-08-29's own Vespers was missing its +Magnificat antiphon entirely for an unrelated reason (see the day-label winner-first fix earlier +this date) that turned out to expose this whole-calendar gap. Root cause of `advent-4`'s own +skip above, once actually investigated live (divinumofficium.com, Monastic Tridentinum 1617): +Dec 17-23 always carries one of the seven "O Antiphons" at Vespers, keyed to the *calendar date* +itself, not to whichever Advent week (`advent-3` or `advent-4`, depending on the year) happens to +govern that date — a single per-week static file was structurally the wrong shape for this from +the start, not just missing content. + +Added `data/hours/vespers-o-antiphons.yml` (7 entries, `MM-DD` keyed) and a new lookup tier in +`hours/resolve-common.ts`'s `getMagnificatAntiphon`. 5 of the 7 (Dec 17/18/19/22/23) +live-verified directly. The remaining two — "O Clavis David" (Dec 20) and "O Oriens" (Dec 21) — +could not be: every version/track on the live engine (including plain Tridentine 1570) routes +those two dates through St. Thomas's Vigil/feast instead of the ferial Proper of the Time, +apparently a real modeling choice of that engine (a Vigil of an Apostle actually displacing the +ferial Magnificat antiphon), not an app quirk — and this app's own `vigil-of-st-thomas.yml` +already documents a deliberate divergence from exactly that engine behavior on this date (the +Vigil transfers away entirely here, per the ordinary privileged-feria/vigil rule). Marked `draft` +rather than `verified`: real, standard, universally-fixed O-Antiphon wording (unchanged across +every edition of the Roman/Monastic Breviary), just not confirmed against this app's own primary +live reference for lack of a clean query path — see the data file's own header. + +**Follow-up the same day, direct user instruction**: the O Antiphon lookup was originally scoped +to only apply when the day's winner was still plain-temporal (i.e. it deferred to a real +sanctoral winner, same as every other tier in `getMagnificatAntiphon`). The user explicitly +wanted the O Antiphons to actually surface even against a real winning saint — St. Thomas (Dec +21, Duplex II. classis) otherwise wins that day outright with no Magnificat antiphon of his own +authored yet (a separate, pre-existing gap — no sanctoral saint has a +`${propers}-magnificat-antiphon.yml` at all), so "O Oriens" was structurally unreachable on its +own real date. Considered and rejected: lowering Thomas's rank so a ferial default would win Dec +20/21 instead — factually wrong (he really is Duplex II Class pre-1955) and would strip him of +his *entire* office (chapter/hymn/collect/Lauds antiphon too), not just Vespers' Magnificat. +Instead, moved the O-Antiphon lookup to the very top of `getMagnificatAntiphon`, checked +unconditionally before either the temporal or sanctoral branch — the O Antiphon now wins this one +specific text slot on Dec 17-23 full stop, regardless of who otherwise wins the day; every other +part of the office is untouched. Deliberate, explicit app-level design choice (not a +reconstruction of the reference engine's own Dec 20/21 behavior, which shows Thomas's own +antiphon there instead) — see the data file's own header for the reasoning, including why a +saint's own real Magnificat antiphon is still worth authoring later even knowing it would be +permanently unreachable at Vespers on whichever of these seven dates that saint occupies, under +*this* calendar's own construction: the user has a future user-editable calendar in mind, where a +differently-constructed calendar's own precedence might surface it. `tests/hours/vespers.test.ts`'s +Thomas-precedence case now asserts the opposite of its original intent (the O Antiphon wins, his +other office parts don't). + +Net effect: `advent-4`'s practical gap is closed for every date this app's calendar can actually +reach — Dec 17-23 unconditionally now, including Dec 21 itself. One narrow residual sub-case +remains, not touched by this fix: if Advent 4's own *Sunday* itself lands on Dec 24 (the latest +possible date, outside the O-Antiphon range and excluded from the classic weekday-default +fallback since Sunday has none), it still resolves to `missing` — genuinely no source for that +one specific Sunday found yet. `marian-saturday` is unchanged by this pass (still skipped, per +above) — and turns out to be moot for Vespers specifically anyway: its own evening is never +actually reachable through `resolveOrdo`, since a following Sunday always claims First Vespers +over the preceding Saturday regardless of the Sunday's own rank (`calendar/vespers.ts`'s +`hasFirstVespers`). + +New tests: `tests/hours/vespers.test.ts`'s "O Antiphons (Dec 17-23)" block (the Dec 19 case +end-to-end, plus the Thomas-precedence case); `tests/hours/resolve-common.test.ts`'s new +`getMagnificatAntiphon — classic weekday-default fallback` block replaces the old bare-ferial +`resolveOrdo` test with two synthetic-day unit tests instead, since no live date is left in this +app's calendar that still reaches that fallback tier through the real pipeline at all (every +other temporal id now has its own real content, per this whole sweep). `npm test` (556 tests) +and `tsc --noEmit` both pass; live-verified on the dev server (2026-08-29 and 2026-12-19). + +52 new `src/data/propers/temporal/*-magnificat-antiphon.yml` files. `npm test` (553 passed) and +`tsc --noEmit` both pass; spot-checked live in the dev server that 2026-08-29's Vespers now shows +the Magnificat antiphon ("Quǽrite primum * regnum Dei et justítiam ejus...", `post-pentecost-14`'s +own). diff --git a/src/data/hours/vespers-o-antiphons.yml b/src/data/hours/vespers-o-antiphons.yml new file mode 100644 index 0000000..3c7adc5 --- /dev/null +++ b/src/data/hours/vespers-o-antiphons.yml @@ -0,0 +1,86 @@ +# The seven "O Antiphons" (Dec 17-23): the classic pre-Christmas Vespers +# Magnificat antiphons, keyed by calendar date (`MM-DD`) rather than by +# any Advent-week temporal id -- unlike every other week's proper +# Magnificat antiphon (data/propers/temporal/advent-N-magnificat- +# antiphon.yml, one file per Advent week), these seven don't belong to a +# single Advent week at all, since Dec 17-23 can fall within either +# Advent's 3rd or 4th week depending on the year (Advent's own governing +# Sunday moves, see calendar/temporal-id.ts). +# +# See hours/resolve-common.ts's getMagnificatAntiphon for the date-keyed +# lookup this feeds -- checked *unconditionally*, ahead of both the +# per-week temporal file and a real sanctoral winner's own antiphon. +# Direct user instruction (2026-08-30): the O Antiphon always wins this +# one specific text slot on these seven dates, full stop, regardless of +# who otherwise wins the day -- e.g. St. Thomas, Duplex II. classis, +# Dec 21, still wins everything else about that day (chapter, hymn, +# collect, Lauds' own Benedictus antiphon), just not the Magnificat +# antiphon. This is a deliberate app-level design choice, not a +# reconstruction of the reference engine's own behavior (its live query +# shows Thomas's own antiphon on Dec 20/21 instead, see below) -- and it +# means a saint's own real Magnificat antiphon, once authored, is +# permanently unreachable at Vespers on whichever of these seven dates +# that saint happens to occupy, under *this* calendar's own construction. +# Kept on file anyway (worth having for a future differently-constructed, +# e.g. user-editable, calendar) rather than treated as pointless to +# author. +# +# 12-17/18/19/22/23 live-verified against Divinum Officium (Monastic +# Tridentinum 1617), Vespers live query, 2027-12-17/18/19/22/23 -- +# genuinely clean dates in this app's own calendar (no sanctoral entry on +# any of the five, confirmed against data/calendar/sanctoral-calendar.yml). +# +# 12-20 ("O Clavis David") and 12-21 ("O Oriens") could NOT be +# live-verified the same way: every version/track on the live engine +# routes those two dates through St. Thomas's Vigil/feast (Dec 20/21) +# instead of the plain ferial Proper of the Time, even in the plain +# Tridentine 1570 track -- apparently a real modeling choice of that +# engine (a Vigil of an Apostle actually displacing the ferial Magnificat +# antiphon there), not an app-specific quirk. This app's own +# vigil-of-st-thomas.yml already documents a deliberate divergence from +# that engine on this exact date (the Vigil transfers away entirely here, +# per the ordinary privileged-feria/vigil rule, rather than claiming +# Second Vespers the way the live engine's Mass-only-commemoration +# modeling implies) -- so the plain "O Clavis David"/"O Oriens" text +# below, drawn from the standard, universally-fixed wording of these two +# antiphons (unchanged across every edition of the Roman/Monastic +# Breviary), is marked `draft` rather than `verified`: real, correctly +# sourced liturgical text, just not confirmed against this app's own +# primary live reference for lack of a clean query path. + +"12-17": + antiphon: + la: "O Sapiéntia, * quæ ex ore Altíssimi prodiísti, attíngens a fine usque ad finem, fórtiter suavitérque dispónens ómnia: veni ad docéndum nos viam prudéntiæ." + en: "O Wisdom, * that comest out of the mouth of the Most High, that reachest from one end to another, and dost mightily and sweetly order all things; come, to teach us the way of prudence!" + +"12-18": + antiphon: + la: "O Adonái, * et Dux domus Israël, qui Móysi in igne flammæ rubi apparuísti, et ei in Sina legem dedísti: veni ad rediméndum nos in brácchio exténto." + en: "O Adonai, * and Ruler of the house of Israel, who didst appear unto Moses in the burning bush, and gavest him the law in Sinai; come, to redeem us with an outstretched arm!" + +"12-19": + antiphon: + la: "O radix Jesse * qui stas in signum populórum, super quem continébunt reges os suum, quem gentes deprecabúntur: veni ad liberándum nos, jam noli tardáre." + en: "O Root of Jesse, * which standest for an ensign of the people, at whom the kings shall shut their mouths, to whom the Gentiles shall seek; come to deliver us, make no tarrying!" + +"12-20": + antiphon: + la: "O clavis David, * et sceptrum domus Israël; qui áperis, et nemo claudit; claudis, et nemo áperit: veni, et educ vinctum de domo cárceris, sedéntem in ténebris, et umbra mortis." + en: "O Key of David, * and Sceptre of the house of Israel, who openest and no man shutteth, who shuttest and no man openeth; come, and bring forth the prisoner from the prison-house, and him that sitteth in darkness and the shadow of death." + status: { la: draft, en: draft } + +"12-21": + antiphon: + la: "O Oriens, * splendor lucis ætérnæ, et sol justítiæ: veni, et illúmina sedéntes in ténebris, et umbra mortis." + en: "O Dayspring, * Brightness of light everlasting, and Sun of righteousness; come, and enlighten them that sit in darkness and the shadow of death." + status: { la: draft, en: draft } + +"12-22": + antiphon: + la: "O Rex géntium, * et desiderátus eárum, lapísque anguláris, qui facis útraque unum: veni, et salva hóminem, quem de limo formásti." + en: "O King of the Gentiles, * yea, and Desire thereof, O Cornerstone that makest of twain one; come to save man, whom thou hast made of the dust of the earth!" + +"12-23": + antiphon: + la: "O Emmánuel, * Rex et légifer noster, exspectátio géntium, et Salvátor eárum: veni ad salvándum nos, Dómine, Deus noster." + en: "O Emmanuel, our King * and our Law-giver, Longing of the Gentiles, yea, and Salvation thereof; come to save us, O Lord our God!" diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 690a924..038e294 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -9,6 +9,7 @@ import { isInTriduum } from '../calendar/temporal'; import { splitAntiphon, isDoubleOrHigher } from './antiphon'; import { getLaudsSaintOverride } from './lauds-psalmody-overrides'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; +import vespersOAntiphonsData from '../data/hours/vespers-o-antiphons.yml'; type BilingualText = Partial>; type TranslationStatus = 'verified' | 'draft' | 'missing'; @@ -21,6 +22,10 @@ interface MagnificatWeekdayDefault { const vespersMagnificatAntiphons = vespersMagnificatAntiphonsData as Partial< Record >; +// Keyed by "MM-DD" (Dec 17-23) — see the data file's own header for why +// these seven can't live in a per-Advent-week temporal-proper file the +// way every other week's Magnificat antiphon does. +const vespersOAntiphons = vespersOAntiphonsData as Partial>; function toResolvedText(proper: ProperText): ResolvedText { return { text: proper.text, status: proper.status, citation: proper.citation }; @@ -817,15 +822,43 @@ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText { * order exactly (temporal id, then a saint's own `${propers}-magnificat- * antiphon`, then their Common category's `magnificat-antiphon-${common}` * — none of that content authored yet, a separate future pass the same - * shape as the Benedictus one was), but with one real difference: a bare + * shape as the Benedictus one was), but with two real differences: a bare * ferial weekday (Mon-Sat) has actual content to fall back to — * data/hours/vespers-magnificat-antiphons.yml's classic "quote the * Magnificat's own text in sequence" set — which Benedictus has no - * equivalent of. `withPaschaltideAlleluia` applied to that fallback too, - * by analogy with the Common-category treatment above; not independently + * equivalent of; and the O Antiphons below, checked before either the + * temporal or sanctoral branch even runs. + * + * `withPaschaltideAlleluia` applied to the weekday fallback too, by + * analogy with the Common-category treatment above; not independently * confirmed for this specific case. + * + * The O Antiphons (Dec 17-23) are checked first, unconditionally — + * ahead of *both* the per-week `${winner.id}-magnificat-antiphon` file + * and a real sanctoral winner's own antiphon, deliberately: this is the + * app's own design choice (2026-08-30 user instruction), not a + * reconstruction of any single historical rite's rubric — the reference + * engine itself doesn't do this uniformly (its own Dec 20/21 live query + * shows St. Thomas's own antiphon instead, see vespers-o-antiphons.yml's + * header), but this app deliberately gives the O Antiphon absolute + * priority for this one specific text slot on these seven dates, + * regardless of who otherwise wins the day. Every other part of the + * office (chapter/responsory/hymn/versicle/collect, Lauds' own Benedictus + * antiphon) is untouched by this — a saint who wins Dec 17-23 (St. + * Thomas, Dec 21) still gets everything else that comes with winning, + * just not this one slot. This also means a saint's own real Magnificat + * antiphon, once authored, is permanently unreachable on one of these + * seven dates under *this* calendar's own construction — deliberately + * accepted, since a saint's real antiphon is still worth having on file + * for a differently-constructed calendar (e.g. a future user-editable + * one) even where the shipped calendar's own precedence never surfaces + * it here. */ export function getMagnificatAntiphon(day: LiturgicalDay): ResolvedText { + const oAntiphon = vespersOAntiphons[day.date.slice(5)]; + if (oAntiphon) { + return oAntiphon.status ? { text: oAntiphon.antiphon, status: oAntiphon.status } : verifiedText(oAntiphon.antiphon); + } const winner = resolveOfficeWinner(day); if (winner.kind === 'temporal') { const named = toResolvedText(getTemporalProper(`${winner.id}-magnificat-antiphon`)); diff --git a/tests/hours/resolve-common.test.ts b/tests/hours/resolve-common.test.ts index 35573b1..77d8947 100644 --- a/tests/hours/resolve-common.test.ts +++ b/tests/hours/resolve-common.test.ts @@ -3,6 +3,7 @@ import { resolveDay } from '../../src/calendar'; import { getDayCollect, getBenedictusAntiphon, + getMagnificatAntiphon, resolveMinorHourAntiphon, resolveMinorHourChapter, splitNamedAntiphon, @@ -305,3 +306,42 @@ describe('Paschaltide alleluia suffix on Common-category antiphons (2026-08)', ( expect(benedictus.text.la?.match(/allelúja/g)?.length).toBe(2); }); }); + +describe('getMagnificatAntiphon — classic weekday-default fallback', () => { + it('falls back to the classic weekday-quote antiphon for a plain temporal id with no proper Magnificat antiphon of its own', () => { + // Synthetic day, not a real resolveOrdo/resolveDay date: after the + // full temporal-cycle Magnificat-antiphon sweep (2026-08-30, see + // TODO.md), every real temporal id this app's calendar can actually + // land Vespers on has its own proper antiphon authored (or is covered + // date-by-date by the O Antiphons, Dec 17-23 -- see + // vespers-o-antiphons.yml), so there's no live date left to exercise + // this fallback tier through the real pipeline any more -- see + // tests/hours/vespers.test.ts's own note where this test used to + // live. Tests the mechanism directly instead, the same way this + // file's other synthetic-day cases do. + const day: LiturgicalDay = { + date: '2026-01-01', + weekday: 'tuesday', + season: 'trinitytide', + temporalCategory: 'ordinary-feria', + winner: { kind: 'temporal', id: 'not-a-real-temporal-id' }, + commemorations: [], + }; + const magnificat = getMagnificatAntiphon(day); + expect(magnificat.text.la).toContain('Exsúltet'); + expect(magnificat.status.la).toBe('verified'); + }); + + it('has no weekday default for Sunday (its own antiphon varies by Proper of the Time)', () => { + const day: LiturgicalDay = { + date: '2026-01-04', + weekday: 'sunday', + season: 'trinitytide', + temporalCategory: 'ordinary-sunday', + winner: { kind: 'temporal', id: 'not-a-real-temporal-id' }, + commemorations: [], + }; + const magnificat = getMagnificatAntiphon(day); + expect(magnificat.status.la).toBe('missing'); + }); +}); diff --git a/tests/hours/vespers.test.ts b/tests/hours/vespers.test.ts index 00bf1b0..e61d0da 100644 --- a/tests/hours/vespers.test.ts +++ b/tests/hours/vespers.test.ts @@ -248,20 +248,18 @@ describe('resolveOrdo("vespers", ...)', () => { expect(versicle).toBeDefined(); }); - it('falls back to the classic weekday-default Magnificat antiphon on a bare ferial', () => { - // 2033-09-06 is a Tuesday, plain ordinary trinitytide, no saint, no - // active octave (same date already used by calendar/vespers.test.ts's - // "does not anticipate between two plain ferias" -- see that test's - // own comment for why this moved off 2026-06-16). - const ordo = resolveOrdo('vespers', '2033-09-06'); - const magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat'); - expect(magnificatIndex).toBeGreaterThanOrEqual(0); - const magnificat = ordo.parts[magnificatIndex]; - expect(magnificat?.kind === 'canticle' ? magnificat.text.text.la : undefined).toContain('Magníficat ✠ ánima mea'); - // The closing full-antiphon repeat is the very next part. - const closingAntiphon = ordo.parts[magnificatIndex + 1]; - expect(closingAntiphon?.kind === 'antiphon' ? closingAntiphon.text.text.la : undefined).toContain('Exsúltet'); - }); + // The classic weekday-default Magnificat antiphon (getMagnificatAntiphon's + // last fallback tier, vespers-magnificat-antiphons.yml) now has no live, + // reachable date left in this app's own calendar to exercise it through + // resolveOrdo: every temporal id has its own proper antiphon authored + // (the 2026-08-30 full sweep) except advent-4 (now covered date-by-date + // by the O Antiphons for its entire practical range, see the "O + // Antiphons" describe block below) and marian-saturday (whose own + // Vespers is itself unreachable -- Sunday always claims First Vespers + // over the preceding Saturday evening regardless of Sunday's own rank, + // see calendar/vespers.ts's hasFirstVespers). See + // tests/hours/resolve-common.test.ts's own synthetic-day unit test for + // this fallback tier instead. it('folds the cross-day Vespers commemoration into day-collects (St. James keeps Vespers, St. Anne commemorated)', () => { // St. Christopher (same-day collision, commemorated under James @@ -664,6 +662,43 @@ describe('resolveOrdo("vespers", ...)', () => { }); }); +describe('resolveOrdo("vespers", ...) O Antiphons (Dec 17-23)', () => { + it('uses the date-keyed O Antiphon instead of the enclosing Advent week\'s own Magnificat antiphon', () => { + // Regression test for the 2026-08-30 Magnificat-antiphon gap: 2026-12-19 + // (a Saturday, genuinely clean -- no saint on this date, see + // data/calendar/sanctoral-calendar.yml) falls within whichever Advent + // week governs that year (advent-3 or advent-4), but the O Antiphon + // "O radix Jesse" must win regardless of which week id that is -- + // see vespers-o-antiphons.yml's own header for why these seven can't + // live in a per-week temporal-proper file. + const ordo = resolveOrdo('vespers', '2026-12-19'); + const magnificat = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'magnificat'); + expect(magnificat?.kind === 'canticle' ? magnificat.antiphon?.text.la : undefined).toContain('radix Jesse'); + const magnificatIndex = ordo.parts.indexOf(magnificat!); + const closingAntiphon = ordo.parts[magnificatIndex + 1]; + expect(closingAntiphon?.kind === 'antiphon' ? closingAntiphon.text.text.en : undefined).toContain('Root of Jesse'); + }); + + it("gives the O Antiphon absolute priority for the Magnificat slot even over a real sanctoral winner (St. Thomas, Dec 21, Duplex II. classis), while leaving the rest of his own office untouched", () => { + // Direct user instruction (2026-08-30): the O Antiphon always wins + // the Magnificat slot on Dec 17-23, full stop, regardless of who + // otherwise wins the day -- St. Thomas still wins everything else + // about Dec 21 (his own chapter/responsory/hymn/versicle, confirmed + // below), just not this one text. See vespers-o-antiphons.yml's own + // header for the reasoning (a deliberate app-level design choice, not + // a reconstruction of the reference engine's own Dec 21 behavior). + const ordo = resolveOrdo('vespers', '2026-12-21'); + const magnificat = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'magnificat'); + expect(magnificat?.kind === 'canticle' ? magnificat.antiphon?.text.la : undefined).toContain('Oriens'); + expect(magnificat?.kind === 'canticle' ? magnificat.antiphon?.text.en : undefined).toContain('Dayspring'); + // The rest of his own office is untouched by this. + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter?.kind === 'chapter' ? chapter.text.status.la : undefined).not.toBe('missing'); + const hymn = ordo.parts.find((p) => p.kind === 'hymn'); + expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toBeTruthy(); + }); +}); + describe('resolveOrdo("vespers", ...) responsory Gloria omission', () => { // Same shared mechanism/rule as Lauds' — see tests/hours/lauds.test.ts's // and tests/hours/compline.test.ts's own describe blocks.