Add Matins hymn Common-of-Saints tier and a real invitatory-antiphon mechanism
resolveMatinsHymn gained a Common-category tier (override -> octave -> Common category -> season -> ferial), reusing matins-psalmody- overrides.ts's own getSaintRecord(id)?.common lookup pattern. The invitatory antiphon (invitatoryParts), previously one hardcoded fixed text for every day of the year with only full-vs-incipit doubling varying by rank, now goes through a real resolveMatinsInvitatoryText (override -> Common category -> season -> ferial, no octave tier) before the doubling step. Content authored this pass: Common-of-an-Apostle only, both hymn (matins-hymn-common-of-an-apostle.yml, "Aeterna Christi munera") and invitatory antiphon (matins-invitatory-common-of-an-apostle.yml, "Regem Apostolorum Dominum"), both read directly from the reference engine's Commune/C1.txt. Live-verified: St. Bartholomew (2026-08-24) and St. Andrew (2026-11-30) both now resolve to this Common's text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
+72
-7
@@ -80,6 +80,7 @@ import {
|
|||||||
splitNamedAntiphon,
|
splitNamedAntiphon,
|
||||||
openingAntiphon,
|
openingAntiphon,
|
||||||
} from './resolve-common';
|
} from './resolve-common';
|
||||||
|
import { getSaintRecord } from '../calendar/feasts';
|
||||||
import { getBiblePlanReadings } from '../propers/bible-plan';
|
import { getBiblePlanReadings } from '../propers/bible-plan';
|
||||||
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
|
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
|
||||||
import { getOctaveReading } from '../propers/octave-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
|
/** The Invitatory (Ps 94) — real practice interleaves its antiphon as a
|
||||||
* repeating refrain between verse groups; per direct instruction this app
|
* repeating refrain between verse groups; per direct instruction this app
|
||||||
* frames it like any other psalm antiphon instead (one opening, one full
|
* frames it like any other psalm antiphon instead (one opening, one full
|
||||||
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
|
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
|
||||||
* own header. */
|
* own header. */
|
||||||
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
|
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
|
||||||
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
|
const text = resolveMatinsInvitatoryText(day);
|
||||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
const opening = openingAntiphon(text, resolveOfficeWinner(day));
|
||||||
|
const { full } = splitNamedAntiphon(text);
|
||||||
return [
|
return [
|
||||||
psalmPartWithAntiphon(94, opening),
|
psalmPartWithAntiphon(94, opening),
|
||||||
{ kind: 'antiphon', text: full },
|
{ kind: 'antiphon', text: full },
|
||||||
@@ -229,11 +272,16 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
|
|||||||
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
|
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
|
||||||
* when authored; else, on a day within an active octave whose own feast
|
* 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}`) —
|
* has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) —
|
||||||
* see below; else falls to a *seasonal* default (Advent/Lent/
|
* see below; else the winner's Common-of-Saints hymn (`matins-hymn-
|
||||||
* Passiontide/Paschaltide, none authored yet), else the plain year-round
|
* ${commonId}`, via `SaintRecord.common`, e.g. "Aeterna Christi munera"
|
||||||
* ferial hymn — same override > octave > season > ferial precedence every
|
* for any Apostle with no proper hymn of his own — same category-lookup
|
||||||
* other hour's own office bundle uses (see lauds.ts's resolveOffice), just
|
* pattern as matins-psalmody-overrides.ts's getMatinsPsalmodyOverride);
|
||||||
* never fully wired up here before.
|
* 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
|
* 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
|
* (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps
|
||||||
@@ -264,6 +312,23 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
|
|||||||
return octaveHymn;
|
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);
|
const seasonSuffix = seasonalOfficeSuffix(day.season);
|
||||||
if (seasonSuffix) {
|
if (seasonSuffix) {
|
||||||
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);
|
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);
|
||||||
|
|||||||
@@ -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<string, string> } }[];
|
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record<string, string> } }[];
|
||||||
expect(lessons.length).toBeGreaterThan(0);
|
expect(lessons.length).toBeGreaterThan(0);
|
||||||
expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true);
|
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<string, string> } }
|
||||||
|
| 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<string, string> } }[];
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user