Compare commits
5 Commits
42dd4dc04b
...
8bcecd108b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bcecd108b | |||
| 72033221c3 | |||
| 449472d5aa | |||
| 8f55ec6931 | |||
| f8de318b7f |
@@ -2621,6 +2621,82 @@ saint-wins-Ember-commemorated cases) and content resolution (Advent, wins-outrig
|
||||
verified Matins content on all 6 days) end-to-end. `npm test` (435 tests) and `tsc --noEmit`
|
||||
both pass.
|
||||
|
||||
### Matins nocturn-reading temporal/month-week suppression bug, hymn Common-tier + real invitatory mechanism, antiphon-doubling dedup (2026-08-24)
|
||||
|
||||
**Bug fixed**: `nocturnReadingIds` (`hours/matins.ts`) unconditionally pooled the plain
|
||||
temporal-cycle reading id and the calendar-month/week id into every day's nocturn reading
|
||||
pool, even on a day where a real feast wins outright with zero commemorations
|
||||
(`calendar/commemorations.ts`'s `decideOccurrence`, `ordinary-feria` branch — correctly
|
||||
suppressing the temporal identity entirely). St. Bartholomew (duplex-2-classis, 2026-08-24, a
|
||||
Monday) has no Nocturn 3 content of his own, so the leftover 13th-Sunday-after-Pentecost
|
||||
(`post-pentecost-13.yml`) and month-week (`month-week-084.yml`) readings wrongly filled his
|
||||
Nocturn 3. Fixed by gating that pooling on whether the day's own occurrence decision actually
|
||||
retained the temporal identity (`day.winner.kind === 'temporal'`, or a `kind: 'temporal'` entry
|
||||
in `day.commemorations`) — mirrors `getDayCollects`'s own long-standing pattern for the same
|
||||
question. New tests in `tests/hours/matins.test.ts` cover both the regression (Bartholomew) and
|
||||
the non-regression case (a plain ferial weekday with no sanctoral winner, 2026-09-04).
|
||||
|
||||
**Matins hymn + invitatory antiphon — new Common-of-Saints tier, real invitatory mechanism**:
|
||||
also fixed while investigating the same report. `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 —
|
||||
no per-octave invitatory override exists anywhere in the reference source) before the
|
||||
pre-existing doubling step is applied. Content authored this pass: Common-of-an-Apostle only,
|
||||
both hymn (`matins-hymn-common-of-an-apostle.yml`, "Ætérna Christi múnera") and invitatory
|
||||
antiphon (`matins-invitatory-common-of-an-apostle.yml`, "Regem Apostolórum Dóminum"), both read
|
||||
directly from the reference engine's `Commune/C1.txt` (Latin + English both present there).
|
||||
Live-verified: St. Bartholomew (2026-08-24) and St. Andrew (2026-11-30) both now resolve to this
|
||||
Common's text for hymn + invitatory.
|
||||
|
||||
**Not authored, logged as explicit backlog**, same incremental-content pattern as everywhere
|
||||
else in this project:
|
||||
- Hymn + invitatory Common tiers for: Martyrs (C2), Confessors (C4), Virgins (C6), Holy Women
|
||||
(C7), Dedication (C8), General/All Saints (C9) — all confirmed to have their own distinct
|
||||
`[Invit]`/`[Hymnus Matutinum]` text in the reference source, not yet transcribed.
|
||||
- Seasonal invitatory-antiphon tier content (Advent/Lent/Passiontide/Paschaltide/Christmastide)
|
||||
— mechanism now exists (same `seasonalOfficeSuffix`-keyed lookup the hymn's own long-standing
|
||||
seasonal tier already uses), but no seasonal invitatory file is authored yet; every season
|
||||
currently still falls through to the plain ferial invitatory antiphon. Confirmed varying
|
||||
content exists in the reference source for at least Paschaltide (`Tempora/Pasc0-0.txt`,
|
||||
"Surréxit Dóminus vere") and Christmastide (`Tempora/Nat2-0.txt`, "Admirábile nomen Jesu").
|
||||
- Seasonal Matins-hymn content itself (Advent/Lent/Passiontide/Paschaltide) — this gap predates
|
||||
this pass (already noted in `resolveMatinsHymn`'s own doc comment) and is unchanged here.
|
||||
|
||||
**Cross-hour antiphon-doubling dedup**: the same idiom (`splitNamedAntiphon` +
|
||||
`isDoubleOrHigher(...) ? full : incipit`) was copy-pasted at 11 call sites across 8 hour files
|
||||
(matins.ts x4, lauds.ts x2, vespers.ts x2, compline.ts x1, prime/terce/sext/none.ts x1 each).
|
||||
Centralized into one new `openingAntiphon(antiphon, winner)` helper in `hours/resolve-common.ts`
|
||||
(alongside `splitNamedAntiphon`, which it wraps); every call site now uses it. Pure dedup, no
|
||||
behavior change — confirmed by the full existing test suite passing unchanged.
|
||||
|
||||
**Cross-hour hymn-resolution dedup — partial**: investigated whether hymn resolution across all
|
||||
8 hours could collapse into one shared resolver the same way antiphon-doubling did. Found it
|
||||
does *not* fit a single universal helper — hymn resolution falls into 4 genuinely different tier
|
||||
shapes (Matins's standalone 4-tier hymn-only resolver; Lauds/Vespers's bundled
|
||||
hymn+chapter+responsory+versicle resolver; Prime/Compline's fixed-id-plus-`appendDoxology`;
|
||||
Terce/Sext/None's flat one-liner with the doxology baked into the static hymn text). Of these,
|
||||
only Terce/Sext/None's case was genuinely byte-identical and safe to collapse — done, via a new
|
||||
`resolveSelfContainedHymn` helper in `resolve-common.ts`. **Lauds and Vespers's own bundle
|
||||
resolvers turned out NOT to be byte-identical either**, despite looking that way at a glance:
|
||||
Lauds gates its whole bundle on whether a `LaudsPsalmodyOverride` exists for the winning saint
|
||||
(`getPsalmodyOverrideFor(day)?.id`), while Vespers gates purely on duplex-majus+ rank via
|
||||
`getOfficeOverrideId` directly (it has no separate per-feast psalmody-override table to check
|
||||
against). Collapsing them into one parameterized helper risks silently changing eligibility
|
||||
behavior for one of the two hours, so this was deliberately left alone rather than risked — a
|
||||
real follow-on refactor, but one that needs an explicit "eligibility" plugin point designed
|
||||
first, not a same-day mechanical extraction. Also checked: Matins's own explicit
|
||||
`resolveActiveOctave` hymn tier looked at first like it might be redundant with
|
||||
`resolveOfficeWinner`'s own octave-folding (which Lauds/Vespers rely on instead of a separate
|
||||
octave check) — confirmed it is *not* redundant: `resolveOfficeWinner`'s fold-in only fires for
|
||||
`ordinary-feria` days or via `octaveGoverningPrivilegedDay` (which explicitly excludes
|
||||
Christmastide's own stacked octaves), so Matins's separate check still covers real cases (e.g.
|
||||
Christmas/Stephen/John/Innocents octave days) the other path doesn't reach. Left unchanged.
|
||||
|
||||
`npm test` (470 tests) and `tsc --noEmit` both pass.
|
||||
|
||||
## Known, deliberate simplifications (not bugs — working as designed)
|
||||
|
||||
- `getDayCollects`: each collect in a multi-collect day renders as its own
|
||||
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ import { getHymnDoxologyId } from './hymn-doxology';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common';
|
||||
import { resolveCommon, appendDoxology, splitNamedAntiphon, openingAntiphon, resolveResponsory } from './resolve-common';
|
||||
import complineDefinitionData from '../data/hours/compline.yml';
|
||||
|
||||
const complineDefinition = complineDefinitionData as HourDefinition;
|
||||
@@ -63,8 +63,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
},
|
||||
];
|
||||
case 'nunc-dimittis': {
|
||||
const { incipit, full } = splitNamedAntiphon(resolveCommon('nunc-dimittis-antiphon'));
|
||||
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
const antiphonText = resolveCommon('nunc-dimittis-antiphon');
|
||||
const opening = openingAntiphon(antiphonText, day.winner);
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
return [
|
||||
{ kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening },
|
||||
{ kind: 'antiphon', text: full },
|
||||
|
||||
+6
-6
@@ -14,6 +14,7 @@ import {
|
||||
getDayCollects,
|
||||
getBenedictusAntiphon,
|
||||
splitNamedAntiphon,
|
||||
openingAntiphon,
|
||||
resolveOfficeWinner,
|
||||
verifiedText,
|
||||
seasonalOfficeSuffix,
|
||||
@@ -134,10 +135,8 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
|
||||
* plain weekday default below gets substituted. */
|
||||
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
|
||||
const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday];
|
||||
const opening = (antiphon: BilingualText) => {
|
||||
const { incipit, full } = splitNamedAntiphon(verifiedText(antiphon));
|
||||
return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
};
|
||||
const winner = resolveOfficeWinner(day);
|
||||
const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner);
|
||||
const parts: ResolvedPart[] = [
|
||||
{
|
||||
kind: 'psalm',
|
||||
@@ -237,8 +236,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'lauds-office':
|
||||
return resolveOffice(day);
|
||||
case 'benedictus': {
|
||||
const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const antiphonText = getBenedictusAntiphon(day);
|
||||
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
return [
|
||||
{ kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening },
|
||||
{ kind: 'antiphon', text: full },
|
||||
|
||||
+101
-17
@@ -78,7 +78,9 @@ import {
|
||||
seasonalOfficeSuffix,
|
||||
verifiedText,
|
||||
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';
|
||||
@@ -167,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 },
|
||||
@@ -187,8 +231,9 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
|
||||
function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
|
||||
const parts: ResolvedPart[] = [];
|
||||
for (const g of group.groups ?? []) {
|
||||
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const antiphonText = verifiedText(g.antiphon);
|
||||
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
g.psalms.forEach((n, i) => {
|
||||
parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined));
|
||||
});
|
||||
@@ -201,8 +246,9 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP
|
||||
/** Nocturn 3's 3 fixed OT canticles under one shared antiphon — see
|
||||
* data/hours/matins-sunday-antiphons.yml's own header. */
|
||||
function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
|
||||
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {}));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const antiphonText = verifiedText(group.antiphon ?? {});
|
||||
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => {
|
||||
const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
|
||||
const text: BilingualText = {
|
||||
@@ -226,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
|
||||
@@ -261,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}`);
|
||||
@@ -285,11 +353,12 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
|
||||
* as one combined V./R. block via `versicleText`. */
|
||||
function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
|
||||
const nocturn = ferialAntiphons[day.weekday as Exclude<Weekday, 'sunday'>];
|
||||
const isDouble = isDoubleOrHigher(resolveOfficeWinner(day));
|
||||
const winner = resolveOfficeWinner(day);
|
||||
const parts: ResolvedPart[] = [];
|
||||
for (const group of nocturn.groups) {
|
||||
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
|
||||
const opening = isDouble ? full : incipit;
|
||||
const antiphonText = verifiedText(group.antiphon);
|
||||
const opening = openingAntiphon(antiphonText, winner);
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
group.psalms.forEach((ref, i) => {
|
||||
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status }));
|
||||
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
|
||||
@@ -353,9 +422,24 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string)
|
||||
// nocturn-readings content too, not just a commemorated sanctoral one.
|
||||
if (c.kind === 'sanctoral' || c.kind === 'temporal') ids.add(c.id);
|
||||
}
|
||||
ids.add(temporalId);
|
||||
const monthWeek = monthWeekId(date);
|
||||
if (monthWeek) ids.add(`month-week-${monthWeek}`);
|
||||
// The plain temporalId/month-week content is only pooled when the day's
|
||||
// own occurrence decision (calendar/commemorations.ts's decideOccurrence)
|
||||
// actually retained the temporal identity in some form: the temporal
|
||||
// cycle won outright (day.winner.kind === 'temporal' -- a plain ferial/
|
||||
// Sunday, or a named temporal override like Christ the King), or it
|
||||
// survives as a commemoration alongside a sanctoral winner. Excluded:
|
||||
// decideOccurrence's `ordinary-feria` branch, where a real feast --
|
||||
// however low-ranked -- wins with zero commemorations, correctly
|
||||
// suppressing the temporal identity entirely (e.g. St. Bartholomew,
|
||||
// duplex-2-classis, 2026-08-24 -- his own proper reading has no Nocturn
|
||||
// 3 content, and without this gate the leftover 13th-Sunday-after-
|
||||
// Pentecost/month-week content wrongly filled Nocturn 3 instead).
|
||||
const temporalKept = day.winner.kind === 'temporal' || day.commemorations.some((c) => c.kind === 'temporal');
|
||||
if (temporalKept) {
|
||||
ids.add(temporalId);
|
||||
const monthWeek = monthWeekId(date);
|
||||
if (monthWeek) ids.add(`month-week-${monthWeek}`);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -5,14 +5,15 @@ import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||
import { applyFlexaMark } from './antiphon';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollect,
|
||||
splitNamedAntiphon,
|
||||
openingAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
resolveMinorHourChapter,
|
||||
resolveSelfContainedHymn,
|
||||
} from './resolve-common';
|
||||
import noneDefinitionData from '../data/hours/none.yml';
|
||||
import antiphonsData from '../data/hours/none-antiphons.yml';
|
||||
@@ -25,8 +26,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'opening-versicle':
|
||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
||||
case 'hymn':
|
||||
// Self-contained, no appendDoxology — see none-hymn.yml.
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
return resolveSelfContainedHymn(part);
|
||||
case 'chapter':
|
||||
return [{ kind: 'chapter', text: resolveMinorHourChapter('none', day, part.textRef.id) }];
|
||||
case 'day-collect':
|
||||
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// psalms, no closing repeat (unlike Prime): confirmed directly
|
||||
// against the engine, Capitulum follows the third psalm immediately.
|
||||
const psalmRefs = getPsalmsFor('none', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const opening = openingAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]), resolveOfficeWinner(day));
|
||||
return psalmRefs.map((ref, i) => {
|
||||
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
|
||||
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
|
||||
|
||||
+3
-2
@@ -14,6 +14,7 @@ import {
|
||||
resolveCommon,
|
||||
appendDoxology,
|
||||
splitNamedAntiphon,
|
||||
openingAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
isSundayOrFeastOffice,
|
||||
@@ -65,11 +66,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
}
|
||||
{
|
||||
const psalmRefs = getPsalmsFor('prime', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]));
|
||||
const antiphonText = resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]);
|
||||
// Full text on a Double-or-higher feast, otherwise just the incipit
|
||||
// — see hours/antiphon.ts. The full repeat comes later, after the
|
||||
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
||||
return psalmRefs.map((ref, i) => {
|
||||
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
|
||||
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ResolvedPart, ResolvedText } from './types';
|
||||
import type { PropersRef, ResolvedPart, ResolvedText } from './types';
|
||||
import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calendar/types';
|
||||
import type { ProperText } from '../propers';
|
||||
import { getCommonProper, getTemporalProper } from '../propers';
|
||||
@@ -6,7 +6,7 @@ import { getSaintRecord } from '../calendar/feasts';
|
||||
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar';
|
||||
import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
|
||||
import { isInTriduum } from '../calendar/temporal';
|
||||
import { splitAntiphon } from './antiphon';
|
||||
import { splitAntiphon, isDoubleOrHigher } from './antiphon';
|
||||
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
|
||||
|
||||
type BilingualText = Partial<Record<string, string>>;
|
||||
@@ -31,6 +31,15 @@ export function resolveCommon(id: string): ResolvedText {
|
||||
return toResolvedText(getCommonProper(id));
|
||||
}
|
||||
|
||||
/** Terce/Sext/None's own Matins-less hymn 'hymn' case — a plain, fixed,
|
||||
* self-contained text with no doxology mechanism at all (the closing
|
||||
* doxology is baked directly into each hour's own hymn text file; see
|
||||
* {terce,sext,none}-hymn.yml) and no override/season tiering. Byte-
|
||||
* identical across all three files before being centralized here. */
|
||||
export function resolveSelfContainedHymn(part: { textRef: PropersRef }): ResolvedPart[] {
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
}
|
||||
|
||||
// Named temporal winners with real standing of their own, unconditionally
|
||||
// override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which
|
||||
// shares this set (imported from here, not duplicated) since it's the
|
||||
@@ -692,6 +701,19 @@ export function splitNamedAntiphon(antiphon: ResolvedText): {
|
||||
return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } };
|
||||
}
|
||||
|
||||
/** The antiphon actually shown *before* a psalm/canticle: full text on a
|
||||
* Double-rank winner or higher, incipit only below that (see
|
||||
* `isDoubleOrHigher`'s own doc comment for the underlying rule) — the one
|
||||
* idiom every hour's psalmody independently repeated (`splitNamedAntiphon`
|
||||
* + `isDoubleOrHigher(...) ? full : incipit`) before being centralized
|
||||
* here. The closing repeat after the psalm/canticle is always `full`,
|
||||
* unconditionally, at each call site — this helper only decides the
|
||||
* opening. */
|
||||
export function openingAntiphon(antiphon: ResolvedText, winner: DayWinner): ResolvedText {
|
||||
const { incipit, full } = splitNamedAntiphon(antiphon);
|
||||
return isDoubleOrHigher(winner) ? full : incipit;
|
||||
}
|
||||
|
||||
/** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri —
|
||||
* appended after every psalm (hours/index.ts) except during the Sacred
|
||||
* Triduum. Not "verified" via verifiedText() because it's boilerplate
|
||||
|
||||
+5
-6
@@ -5,14 +5,15 @@ import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||
import { applyFlexaMark } from './antiphon';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollect,
|
||||
splitNamedAntiphon,
|
||||
openingAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
resolveMinorHourChapter,
|
||||
resolveSelfContainedHymn,
|
||||
} from './resolve-common';
|
||||
import sextDefinitionData from '../data/hours/sext.yml';
|
||||
import antiphonsData from '../data/hours/sext-antiphons.yml';
|
||||
@@ -25,8 +26,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'opening-versicle':
|
||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
||||
case 'hymn':
|
||||
// Self-contained, no appendDoxology — see sext-hymn.yml.
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
return resolveSelfContainedHymn(part);
|
||||
case 'chapter':
|
||||
return [{ kind: 'chapter', text: resolveMinorHourChapter('sext', day, part.textRef.id) }];
|
||||
case 'day-collect':
|
||||
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// psalms, no closing repeat (unlike Prime): confirmed directly
|
||||
// against the engine, Capitulum follows the third psalm immediately.
|
||||
const psalmRefs = getPsalmsFor('sext', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const opening = openingAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]), resolveOfficeWinner(day));
|
||||
return psalmRefs.map((ref, i) => {
|
||||
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
|
||||
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
|
||||
|
||||
+5
-6
@@ -5,14 +5,15 @@ import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmsFor } from '../psalter/distribution';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||
import { applyFlexaMark } from './antiphon';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollect,
|
||||
splitNamedAntiphon,
|
||||
openingAntiphon,
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
resolveMinorHourChapter,
|
||||
resolveSelfContainedHymn,
|
||||
} from './resolve-common';
|
||||
import terceDefinitionData from '../data/hours/terce.yml';
|
||||
import antiphonsData from '../data/hours/terce-antiphons.yml';
|
||||
@@ -25,8 +26,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'opening-versicle':
|
||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
||||
case 'hymn':
|
||||
// Self-contained, no appendDoxology — see terce-hymn.yml.
|
||||
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
|
||||
return resolveSelfContainedHymn(part);
|
||||
case 'chapter':
|
||||
return [{ kind: 'chapter', text: resolveMinorHourChapter('terce', day, part.textRef.id) }];
|
||||
case 'day-collect':
|
||||
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
// psalms, no closing repeat (unlike Prime): confirmed directly
|
||||
// against the engine, Capitulum follows the third psalm immediately.
|
||||
const psalmRefs = getPsalmsFor('terce', day.weekday);
|
||||
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const opening = openingAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]), resolveOfficeWinner(day));
|
||||
return psalmRefs.map((ref, i) => {
|
||||
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
|
||||
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
|
||||
|
||||
@@ -4,12 +4,13 @@ import { resolveEveningDay } from '../calendar/vespers';
|
||||
import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
||||
import { applyFlexaMark } from './antiphon';
|
||||
import {
|
||||
resolveCommon,
|
||||
getDayCollects,
|
||||
getMagnificatAntiphon,
|
||||
splitNamedAntiphon,
|
||||
openingAntiphon,
|
||||
resolveOfficeWinner,
|
||||
verifiedText,
|
||||
seasonalOfficeSuffix,
|
||||
@@ -47,8 +48,9 @@ function normalizePsalmRef(ref: PsalmRefLike): { number: number; verses?: string
|
||||
* in full once, after the last. Mirrors lauds.ts's psalmParts, extended
|
||||
* for verse-range entries (see PsalmRefLike). */
|
||||
function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
|
||||
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const antiphonText = verifiedText(group.antiphon);
|
||||
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
const refs = group.psalms.map(normalizePsalmRef);
|
||||
const parts: ResolvedPart[] = refs.map((ref, i) => {
|
||||
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
|
||||
@@ -161,8 +163,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'vespers-office':
|
||||
return resolveOffice(day);
|
||||
case 'magnificat': {
|
||||
const { incipit, full } = splitNamedAntiphon(getMagnificatAntiphon(day));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
const antiphonText = getMagnificatAntiphon(day);
|
||||
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
|
||||
const { full } = splitNamedAntiphon(antiphonText);
|
||||
return [
|
||||
{ kind: 'canticle', canticleId: 'magnificat', text: resolveCommon('magnificat'), antiphon: opening },
|
||||
{ kind: 'antiphon', text: full },
|
||||
|
||||
@@ -490,3 +490,111 @@ describe('resolveOrdo("matins", ...) calendar-month/week nocturn-readings import
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Real bug (2026-08-24 fix): nocturnReadingIds unconditionally pooled the
|
||||
// plain temporalId/month-week ids into every day's nocturn-reading pool,
|
||||
// even on a day where a real feast wins outright with zero commemorations
|
||||
// (decideOccurrence's `ordinary-feria` branch) -- correctly suppressing the
|
||||
// temporal identity entirely. St. Bartholomew (duplex-2-classis, 2026-08-24,
|
||||
// a Monday) has no Nocturn 3 content of his own, so the leftover 13th-
|
||||
// Sunday-after-Pentecost (post-pentecost-13.yml) and month-week (month-
|
||||
// week-084.yml) readings wrongly filled his Nocturn 3. Fixed by gating that
|
||||
// pooling on whether the day's own occurrence decision actually retained
|
||||
// the temporal identity (day.winner.kind === 'temporal', or a `kind:
|
||||
// 'temporal'` entry in day.commemorations).
|
||||
describe('resolveOrdo("matins", ...) nocturn-reading temporal/month-week suppression (2026-08-24 fix)', () => {
|
||||
function lessonLabels(date: string) {
|
||||
const ordo = resolveOrdo('matins', date);
|
||||
return ordo.parts.filter((p) => p.kind === 'lesson').map((p) => (p as { label?: string }).label);
|
||||
}
|
||||
|
||||
it("St. Bartholomew (2026-08-24, duplex-2-classis, zero commemorations) does not pool the suppressed Sunday/month-week readings", () => {
|
||||
const labels = lessonLabels('2026-08-24');
|
||||
expect(labels).not.toContain('St. Augustine, Bishop of Hippo, Book 2, Questions on the Gospels, ch. 40');
|
||||
expect(labels).not.toContain('St. Gregory the Great, Moralia in Job, Book 1, ch. 10');
|
||||
});
|
||||
|
||||
it("St. Bartholomew's own Nocturn 2 still carries his own proper vita reading", () => {
|
||||
const ordo = resolveOrdo('matins', '2026-08-24');
|
||||
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { text: Record<string, string> } }[];
|
||||
expect(lessons.some((l) => l.text.text.la?.includes('Bartholomǽus Apóstolus'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a plain ferial weekday with no sanctoral winner (2026-09-04, a gap day in sanctoral-calendar.yml) still pools the plain temporal/month-week readings, unaffected by the gate', () => {
|
||||
const ordo = resolveOrdo('matins', '2026-09-04');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user