Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1fd3b9c4 | |||
| 6f8f6618b9 | |||
| 9364185f70 | |||
| e93fc4032a | |||
| 7ffb9afea2 | |||
| b230f3f1e8 |
+16
-5
@@ -115,11 +115,19 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass {
|
||||
*
|
||||
* - Highest effective rank (`wins`, already elevated on either octave's
|
||||
* own closing day) wins outright.
|
||||
* - Tied rank: the more recently *started* octave wins (smaller
|
||||
* `dayNumber` today) — the reasoning given was that day 1 of a newly
|
||||
* started octave needs to be fully present, which is the whole point
|
||||
* of it starting; the older octave that's already been running is
|
||||
* commemorated instead, same as any octave that loses this comparison.
|
||||
* - Tied rank, one of them on its own closing day: the closing day wins
|
||||
* — its "in Octava" day is the octave's own culminating day, so it
|
||||
* takes precedence over an ordinary day of an equally-ranked octave
|
||||
* even if that other octave started more recently (2026-08 user
|
||||
* instruction, concrete case: Aug 17, St. Lawrence's own closing day,
|
||||
* vs. the Assumption's ordinary day 3, both `duplex` once Assumption's
|
||||
* octave was strengthened — Lawrence's closing day wins).
|
||||
* - Tied rank, neither (or both) on their own closing day: the more
|
||||
* recently *started* octave wins (smaller `dayNumber` today) — the
|
||||
* reasoning given was that day 1 of a newly started octave needs to be
|
||||
* fully present, which is the whole point of it starting; the older
|
||||
* octave that's already been running is commemorated instead, same as
|
||||
* any octave that loses this comparison.
|
||||
*
|
||||
* Undefined when no octave is active at all. Every other active octave
|
||||
* still gets commemorated regardless of which one wins here — this
|
||||
@@ -187,6 +195,9 @@ export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefi
|
||||
if (rankCmp < 0) {
|
||||
return best;
|
||||
}
|
||||
if (candidate.isClosingDay !== best.isClosingDay) {
|
||||
return candidate.isClosingDay ? candidate : best;
|
||||
}
|
||||
return candidate.dayNumber < best.dayNumber ? candidate : best;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,18 @@ export function easterOffsetOf(isoDate: string): number {
|
||||
return daysBetween(toIsoDate(easterSunday(year)), isoDate);
|
||||
}
|
||||
|
||||
/** The Sacred Triduum: Maundy/Holy Thursday through Holy Saturday
|
||||
* (inclusive). Doesn't line up with `season` (which resolves this window
|
||||
* to 'passiontide', same as the two weeks before it) — see
|
||||
* hours/marian-antiphon.ts's isCandlemasToHolyWednesday for the same
|
||||
* direct-date-check pattern used for another window `season` can't
|
||||
* represent. Gloria Patri is omitted after psalms only in this narrower
|
||||
* window, not throughout Passiontide/Holy Week generally. */
|
||||
export function isInTriduum(isoDate: string): boolean {
|
||||
const offset = easterOffsetOf(isoDate);
|
||||
return offset >= -3 && offset <= -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's precedence category under the temporal cycle alone — see
|
||||
* calendar/types.ts's TemporalCategory doc comment and
|
||||
|
||||
@@ -10,5 +10,16 @@ rank: duplex-1-classis
|
||||
common: common-of-the-bvm
|
||||
propers: "assumption"
|
||||
# Has a real octave (Aug 15-22) -- see calendar/types.ts's OctaveConfig.
|
||||
# Strengthened to `wins: duplex` (2026-08 user decision), same pattern
|
||||
# as pentecost-sunday.yml's own override -- a Semiduplex saint no
|
||||
# longer just ties (and, by the ordinary-day tie rule, wins) an
|
||||
# ordinary octave day here; it loses outright to the octave instead.
|
||||
# Concrete motivation: st-john-eudes.yml's demotion to Semiduplex only
|
||||
# actually cedes Aug 19 to the octave because of this. Side effect
|
||||
# accepted by the user: st-damasus.yml (Dec 11, Immaculate Conception's
|
||||
# octave) and st-nicholas-of-tolentino.yml (Sep 10, Nativity of the
|
||||
# BVM's octave) are unrelated octaves not touched by this change, so
|
||||
# they're unaffected -- this only strengthens Assumption's own octave.
|
||||
octave:
|
||||
enabled: true
|
||||
wins: duplex
|
||||
|
||||
@@ -9,5 +9,11 @@ common: common-of-the-bvm
|
||||
propers: "immaculate-conception"
|
||||
minorHoursCommon: common-of-the-bvm
|
||||
# Has a real octave (Dec 8-15) -- see calendar/types.ts's OctaveConfig.
|
||||
# Strengthened to `wins: duplex` (2026-08 user decision), same pattern
|
||||
# as pentecost-sunday.yml's and assumption.yml's own overrides. Side
|
||||
# effect accepted by the user: St. Damasus (Dec 11, semiduplex) no
|
||||
# longer ties and wins an ordinary octave day here -- he now loses
|
||||
# outright to the octave instead (see st-damasus.yml).
|
||||
octave:
|
||||
enabled: true
|
||||
wins: duplex
|
||||
|
||||
@@ -10,5 +10,12 @@ common: common-of-the-bvm
|
||||
propers: "nativity-bvm"
|
||||
minorHoursCommon: common-of-the-bvm
|
||||
# Has a real octave (Sep 8-15) -- see calendar/types.ts's OctaveConfig.
|
||||
# Strengthened to `wins: duplex` (2026-08 user decision), same pattern
|
||||
# as pentecost-sunday.yml's and assumption.yml's own overrides. Side
|
||||
# effect accepted by the user: St. Nicholas of Tolentino (Sep 10,
|
||||
# semiduplex) no longer ties and wins an ordinary octave day here -- he
|
||||
# now loses outright to the octave instead (see
|
||||
# st-nicholas-of-tolentino.yml).
|
||||
octave:
|
||||
enabled: true
|
||||
wins: duplex
|
||||
|
||||
@@ -4,9 +4,16 @@
|
||||
# No [Ant 1] of his own -- benedictusCommon/minorHoursCommon fall back
|
||||
# to the plain Common of a Confessor. No Lectio7-9 in his own file --
|
||||
# defers to the Common's own Nocturn 3, not transcribed here.
|
||||
#
|
||||
# Deliberately demoted from the reference engine's own Duplex to
|
||||
# Semiduplex (2026-08 user decision) -- a minor confessor of no
|
||||
# particular standing shouldn't outrank the Assumption's own octave,
|
||||
# now strengthened to `wins: duplex` (see assumption.yml) specifically
|
||||
# so this demotion actually takes effect on his day (Aug 19, the
|
||||
# octave's 5th day) instead of merely tying and still winning.
|
||||
id: st-john-eudes
|
||||
name: "St. John Eudes, Confessor"
|
||||
rank: duplex
|
||||
rank: semiduplex
|
||||
common: common-of-a-confessor
|
||||
propers: "st-john-eudes"
|
||||
benedictusCommon: common-of-a-confessor
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
# `privileged-feria` model would have wrongly forbidden). Octave day
|
||||
# commemorated in return. Own proper collect, no unique antiphon of his
|
||||
# own.
|
||||
#
|
||||
# Deliberately promoted from the reference engine's own Semiduplex to
|
||||
# Duplex (2026-08 user decision, made alongside assumption.yml's octave
|
||||
# strengthening to `wins: duplex`) -- purely to keep him winning Dec 29
|
||||
# outright once the common octave-vs-saint default is no longer
|
||||
# uniformly Semiduplex everywhere; not a reference-engine finding.
|
||||
id: st-thomas-becket
|
||||
name: "St. Thomas of Canterbury, Bishop and Martyr"
|
||||
rank: semiduplex
|
||||
rank: duplex
|
||||
common: common-of-a-martyr-bishop
|
||||
propers: "st-thomas-becket"
|
||||
# P/T/S/N: no [Ant Prima]/etc of its own in the source (12-29o.txt) --
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds live
|
||||
# query. Fixed year-round, same shape as nunc-dimittis.yml — the framing
|
||||
# antiphon is resolved separately, by day (see hours/resolve-common.ts's
|
||||
# getBenedictusAntiphon), not stored here.
|
||||
# getBenedictusAntiphon), not stored here. The closing Gloria Patri isn't
|
||||
# stored here either — it's attached centrally (hours/index.ts) so it can
|
||||
# be omitted during the Sacred Triduum (live-verified: Divino Afflatu 1954
|
||||
# and Tridentine 1906 both omit it at Lauds of Holy Thursday).
|
||||
id: benedictus
|
||||
text:
|
||||
la: |
|
||||
@@ -17,8 +20,6 @@ text:
|
||||
Ad dandam sciéntiam salútis plebi ejus: * in remissiónem peccatórum eórum:
|
||||
Per víscera misericórdiæ Dei nostri: * in quibus visitávit nos, óriens ex alto:
|
||||
Illumináre his, qui in ténebris, et in umbra mortis sedent: * ad dirigéndos pedes nostros in viam pacis.
|
||||
V. Glória Patri, et Fílio, * et Spirítui Sancto.
|
||||
R. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.
|
||||
en: |
|
||||
Blessed be the Lord ✠ God of Israel; * because he hath visited and wrought the redemption of his people:
|
||||
And hath raised up an horn of salvation to us, * in the house of David his servant:
|
||||
@@ -32,8 +33,6 @@ text:
|
||||
To give knowledge of salvation to his people, * unto the remission of their sins:
|
||||
Through the bowels of the mercy of our God, * in which the Orient from on high hath visited us:
|
||||
To enlighten them that sit in darkness, and in the shadow of death: * to direct our feet into the way of peace.
|
||||
V. Glory be to the Father, and to the Son, * and to the Holy Ghost.
|
||||
R. As it was in the beginning, is now, * and ever shall be, world without end. Amen.
|
||||
citation:
|
||||
la: "Luc. 1:68-79"
|
||||
en: "Luke 1:68-79"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
# year-round, same shape as benedictus.yml/nunc-dimittis.yml — the framing
|
||||
# antiphon is resolved separately, by day (see hours/resolve-common.ts's
|
||||
# getMagnificatAntiphon), not stored here. Not live-query-verified (see
|
||||
# TODO.md) — read directly from source.
|
||||
# TODO.md) — read directly from source. The closing Gloria Patri isn't
|
||||
# stored here either — it's attached centrally (hours/index.ts) so it can
|
||||
# be omitted during the Sacred Triduum, same as every other canticle/psalm.
|
||||
id: magnificat
|
||||
text:
|
||||
la: |
|
||||
@@ -18,8 +20,6 @@ text:
|
||||
Esuriéntes implévit bonis: * et dívites dimísit inánes.
|
||||
Suscépit Israël púerum suum: * recordátus misericórdiæ suæ.
|
||||
Sicut locútus est ad patres nostros: * Ábraham, et sémini ejus in sǽcula.
|
||||
V. Glória Patri, et Fílio, * et Spirítui Sancto.
|
||||
R. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.
|
||||
en: |
|
||||
My soul ✠ doth magnify the Lord.
|
||||
And my spirit hath rejoiced * in God my Saviour.
|
||||
@@ -31,8 +31,6 @@ text:
|
||||
He hath filled the hungry with good things; * and the rich he hath sent empty away.
|
||||
He hath received Israel his servant, * being mindful of his mercy:
|
||||
As he spoke to our fathers, * to Abraham and to his seed for ever.
|
||||
V. Glory be to the Father, and to the Son, * and to the Holy Ghost.
|
||||
R. As it was in the beginning, is now, * and ever shall be, world without end. Amen.
|
||||
status:
|
||||
la: verified
|
||||
en: verified
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Verified against Divinum Officium (Tridentine 1906 run, 2026-08-25 —
|
||||
# Monastic Compline doesn't have this canticle at all; it's added from the
|
||||
# Tridentine 1906 form by explicit choice). The antiphon that frames it
|
||||
# ("Salva nos") is a separate proper — see nunc-dimittis-antiphon.yml.
|
||||
# ("Salva nos") is a separate proper — see nunc-dimittis-antiphon.yml. The
|
||||
# closing Gloria Patri isn't stored here either — it's attached centrally
|
||||
# (hours/index.ts) so it can be omitted during the Sacred Triduum, same as
|
||||
# every other canticle/psalm.
|
||||
id: nunc-dimittis
|
||||
text:
|
||||
la: |
|
||||
@@ -9,15 +12,11 @@ text:
|
||||
Quia vidérunt óculi mei * salutáre tuum,
|
||||
Quod parásti * ante fáciem ómnium populórum,
|
||||
Lumen ad revelatiónem géntium, * et glóriam plebis tuæ Israël.
|
||||
V. Glória Patri, et Fílio, * et Spirítui Sancto.
|
||||
R. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.
|
||||
en: |
|
||||
Now ✠ thou dost dismiss thy servant, O Lord, * according to thy word in peace;
|
||||
Because my eyes have seen * thy salvation,
|
||||
Which thou hast prepared * before the face of all peoples:
|
||||
A light to the revelation of the Gentiles, * and the glory of thy people Israel.
|
||||
V. Glory be to the Father, and to the Son, * and to the Holy Ghost.
|
||||
R. As it was in the beginning, is now, * and ever shall be, world without end. Amen.
|
||||
citation:
|
||||
la: "Luc. 2:29-32"
|
||||
en: "Luke 2:29-32"
|
||||
|
||||
@@ -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 } from './resolve-common';
|
||||
import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common';
|
||||
import complineDefinitionData from '../data/hours/compline.yml';
|
||||
|
||||
const complineDefinition = complineDefinitionData as HourDefinition;
|
||||
@@ -36,10 +36,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
return [{ kind: 'hymn', text: appendDoxology(body, doxology) }];
|
||||
}
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
case 'versicle':
|
||||
case 'prayer':
|
||||
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
|
||||
case 'responsory':
|
||||
return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(part.textRef.id), day) }];
|
||||
case 'preces':
|
||||
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
|
||||
return [];
|
||||
|
||||
+25
-2
@@ -1,4 +1,4 @@
|
||||
import type { HourId, ResolvedOrdo } from './types';
|
||||
import type { HourId, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
||||
import { HOUR_IDS } from './types';
|
||||
import * as prime from './prime';
|
||||
import * as compline from './compline';
|
||||
@@ -8,6 +8,8 @@ import * as none from './none';
|
||||
import * as lauds from './lauds';
|
||||
import * as vespers from './vespers';
|
||||
import * as matins from './matins';
|
||||
import { resolveDay } from '../calendar';
|
||||
import { resolveGloriaPatri } from './resolve-common';
|
||||
|
||||
type Resolver = (date: string) => ResolvedOrdo;
|
||||
|
||||
@@ -24,8 +26,29 @@ const registry: Record<HourId, Resolver> = {
|
||||
matins: matins.resolveOrdo,
|
||||
};
|
||||
|
||||
/** Attaches the closing Gloria Patri to every plain psalm and canticle part
|
||||
* (recursing into 'nocturn-psalmody' groups), so no per-hour builder has to
|
||||
* remember to do it — and so the Sacred Triduum omission
|
||||
* (resolveGloriaPatri) is enforced in exactly one place rather than once
|
||||
* per hour file. Live-verified (Divino Afflatu 1954, Tridentine 1906) that
|
||||
* canticles (e.g. Benedictus at Lauds of Holy Thursday) follow the exact
|
||||
* same Triduum-only omission as plain psalms — not a separate rule. */
|
||||
function withGloriaPatri(parts: ResolvedPart[], gloriaPatri: ResolvedText | undefined): ResolvedPart[] {
|
||||
return parts.map((part) => {
|
||||
if (part.kind === 'psalm' || part.kind === 'canticle') {
|
||||
return { ...part, gloriaPatri };
|
||||
}
|
||||
if (part.kind === 'nocturn-psalmody') {
|
||||
return { ...part, psalms: withGloriaPatri(part.psalms, gloriaPatri) };
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo {
|
||||
return registry[hourId](date);
|
||||
const ordo = registry[hourId](date);
|
||||
const gloriaPatri = resolveGloriaPatri(resolveDay(date));
|
||||
return { ...ordo, parts: withGloriaPatri(ordo.parts, gloriaPatri) };
|
||||
}
|
||||
|
||||
export { HOUR_IDS };
|
||||
|
||||
+8
-11
@@ -19,6 +19,7 @@ import {
|
||||
seasonalOfficeSuffix,
|
||||
isFerialOrVigil,
|
||||
getOfficeOverrideId,
|
||||
resolveResponsory,
|
||||
} from './resolve-common';
|
||||
import laudsDefinitionData from '../data/hours/lauds.yml';
|
||||
import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml';
|
||||
@@ -92,19 +93,15 @@ function psalmParts(numbers: number[], antiphon: BilingualText, opening: Resolve
|
||||
|
||||
const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const;
|
||||
|
||||
const GLORIA_PATRI = {
|
||||
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.',
|
||||
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.',
|
||||
};
|
||||
|
||||
/** Joins a canticle's verse array (or a `[start, end)` slice of it — see
|
||||
* Saturday's split canticle below) into one flowing text block, the same
|
||||
* shape nunc-dimittis/benedictus already use — Lauds' weekday canticles
|
||||
* are stored verse-by-verse (see lauds-canticles.ts) purely because that's
|
||||
* how they were transcribed, not because callers need per-verse access.
|
||||
* Appends the fixed Gloria Patri line every canticle (or canticle part)
|
||||
* ends with in real practice, same wording as benedictus.yml's own
|
||||
* trailing line. */
|
||||
* Does NOT append a closing Gloria Patri — that's attached centrally
|
||||
* (hours/index.ts) as its own 'canticle' field, same as every other
|
||||
* canticle/psalm, so the Sacred Triduum omission only has to be handled
|
||||
* in one place. */
|
||||
function canticleText(canticleId: string, slice?: [number, number]): ResolvedText {
|
||||
const canticle = getCanticle(canticleId);
|
||||
const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses;
|
||||
@@ -115,7 +112,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
text[lang] = `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`;
|
||||
text[lang] = lines.join('\n');
|
||||
let worst: 'verified' | 'draft' | 'missing' = 'verified';
|
||||
for (const v of verses) {
|
||||
const s = v.status[lang] ?? 'missing';
|
||||
@@ -196,7 +193,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
|
||||
if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') {
|
||||
return [
|
||||
{ kind: 'chapter', text: chapter },
|
||||
{ kind: 'responsory', text: resolveCommon(`lauds-responsory-${overrideId}`) },
|
||||
{ kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${overrideId}`), day) },
|
||||
{ kind: 'hymn', text: resolveCommon(`lauds-hymn-${overrideId}`) },
|
||||
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) },
|
||||
];
|
||||
@@ -206,7 +203,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
|
||||
const key = seasonSuffix ?? day.weekday;
|
||||
return [
|
||||
{ kind: 'chapter', text: resolveCommon(seasonSuffix ? `lauds-capitulum-${key}` : capitulumId(day.weekday)) },
|
||||
{ kind: 'responsory', text: resolveCommon(`lauds-responsory-${key}`) },
|
||||
{ kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${key}`), day) },
|
||||
{ kind: 'hymn', text: resolveCommon(`lauds-hymn-${key}`) },
|
||||
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) },
|
||||
];
|
||||
|
||||
+12
-1
@@ -63,6 +63,7 @@
|
||||
import type { ResolvedOrdo, ResolvedPart, ResolvedVerse } from './types';
|
||||
import type { LiturgicalDay } from '../calendar/types';
|
||||
import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar';
|
||||
import { isInTriduum } from '../calendar/temporal';
|
||||
import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
|
||||
@@ -301,7 +302,17 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
const pool = buildReadingPool(day, temporalId, date);
|
||||
const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1);
|
||||
|
||||
const parts: ResolvedPart[] = [
|
||||
// Tenebrae's real rubric: during the Sacred Triduum the whole opening
|
||||
// (versicle, Ps 3, Invitatory, hymn) is dropped, replaced by a silently-
|
||||
// said Pater/Ave/Credo — live-verified against both Monastic Tridentinum
|
||||
// 1617 and Divino Afflatu 1954 (Holy Thursday: "Invitatorium{omittitur}",
|
||||
// "Hymnus{omittitur}", no Ps 3 or versicle either). This app already
|
||||
// never renders that silent Pater/Ave/Credo elsewhere (see
|
||||
// data/hours/prime.yml's own header: dropped "to keep the hour
|
||||
// shorter"), so the fix here is a pure omission, not a new part kind.
|
||||
const parts: ResolvedPart[] = isInTriduum(day.date)
|
||||
? []
|
||||
: [
|
||||
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
|
||||
plainPsalm(3),
|
||||
{ kind: 'section-heading', label: 'Invitatory' },
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ import {
|
||||
resolveOfficeWinner,
|
||||
resolveMinorHourAntiphon,
|
||||
isSundayOrFeastOffice,
|
||||
resolveResponsory,
|
||||
} from './resolve-common';
|
||||
import primeDefinitionData from '../data/hours/prime.yml';
|
||||
import antiphonsData from '../data/hours/prime-antiphons.yml';
|
||||
@@ -60,7 +61,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
if (part.resolve === 'by-season') {
|
||||
// Currently only the chapter responsory's verse uses this — see
|
||||
// hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime.
|
||||
return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.winner)) }];
|
||||
return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(getChapterResponsoryId(day.season, day.winner)), day) }];
|
||||
}
|
||||
{
|
||||
const psalmRefs = getPsalmsFor('prime', day.weekday);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getCommonProper, getTemporalProper } from '../propers';
|
||||
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 vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
|
||||
|
||||
@@ -677,3 +678,65 @@ export function splitNamedAntiphon(antiphon: ResolvedText): {
|
||||
}
|
||||
return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } };
|
||||
}
|
||||
|
||||
/** 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
|
||||
* used everywhere, not a sourced proper text. */
|
||||
const GLORIA_PATRI: BilingualText = {
|
||||
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.',
|
||||
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.',
|
||||
};
|
||||
|
||||
/** The closing Gloria Patri for a psalm, or `undefined` during the Sacred
|
||||
* Triduum (calendar/temporal.ts's isInTriduum) when it's omitted
|
||||
* entirely — not just seasonally varied wording, an actual omission. */
|
||||
export function resolveGloriaPatri(day: LiturgicalDay): ResolvedText | undefined {
|
||||
if (isInTriduum(day.date)) {
|
||||
return undefined;
|
||||
}
|
||||
return verifiedText(GLORIA_PATRI);
|
||||
}
|
||||
|
||||
// The responsory's own closing Gloria is a DIFFERENT rule from the psalm/
|
||||
// canticle one above: wider window (all of Passiontide, Passion Sunday
|
||||
// through Holy Saturday -- `season === 'passiontide'` already covers
|
||||
// exactly that span, no separate date-window helper needed), and it
|
||||
// doesn't apply to a Sancti (saint) feast's own day even within that
|
||||
// window -- live-verified: St. Joseph (Duplex I, 2027-03-19, landing in
|
||||
// Passiontide that year) keeps "In manus tuas"'s Gloria at Compline, while
|
||||
// an ordinary ferial day in the same window (2027-03-15) omits it, at both
|
||||
// Compline and Prime's chapter-responsory.
|
||||
//
|
||||
// Every currently-authored responsory -- Gloria-bearing or already
|
||||
// Gloria-free (e.g. lauds-responsory-passiontide.yml, a genuinely
|
||||
// different proper-of-season chant, not a stripped copy of the ordinary
|
||||
// one) -- ends the same way regardless: a short "R. [repetenda]" after
|
||||
// the verse, then (only when shown) this same fixed Gloria line, then
|
||||
// always a final "R. [full repetenda]" line. So the two forms differ by
|
||||
// exactly one known, fixed-wording line -- omitting it is a filter, not a
|
||||
// data migration.
|
||||
const RESPONSORY_GLORIA_LINE: BilingualText = {
|
||||
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.',
|
||||
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.',
|
||||
};
|
||||
|
||||
export function omitResponsoryGloria(day: LiturgicalDay): boolean {
|
||||
return day.season === 'passiontide' && day.winner.kind !== 'sanctoral';
|
||||
}
|
||||
|
||||
/** Strips the responsory's closing Gloria line from `text` when the day
|
||||
* calls for its omission (see omitResponsoryGloria); returns `text`
|
||||
* unchanged otherwise. Safe to call on already-Gloria-free text (e.g. the
|
||||
* proper-of-season responsories) -- the line just won't be found. */
|
||||
export function resolveResponsory(text: ResolvedText, day: LiturgicalDay): ResolvedText {
|
||||
if (!omitResponsoryGloria(day)) {
|
||||
return text;
|
||||
}
|
||||
const stripped: Partial<Record<string, string>> = {};
|
||||
for (const [lang, t] of Object.entries(text.text)) {
|
||||
const gloriaLine = RESPONSORY_GLORIA_LINE[lang];
|
||||
stripped[lang] = t && gloriaLine ? t.split('\n').filter((line) => line !== gloriaLine).join('\n') : t;
|
||||
}
|
||||
return { ...text, text: stripped };
|
||||
}
|
||||
|
||||
+8
-2
@@ -199,8 +199,14 @@ export interface HourDefinition {
|
||||
export type ResolvedPart =
|
||||
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText }
|
||||
| { kind: 'preces'; text: ResolvedText; label?: string }
|
||||
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[] }
|
||||
| { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText }
|
||||
// `gloriaPatri` is filled in centrally (hours/index.ts, after every hour
|
||||
// builder returns) rather than per-hour, so it's absent only during the
|
||||
// Sacred Triduum (calendar/temporal.ts's isInTriduum) — never omit it
|
||||
// when building a psalm part yourself.
|
||||
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[]; gloriaPatri?: ResolvedText }
|
||||
// `gloriaPatri` filled in centrally, same as the 'psalm' kind above —
|
||||
// see that field's doc comment.
|
||||
| { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText; gloriaPatri?: ResolvedText }
|
||||
// A standalone antiphon, said on its own rather than framing a psalm —
|
||||
// see 'closing-antiphon' above.
|
||||
| { kind: 'antiphon'; text: ResolvedText }
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
seasonalOfficeSuffix,
|
||||
isFerialOrVigil,
|
||||
getOfficeOverrideId,
|
||||
resolveResponsory,
|
||||
} from './resolve-common';
|
||||
import vespersDefinitionData from '../data/hours/vespers.yml';
|
||||
import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml';
|
||||
@@ -122,7 +123,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
|
||||
if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') {
|
||||
return [
|
||||
{ kind: 'chapter', text: chapter },
|
||||
{ kind: 'responsory', text: resolveCommon(`vespers-responsory-${overrideId}`) },
|
||||
{ kind: 'responsory', text: resolveResponsory(resolveCommon(`vespers-responsory-${overrideId}`), day) },
|
||||
{ kind: 'hymn', text: resolveCommon(`vespers-hymn-${overrideId}`) },
|
||||
{ kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) },
|
||||
];
|
||||
@@ -137,7 +138,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
|
||||
},
|
||||
{
|
||||
kind: 'responsory',
|
||||
text: resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)),
|
||||
text: resolveResponsory(resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)), day),
|
||||
},
|
||||
{ kind: 'hymn', text: resolveCommon(`vespers-hymn-${key}`) },
|
||||
{
|
||||
|
||||
@@ -94,6 +94,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
|
||||
<ol class="psalm-verses">
|
||||
${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')}
|
||||
</ol>
|
||||
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -108,6 +109,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
|
||||
<h3 class="ordo-part-label">${escapeHtml(title)}</h3>
|
||||
${renderCitation(part.text)}
|
||||
${renderColumns(part.text, languages)}
|
||||
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('applyChristmasOctaveSunday', () => {
|
||||
expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'holy-innocents')).toBe(false);
|
||||
});
|
||||
|
||||
it('Sunday on Dec 29 (St. Thomas Becket, only Semiduplex): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => {
|
||||
it('Sunday on Dec 29 (St. Thomas Becket): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => {
|
||||
const sunday = resolveDay('2024-12-29');
|
||||
expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' });
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('applyChristmasOctaveSunday', () => {
|
||||
kind: 'sanctoral',
|
||||
id: 'st-thomas-becket',
|
||||
name: 'St. Thomas of Canterbury, Bishop and Martyr',
|
||||
rank: 'semiduplex',
|
||||
rank: 'duplex',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('applyChristmasOctaveSunday', () => {
|
||||
kind: 'sanctoral',
|
||||
id: 'st-thomas-becket',
|
||||
name: 'St. Thomas of Canterbury, Bishop and Martyr',
|
||||
rank: 'semiduplex',
|
||||
rank: 'duplex',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('getDayLabel — active octave', () => {
|
||||
expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)');
|
||||
});
|
||||
|
||||
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
|
||||
it("picks St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, both duplex now that Assumption's octave is strengthened, via the closing-day tie-break -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
|
||||
// Live-verified against the reference engine directly (Monastic
|
||||
// Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S.
|
||||
// Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day
|
||||
@@ -232,12 +232,16 @@ describe('getDayLabel — active octave', () => {
|
||||
// closing day favors the octave (calendar/index.ts's applyOctaves),
|
||||
// unlike an ordinary mid-octave day's tie, which still favors an
|
||||
// occurring saint (see octaves.test.ts and the December/September
|
||||
// sanctoral tests for those counterexamples). The Assumption's own
|
||||
// day 3 (semiduplex, genuinely active but outranked by Lawrence's
|
||||
// elevated duplex) still gets named, not dropped, alongside it -- with
|
||||
// its own full "Nth Day within the Octave of X" phrasing, not a bare
|
||||
// name: live-verified directly (Divino Afflatu 1954, where Aug 17's
|
||||
// real winner is Hyacinth himself, not Lawrence's octave -- a
|
||||
// sanctoral tests for those counterexamples). Octave-vs-octave, the
|
||||
// same closing-day precedence applies (2026-08 user instruction,
|
||||
// octaves.ts's pickWinningOctave): now that Assumption's own octave
|
||||
// is strengthened to `wins: duplex`, its ordinary day 3 ties
|
||||
// Lawrence's elevated closing day in rank, and the closing day wins
|
||||
// that tie regardless of which octave started more recently. The
|
||||
// Assumption's own day 3 still gets named, not dropped, alongside
|
||||
// it -- with its own full "Nth Day within the Octave of X" phrasing,
|
||||
// not a bare name: live-verified directly (Divino Afflatu 1954, where
|
||||
// Aug 17's real winner is Hyacinth himself, not Lawrence's octave -- a
|
||||
// different rubric-track outcome, but the same commemoration phrasing
|
||||
// question): "Commemoratio: Tertia die infra Octavam S. Assumptionis
|
||||
// Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed
|
||||
@@ -246,7 +250,7 @@ describe('getDayLabel — active octave', () => {
|
||||
'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor',
|
||||
);
|
||||
expect(getDayLabel(resolveDay('2026-08-18')).en).toBe(
|
||||
'4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)',
|
||||
'4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Duplex)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -304,21 +308,19 @@ describe('getDayLabel — active octave', () => {
|
||||
expect(getDayLabel(day).en).not.toContain('Octave');
|
||||
});
|
||||
|
||||
it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => {
|
||||
// Live-verified against the reference engine (Divino Afflatu 1954,
|
||||
// prayMatutinum): Aug 19 is "S. Joannis Eudes Confessoris ~ Duplex"
|
||||
// with the commemoration read as plain "Quinta die infra Octavam S.
|
||||
// Assumptionis Beatæ Mariæ Virginis" -- no rank at all -- i.e. the
|
||||
// octave's real DO title even when it lost outright, not a bare "The
|
||||
// Assumption of the Blessed Virgin Mary" (this app's previous
|
||||
// behavior, reusing `otherActiveOctaveNames`'s plain-name formatting
|
||||
// -- correct for a *secondary* octave alongside another *octave* that
|
||||
// won, see the Aug 17/18 cases above, but wrong here where the octave
|
||||
// itself is what's being commemorated against a winning saint) and
|
||||
// also not `octaveLabel`'s own rank parenthetical (that belongs only
|
||||
// to an octave day when it's the day's own primary winner).
|
||||
it('the Assumption octave, strengthened to `wins: duplex`, now outright displaces St. John Eudes (demoted to Semiduplex) on its own ordinary day 5 -- he is merely commemorated, not won as the day\'s own winner', () => {
|
||||
// 2026-08 user decision: St. John Eudes demoted from the reference
|
||||
// engine's own Duplex to Semiduplex (see st-john-eudes.yml), and the
|
||||
// Assumption's octave strengthened from the shared default
|
||||
// (`wins: semiduplex`) to `wins: duplex` (see assumption.yml, same
|
||||
// pattern as pentecost-sunday.yml's own override) specifically so an
|
||||
// ordinary-day tie no longer favors the occurring saint here: Eudes's
|
||||
// Semiduplex now loses outright to the octave's Duplex threshold,
|
||||
// rather than tying and winning. This deliberately diverges from the
|
||||
// reference engine's own live behavior (which has Eudes winning
|
||||
// outright as Duplex) -- see CLAUDE.md's "not a reconstruction" note.
|
||||
expect(getDayLabel(resolveDay('2026-08-19')).en).toBe(
|
||||
'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary',
|
||||
'5th Day within the Octave of The Assumption of the Blessed Virgin Mary (Duplex) — St. John Eudes, Confessor',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,13 +88,13 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('St. Thomas of Canterbury, only Semiduplex, still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => {
|
||||
it('St. Thomas of Canterbury still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => {
|
||||
const day = resolveDay('2033-12-29');
|
||||
expect(day.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'st-thomas-becket',
|
||||
name: 'St. Thomas of Canterbury, Bishop and Martyr',
|
||||
rank: 'semiduplex',
|
||||
rank: 'duplex',
|
||||
});
|
||||
// Thomas himself has no octave (user-confirmed, 2026-08), but by his
|
||||
// own day, Christmas + Stephen + John + Holy Innocents' octaves have
|
||||
@@ -128,7 +128,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
|
||||
});
|
||||
|
||||
it('the Christmas Octave Sunday still keeps a lesser feast from winning, now via applyChristmasOctaveSunday specifically', () => {
|
||||
// St. Thomas Becket (Semiduplex, Dec 29) transfers off the Sunday
|
||||
// St. Thomas Becket (Dec 29) transfers off the Sunday
|
||||
// rather than winning it -- per calendar/index.ts's
|
||||
// applyChristmasOctaveSunday (see its own dedicated test file), which
|
||||
// now unconditionally wins the Sunday over *any* rank within Dec
|
||||
@@ -144,7 +144,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
|
||||
kind: 'sanctoral',
|
||||
id: 'st-thomas-becket',
|
||||
name: 'St. Thomas of Canterbury, Bishop and Martyr',
|
||||
rank: 'semiduplex',
|
||||
rank: 'duplex',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('activeOctavesFor', () => {
|
||||
{
|
||||
id: 'assumption',
|
||||
name: 'The Assumption of the Blessed Virgin Mary',
|
||||
wins: 'semiduplex',
|
||||
wins: 'duplex',
|
||||
dayNumber: 3,
|
||||
isClosingDay: false,
|
||||
},
|
||||
@@ -60,7 +60,7 @@ describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave preceden
|
||||
expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence');
|
||||
});
|
||||
|
||||
it("St. Lawrence's own elevated closing day (duplex) outranks the Assumption's ordinary day 3 (semiduplex) on their one real overlap, Aug 17", () => {
|
||||
it("St. Lawrence's own elevated closing day (duplex) wins the closing-day tie-break over the Assumption's ordinary day 3 (also duplex) on their one real overlap, Aug 17", () => {
|
||||
const winner = resolveActiveOctave('2026-08-17');
|
||||
expect(winner?.id).toBe('st-lawrence');
|
||||
expect(winner?.wins).toBe('duplex');
|
||||
|
||||
@@ -48,14 +48,13 @@ describe('September sanctoral pull (first pass)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('a monastic-calendar-specific reassignment replaces what the base Tridentine table treats as a plain Nativity-octave continuation', () => {
|
||||
it('a monastic-calendar-specific reassignment places St. Nicholas of Tolentino here, but the Nativity octave (strengthened to `wins: duplex`, 2026-08 user decision) now outranks his Semiduplex outright, commemorating him instead of losing the day to him', () => {
|
||||
const day = resolveDay('2025-09-10');
|
||||
expect(day.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'st-nicholas-of-tolentino',
|
||||
name: 'St. Nicholas of Tolentino, Confessor',
|
||||
rank: 'semiduplex',
|
||||
});
|
||||
expect(day.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-13' });
|
||||
expect(day.commemorations).toEqual([
|
||||
{ kind: 'sanctoral', id: 'st-nicholas-of-tolentino', name: 'St. Nicholas of Tolentino, Confessor', rank: 'semiduplex' },
|
||||
{ kind: 'octave', id: 'nativity-bvm', name: 'The Nativity of the Blessed Virgin Mary' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('St. Matthew wins outright at Duplex II. classis with a real proper collect, distinct from his own Vigil the day before', () => {
|
||||
|
||||
@@ -143,3 +143,38 @@ describe('resolveOrdo("compline", ...)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo("compline", ...) responsory Gloria omission', () => {
|
||||
// Passiontide (Passion Sunday through Holy Saturday) is a wider window
|
||||
// than the psalm/canticle Gloria's own Triduum-only rule, and doesn't
|
||||
// apply on a Sancti feast's own day even within that window --
|
||||
// live-verified: 2027-03-19 is St. Joseph (Duplex I), landing inside
|
||||
// Passiontide that year, keeping "In manus tuas"'s Gloria; 2027-03-15
|
||||
// is a plain ferial in the same window, and omits it.
|
||||
const FERIAL_PASSIONTIDE = '2027-03-15';
|
||||
const FEAST_IN_PASSIONTIDE = '2027-03-19'; // St. Joseph, Duplex I.
|
||||
const ORDINARY_DATE = '2026-08-20';
|
||||
|
||||
it('omits the Gloria on a ferial Passiontide day', () => {
|
||||
const ordo = resolveOrdo('compline', FERIAL_PASSIONTIDE);
|
||||
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).not.toContain('Glória Patri');
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.en : undefined).not.toContain(
|
||||
'Glory be to the Father',
|
||||
);
|
||||
// Everything else about the responsory is untouched.
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.en : undefined).toContain('Into thy hands, O Lord');
|
||||
});
|
||||
|
||||
it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => {
|
||||
const ordo = resolveOrdo('compline', FEAST_IN_PASSIONTIDE);
|
||||
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
|
||||
});
|
||||
|
||||
it('keeps the Gloria on an ordinary date outside Passiontide', () => {
|
||||
const ordo = resolveOrdo('compline', ORDINARY_DATE);
|
||||
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,8 +78,8 @@ describe('resolveOrdo("lauds", ...)', () => {
|
||||
expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Audíte, cæli');
|
||||
expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Ignis succénsus est');
|
||||
// Each part gets its own Gloria Patri.
|
||||
expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Glória Patri');
|
||||
expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Glória Patri');
|
||||
expect(part1?.kind === 'canticle' ? part1.gloriaPatri?.text.la : undefined).toContain('Glória Patri');
|
||||
expect(part2?.kind === 'canticle' ? part2.gloriaPatri?.text.la : undefined).toContain('Glória Patri');
|
||||
|
||||
const repeatIndex = ordo.parts.indexOf(part2!) + 1;
|
||||
const repeat = ordo.parts[repeatIndex];
|
||||
@@ -320,3 +320,22 @@ describe('resolveOrdo("lauds", ...)', () => {
|
||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Auróra lucis rútilat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo("lauds", ...) responsory Gloria omission', () => {
|
||||
// Real Passiontide already routes to a wholesale different proper-of-
|
||||
// season responsory (lauds-responsory-passiontide.yml, "Érue a frámea")
|
||||
// that was already transcribed without a Gloria line -- this just
|
||||
// confirms the shared mechanism (resolve-common.ts's resolveResponsory)
|
||||
// doesn't break that, and still applies correctly on an ordinary date.
|
||||
// Same Passiontide-wide, Sancti-exempt rule as Compline's -- see
|
||||
// tests/hours/compline.test.ts's own describe block for the live-
|
||||
// verification dates.
|
||||
it('has no Gloria on a ferial Passiontide day, has one on an ordinary date', () => {
|
||||
const passiontideResponsory = resolveOrdo('lauds', '2027-03-15').parts.find((p) => p.kind === 'responsory');
|
||||
const ordinaryResponsory = resolveOrdo('lauds', '2026-08-20').parts.find((p) => p.kind === 'responsory');
|
||||
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
|
||||
'Glória Patri',
|
||||
);
|
||||
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,3 +299,36 @@ describe('resolveOrdo("matins", ...) second Duplex+ weekday-feast proof — St.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo("matins", ...) Sacred Triduum', () => {
|
||||
// Tenebrae's real rubric drops the whole opening (versicle, Ps 3,
|
||||
// Invitatory, hymn) in favor of a silently-said Pater/Ave/Credo —
|
||||
// live-verified against both Monastic Tridentinum 1617 and Divino
|
||||
// Afflatu 1954 (Holy Thursday: "Invitatorium{omittitur}",
|
||||
// "Hymnus{omittitur}", no Ps 3 or versicle either). This app never
|
||||
// renders that silent block anywhere (see data/hours/prime.yml's own
|
||||
// header), so the fix is a pure omission -- straight into Nocturn 1.
|
||||
const HOLY_THURSDAY = '2026-04-02';
|
||||
const GOOD_FRIDAY = '2026-04-03';
|
||||
const HOLY_SATURDAY = '2026-04-04';
|
||||
|
||||
it.each([HOLY_THURSDAY, GOOD_FRIDAY, HOLY_SATURDAY])('drops the opening versicle/Ps 3/Invitatory/hymn on %s', (date) => {
|
||||
const ordo = resolveOrdo('matins', date);
|
||||
expect(ordo.parts.some((p) => p.kind === 'versicle')).toBe(false);
|
||||
expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 3)).toBe(false);
|
||||
expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 94)).toBe(false);
|
||||
expect(ordo.parts.some((p) => p.kind === 'section-heading' && p.label === 'Invitatory')).toBe(false);
|
||||
expect(ordo.parts.some((p) => p.kind === 'hymn')).toBe(false);
|
||||
});
|
||||
|
||||
it('goes straight into Nocturn 1 psalmody as the very first part', () => {
|
||||
const ordo = resolveOrdo('matins', HOLY_THURSDAY);
|
||||
expect(ordo.parts[0]?.kind).toBe('psalm');
|
||||
});
|
||||
|
||||
it("leaves an ordinary day's opening block untouched", () => {
|
||||
const ordo = resolveOrdo('matins', '2026-08-20');
|
||||
expect(ordo.parts[0]?.kind).toBe('versicle');
|
||||
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,3 +224,23 @@ describe('resolveOrdo("prime", ...)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo("prime", ...) chapter-responsory Gloria omission', () => {
|
||||
// Same Passiontide-wide, Sancti-exempt rule as Compline's — see
|
||||
// tests/hours/compline.test.ts's own describe block for the live-
|
||||
// verification dates.
|
||||
it('omits the Gloria on a ferial Passiontide day, keeps it on an ordinary date', () => {
|
||||
const passiontideResponsory = resolveOrdo('prime', '2027-03-15').parts.find((p) => p.kind === 'responsory');
|
||||
const ordinaryResponsory = resolveOrdo('prime', '2026-08-20').parts.find((p) => p.kind === 'responsory');
|
||||
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
|
||||
'Glória Patri',
|
||||
);
|
||||
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
|
||||
});
|
||||
|
||||
it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => {
|
||||
const ordo = resolveOrdo('prime', '2027-03-19'); // St. Joseph, Duplex I.
|
||||
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,3 +261,16 @@ describe('resolveOrdo("vespers", ...)', () => {
|
||||
expect(versicle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
it('has no Gloria on a ferial Passiontide day, has one on an ordinary date', () => {
|
||||
const passiontideResponsory = resolveOrdo('vespers', '2027-03-15').parts.find((p) => p.kind === 'responsory');
|
||||
const ordinaryResponsory = resolveOrdo('vespers', '2026-08-20').parts.find((p) => p.kind === 'responsory');
|
||||
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
|
||||
'Glória Patri',
|
||||
);
|
||||
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user