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:
2026-08-24 07:14:06 -04:00
parent 8f55ec6931
commit 449472d5aa
4 changed files with 259 additions and 7 deletions
+73
View File
@@ -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> } }[];
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<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');
});
});