diff --git a/src/data/propers/common/matins-hymn-common-of-an-apostle.yml b/src/data/propers/common/matins-hymn-common-of-an-apostle.yml new file mode 100644 index 0000000..63e0048 --- /dev/null +++ b/src/data/propers/common/matins-hymn-common-of-an-apostle.yml @@ -0,0 +1,101 @@ +# The Common-of-an-Apostle's own Matins hymn ("Ætérna Christi múnera" / +# "Th' eternal gifts of Christ the King"), read from the reference engine's +# own Commune/C1.txt [Hymnus Matutinum] block (both Latin and English +# present and complete there). Any winning apostle with no proper Matins +# hymn of his own authored falls to this Common tier (hours/matins.ts's +# resolveMatinsHymn) — e.g. St. Bartholomew (2026-08-24, live-verified): +# Monastic 1617 gives him no [Hymnus Matutinum] override of his own, so +# this Common's own text genuinely renders. +id: matins-hymn-common-of-an-apostle +text: + la: >- + Ætérna Christi múnera, + + Apostolórum glóriam, + + Palmas et hymnos débitos + + Lætis canámus méntibus. + + + Ecclesiárum Príncipes, + + Belli triumpháles duces, + + Cæléstis aulæ mílites, + + Et vera mundi lúmina. + + + Devóta Sanctórum fides, + + Invícta spes credéntium, + + Perfécta Christi cáritas + + Mundi tyránnum cónterit. + + + In his Patérna glória, + + In his triúmphat Fílius, + + In his volúntas Spíritus, + + Cælum replétur gáudio. + + + Patri, simúlque Fílio, + + Tibíque Sancte Spíritus, + + Sicut fuit, sit júgiter + + Sæclum per omne glória. Amen. + en: >- + Th' eternal gifts of Christ the King, + + The Apostles' glory, let us sing; + + And all with hearts of gladness raise + + Due hymns of thankful love and praise. + + + For they the Church's princes are, + + Triumphant leaders in the war, + + The heavenly King's own warrior band, + + True lights to lighten every land. + + + Theirs was the steadfast faith of saints, + + The hope that never yields nor faints, + + The love of Christ in perfect glow, + + That lay the prince of this world low. + + + In them the Father's glory shone, + + In them the Spirit's will was done, + + The Son himself exults in them; + + Joy fills the new Jerusalem. + + + Praise to the Father, with the Son, + + And Holy Spirit, Three in One; + + As ever was in ages past, + + And so shall be while ages last. Amen. +status: + la: verified + en: verified diff --git a/src/data/propers/common/matins-invitatory-common-of-an-apostle.yml b/src/data/propers/common/matins-invitatory-common-of-an-apostle.yml new file mode 100644 index 0000000..e5543a5 --- /dev/null +++ b/src/data/propers/common/matins-invitatory-common-of-an-apostle.yml @@ -0,0 +1,13 @@ +# The Common-of-an-Apostle's own invitatory antiphon ("Regem Apostolórum +# Dóminum" / "The Lord, the King of Apostles"), read from the reference +# engine's own Commune/C1.txt [Invit] line (both languages). Any winning +# apostle with no proper invitatory antiphon of his own authored falls to +# this Common tier (hours/matins.ts's resolveMatinsInvitatoryText) — e.g. +# St. Bartholomew (2026-08-24, live-verified). +id: matins-invitatory-common-of-an-apostle +text: + la: "Regem Apostolórum Dóminum, * Veníte, adorémus." + en: "The Lord, the King of Apostles, * O come, let us adore." +status: + la: verified + en: verified diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 4c3f114..29de2ba 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -80,6 +80,7 @@ import { splitNamedAntiphon, openingAntiphon, } from './resolve-common'; +import { getSaintRecord } from '../calendar/feasts'; import { getBiblePlanReadings } from '../propers/bible-plan'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; import { getOctaveReading } from '../propers/octave-readings'; @@ -168,14 +169,56 @@ function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] { })); } +/** The invitatory antiphon's own text — same override > Common-category > + * seasonal > ferial precedence as `resolveMatinsHymn`, minus the octave + * tier: no per-octave invitatory-antiphon override exists anywhere in the + * reference source (unlike the hymn, which genuinely persists all week on + * some octaves) — an octave day already falls through correctly to its + * governing saint's own Common/season tier here, so adding an octave tier + * would have nothing to fire on. Keeps the existing ferial fallback id + * (`matins-invitatory-antiphon`) as-is rather than renaming it to match + * the hymn's `-ferial` convention — it's already `verified` and its own + * header already documents itself as the fallback-of-record; renaming + * would be pure churn. + * + * Doubling (`isDoubleOrHigher`, via `openingAntiphon`) is a separate, final + * step applied to whichever tier's text wins here — never part of content + * selection itself. */ +function resolveMatinsInvitatoryText(day: LiturgicalDay): ResolvedText { + const overrideId = getOfficeOverrideId(day); + if (overrideId) { + const proper = resolveCommon(`matins-invitatory-${overrideId}`); + if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { + return proper; + } + } + const winner = resolveOfficeWinner(day); + const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined; + if (commonId) { + const common = resolveCommon(`matins-invitatory-${commonId}`); + if (common.status.la !== 'missing' || common.status.en !== 'missing') { + return common; + } + } + const seasonSuffix = seasonalOfficeSuffix(day.season); + if (seasonSuffix) { + const seasonal = resolveCommon(`matins-invitatory-${seasonSuffix}`); + if (seasonal.status.la !== 'missing' || seasonal.status.en !== 'missing') { + return seasonal; + } + } + return resolveCommon('matins-invitatory-antiphon'); +} + /** The Invitatory (Ps 94) — real practice interleaves its antiphon as a * repeating refrain between verse groups; per direct instruction this app * frames it like any other psalm antiphon instead (one opening, one full * repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's * own header. */ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] { - const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon')); - const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; + const text = resolveMatinsInvitatoryText(day); + const opening = openingAntiphon(text, resolveOfficeWinner(day)); + const { full } = splitNamedAntiphon(text); return [ psalmPartWithAntiphon(94, opening), { kind: 'antiphon', text: full }, @@ -229,11 +272,16 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv * getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses), * when authored; else, on a day within an active octave whose own feast * has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) — - * see below; else falls to a *seasonal* default (Advent/Lent/ - * Passiontide/Paschaltide, none authored yet), else the plain year-round - * ferial hymn — same override > octave > season > ferial precedence every - * other hour's own office bundle uses (see lauds.ts's resolveOffice), just - * never fully wired up here before. + * see below; else the winner's Common-of-Saints hymn (`matins-hymn- + * ${commonId}`, via `SaintRecord.common`, e.g. "Aeterna Christi munera" + * for any Apostle with no proper hymn of his own — same category-lookup + * pattern as matins-psalmody-overrides.ts's getMatinsPsalmodyOverride); + * else falls to a *seasonal* default (Advent/Lent/Passiontide/ + * Paschaltide, none authored yet), else the plain year-round ferial hymn — + * override > octave > Common > season > ferial. Common slots between + * octave and season: an octave's own proper hymn is more specific than any + * Common and must keep winning; a Common-of-Saints hymn is more specific + * than a bare season and must win over it. * * Concrete motivating case (user, 2026-08-21/22): the Assumption's octave * (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps @@ -264,6 +312,23 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText { return octaveHymn; } } + // Common-of-Saints tier (2026-08): a winning saint with no proper hymn of + // his own (most of them) still often shares a real, generic hymn with + // every other saint of his Common (e.g. "Aeterna Christi munera" for any + // Apostle) — same getSaintRecord(id)?.common lookup already proven by + // matins-psalmody-overrides.ts's getMatinsPsalmodyOverride. Keyed off the + // actual winner, not `overrideId` above: `getOfficeOverrideId` only + // returns an id for duplex-majus+ winners (an eligibility gate for the + // per-saint-proper tier), so reusing it here would silently skip this + // tier for any lower-ranked sanctoral winner. + const winner = resolveOfficeWinner(day); + const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined; + if (commonId) { + const commonHymn = resolveCommon(`matins-hymn-${commonId}`); + if (commonHymn.status.la !== 'missing' || commonHymn.status.en !== 'missing') { + return commonHymn; + } + } const seasonSuffix = seasonalOfficeSuffix(day.season); if (seasonSuffix) { const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 4f6cc41..5416658 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -525,3 +525,76 @@ describe('resolveOrdo("matins", ...) nocturn-reading temporal/month-week suppres const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record } }[]; expect(lessons.length).toBeGreaterThan(0); expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true); + }); +}); + +// Coverage gap fix (no bug found): the pre-existing ferial-branch test +// above only asserted the opening invitatory antiphon *contains* "Veníte," +// true for both the incipit ("Ant. Veníte.") and full ("Ant. Veníte, * +// Exsultémus Dómino.") forms -- it never actually distinguished doubled vs. +// undoubled. isDoubleOrHigher/openingAntiphon traced out correct in source +// for every rank tested here; these tests just make the distinction real. +describe('resolveOrdo("matins", ...) invitatory antiphon doubling by rank', () => { + function openingAntiphonLatin(date: string): string | undefined { + const ordo = resolveOrdo('matins', date); + const invitatoryPsalm = ordo.parts.find((p) => p.kind === 'psalm' && (p as { psalmNumber: number }).psalmNumber === 94) as + | { antiphon?: { text: Record } } + | undefined; + return invitatoryPsalm?.antiphon?.text.la; + } + + it('a Duplex+ weekday winner opens with the FULL (not incipit-only) invitatory antiphon (St. Lawrence, 2026-08-17: ferial text, no proper/Common of his own; St. Bartholomew, duplex-2-classis, 2026-08-24: Common-of-an-Apostle text)', () => { + expect(openingAntiphonLatin('2026-08-17')).toContain('Exsultémus'); + expect(openingAntiphonLatin('2026-08-24')).toContain('Regem Apostolórum Dóminum, * Veníte'); + }); + + it('a plain ferial day opens with the incipit only', () => { + expect(openingAntiphonLatin(FERIAL_DATE)).not.toContain('Exsultémus'); + }); + + it("a Simplex saint merely commemorated (not winning) under an Advent feria still opens with the incipit only — the winner's own rank governs, not any commemorated saint's (St. Bibiana, 2025-12-02)", () => { + expect(openingAntiphonLatin('2025-12-02')).not.toContain('Exsultémus'); + }); +}); + +// New mechanism (2026-08): resolveMatinsHymn gained a Common-of-Saints +// tier (override -> octave -> Common category -> season -> ferial), and +// the invitatory antiphon -- previously one hardcoded fixed text for every +// day of the year -- now goes through the same shape of tiering +// (resolveMatinsInvitatoryText: override -> Common category -> season -> +// ferial). Both confirmed against the reference engine's own Commune/ +// C1.txt (Common of an Apostle): hymn "Ætérna Christi múnera," invitatory +// "Regem Apostolórum Dóminum." St. Bartholomew has no proper Matins hymn +// or invitatory of his own authored, so this Common tier is what actually +// renders for him. +describe('resolveOrdo("matins", ...) Common-of-an-Apostle hymn + invitatory (2026-08-24 fix)', () => { + function hymnLatin(date: string): string | undefined { + const ordo = resolveOrdo('matins', date); + const hymn = ordo.parts.find((p) => p.kind === 'hymn') as { text: { text: { la: string } } } | undefined; + return hymn?.text.text.la; + } + function invitatoryFullLatin(date: string): string | undefined { + const ordo = resolveOrdo('matins', date); + const antiphonParts = ordo.parts.filter((p) => p.kind === 'antiphon') as { text: { text: Record } }[]; + return antiphonParts[0]?.text.text.la; + } + + it("St. Bartholomew's own day (2026-08-24, no proper Matins hymn of his own) picks up the Common-of-an-Apostle hymn", () => { + expect(hymnLatin('2026-08-24')).toContain('Ætérna Christi múnera'); + }); + + it("St. Bartholomew's invitatory antiphon resolves to the Common-of-an-Apostle text", () => { + expect(invitatoryFullLatin('2026-08-24')).toContain('Regem Apostolórum Dóminum'); + }); + + it('St. Andrew (2026-11-30, also common-of-an-apostle, no proper hymn/invitatory of his own) resolves to the same Common text -- confirms the tier generalizes beyond Bartholomew', () => { + expect(hymnLatin('2026-11-30')).toContain('Ætérna Christi múnera'); + expect(invitatoryFullLatin('2026-11-30')).toContain('Regem Apostolórum Dóminum'); + }); + + it('a plain ferial day is unaffected -- still falls through to the plain ferial hymn and invitatory antiphon', () => { + expect(hymnLatin(FERIAL_DATE)).toContain('Somno'); + expect(invitatoryFullLatin(FERIAL_DATE)).toContain('Veníte'); + expect(invitatoryFullLatin(FERIAL_DATE)).not.toContain('Regem Apostolórum'); + }); +});