From 143244203d8bc366591b455c6cdaaf1ef7f5e5b4 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 12 Aug 2026 07:44:39 -0400 Subject: [PATCH] Fix octave-day office content: use the octave's own feast, not the plain ferial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real, multi-part bug found by looking closely at today's rendering (day 3 of St. Lawrence's octave, St. Clare commemorated): the psalmody antiphons, hymn, chapter/responsory/versicle, Benedictus antiphon, and day collect were all keyed off `day.winner` directly, which stays the plain temporal identity (post-pentecost-11) throughout an octave -- St. Lawrence's own already-authored psalmody override and collect never engaged on any day but his actual feast day. Live-verified (Tridentine 1910, 2026-08-12) that the whole office on an ordinary octave day actually comes from the octave's own feast ("{ex Commune aut Festo}" / "{ex Proprio Sanctorum}"), with a *weaker* commemorated saint (Clare) getting a separate Ant+V/R+collect block of her own alongside it. Fixed with a new resolveOfficeWinner(day) in hours/resolve-common.ts, used everywhere getDayCollect/getDayCollects/getBenedictusAntiphon/ getPsalmodyOverrideFor used to read day.winner directly. Deliberately gated on temporalCategory === 'ordinary-feria' (the day has no standing of its own) rather than "any active octave" -- the Christmas Octave's own stacked octaves are the live-verified counterexample where the *temporal* day keeps the office instead, each octave becoming its own separate commemoration block (not yet modeled, flagged in TODO.md). Also fixes the ferial Preces gate (added in the previous commit) to exclude active octaves the same way the suffrages already do -- live-verified "Preces Feriales{omittitur}" on the same date. getDayCollects now renders a sanctoral commemoration as a real Ant+V/R+collect bundle when authored (${propers}-commemoration.yml), not just a bare, unlabeled collect -- the old rendering was indeed the confusing "PRAYER / (translation pending)" block spotted today, which was St. Clare's placeholder with no indication whose it was or why. Authored her real commemoration bundle and standalone collect from the same live query rather than leaving it pending. One correction to a guess floated mid-session: the Benedictus antiphon does NOT go to the commemorated saint (Clare) -- live-verified it stays with the octave's own feast (Lawrence's "In cratícula"), same as everything else in the primary office; only the separate commemoration block is Clare's. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 35 ++++- src/data/calendar/saints/st-clare.yml | 7 +- src/data/propers/common/st-clare-collect.yml | 22 +++ .../propers/common/st-clare-commemoration.yml | 31 +++++ src/hours/lauds.ts | 59 ++++---- src/hours/resolve-common.ts | 126 ++++++++++++++---- tests/hours/lauds-psalmody-overrides.test.ts | 24 +++- tests/hours/lauds.test.ts | 32 +++-- 8 files changed, 268 insertions(+), 68 deletions(-) create mode 100644 src/data/propers/common/st-clare-collect.yml create mode 100644 src/data/propers/common/st-clare-commemoration.yml diff --git a/TODO.md b/TODO.md index 1c90100..ac3d5f5 100644 --- a/TODO.md +++ b/TODO.md @@ -80,12 +80,37 @@ some by-date saint win there, e.g. St. Mark on Easter V some years, "Our Lady of the Snows" — not modeled in this app at all — landing on Post-Pentecost XI whenever the date lines up with Aug 5). -### Octave collects +### Octave content — mostly fixed (2026-08), one real gap left -`getDayCollects` skips a `kind: 'octave'` commemoration entirely — no -collect has ever been authored for an octave (the octave-reading pull only -ever sourced Matins content). Every octave commemoration currently -contributes zero text to Lauds'/Vespers' collect sequence. +Was: `getDayCollects` skipped a `kind: 'octave'` commemoration entirely, +and every other office part (psalmody, hymn, chapter, Benedictus +antiphon) stayed keyed off the plain temporal day underneath the octave +— so an ordinary day within e.g. St. Lawrence's own octave showed nothing +of his at all, not even a label beyond the day-label line. Root cause: +found live (Tridentine 1910, 2026-08-12) that on a day like this, the +*whole* office — chapter/responsory/hymn/versicle, psalm antiphons, +Benedictus antiphon, day collect — comes from the octave's own feast, not +the plain temporal day; only a *weaker* commemorated saint on top of that +(St. Clare, that same date) gets a separate Ant+V/R+collect block of her +own. Fixed via `hours/resolve-common.ts`'s `resolveOfficeWinner`, used +everywhere `getDayCollect`/`getDayCollects`/`getBenedictusAntiphon`/ +`getPsalmodyOverrideFor` used to key off `day.winner` directly — and +`getDayCollects` now renders a real Ant+V/R+collect bundle for a +sanctoral commemoration (`${propers}-commemoration.yml`, e.g. +st-clare-commemoration.yml) when authored, falling back to a *labeled* +bare collect (or a labeled "missing" placeholder) rather than the old +unlabeled one that just read "PRAYER … (translation pending)" with no +indication whose it was. + +Real remaining gap: `resolveOfficeWinner` deliberately only kicks in when +`temporalCategory === 'ordinary-feria'` (the octave day has no standing +of its own) — the live-verified counterexample is the Christmas Octave's +own stack (Christmas + Stephen + John + Holy Innocents, e.g. on Dec 30, +`privileged-feria-minor`): the *temporal* day itself keeps the office +there ("Dominica Infra Octavam Nativitatis", `{ex Proprio de Tempore}`), +and each of the four octaves gets its own separate "Commemoratio Octavæ +…" Ant+V/R+collect block instead — not yet modeled; those four octave +commemorations still contribute nothing but a name in the day label. ## Benedictus antiphons for every sanctoral/temporal entry (in progress) diff --git a/src/data/calendar/saints/st-clare.yml b/src/data/calendar/saints/st-clare.yml index 5b30244..71685c4 100644 --- a/src/data/calendar/saints/st-clare.yml +++ b/src/data/calendar/saints/st-clare.yml @@ -8,10 +8,11 @@ # rubrica ^Monastica" branch, Simplex) rather than the later, higher # Roman/secular Duplex given alongside it in the same file -- consistent # with this app's general Monastic-track sourcing discipline. -# No unique collect/antiphon authored yet -- one of the remaining -# `propers: null` saints (see TODO.md). +# Own collect + commemoration bundle (antiphon + versicle/response + +# collect) authored from that same live query -- see +# st-clare-collect.yml and st-clare-commemoration.yml. id: st-clare name: "St. Clare, Virgin" rank: simplex common: common-of-a-virgin -propers: null +propers: "st-clare" diff --git a/src/data/propers/common/st-clare-collect.yml b/src/data/propers/common/st-clare-collect.yml new file mode 100644 index 0000000..56473bf --- /dev/null +++ b/src/data/propers/common/st-clare-collect.yml @@ -0,0 +1,22 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617) — read +# directly from the reference engine's own real-date output, 2026-08-12 +# (the "Commemoratio S. Claræ Virginis" block within St. Lawrence's own +# octave -- Clare never wins her own day outright in this app's calendar, +# since Lawrence's octave, Aug 10-17, always covers Aug 12 regardless of +# weekday; see st-clare-commemoration.yml for the fuller Ant+V/R+Oratio +# form actually used for her commemoration). +id: st-clare-collect +text: + la: | + Orémus. + Exáudi nos, Deus, salutáris noster: ut sicut de beátæ Claræ Vírginis tuæ festivitáte gaudémus; ita piæ devotiónis erudiámur afféctu. + Per Dóminum nostrum Jesum Christum, Fílium tuum: qui tecum vivit et regnat in unitáte Spíritus Sancti, Deus, per ómnia sǽcula sæculórum. + ℟. Amen. + en: | + Let us pray. + Graciously hear us, O God of our salvation; that as we rejoice in the festivity of blessed Clare thy Virgin, we may be instructed in the affections of a loving devotion. + Through Jesus Christ, thy Son our Lord, Who liveth and reigneth with thee, in the unity of the Holy Ghost, God, world without end. + ℟. Amen. +status: + la: verified + en: verified diff --git a/src/data/propers/common/st-clare-commemoration.yml b/src/data/propers/common/st-clare-commemoration.yml new file mode 100644 index 0000000..ff3281a --- /dev/null +++ b/src/data/propers/common/st-clare-commemoration.yml @@ -0,0 +1,31 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds +# live query, 2026-08-12 -- the "Commemoratio S. Claræ Virginis" block +# that follows St. Lawrence's own collect on any ordinary day within his +# octave (she's never the day's own winner in this app's calendar, always +# commemorated -- see st-clare.yml). Antiphon + versicle/response + full +# collect (with its own doxology and Amen, since this stands alone rather +# than chaining into another suffrage the way lauds-suffrage-*.yml's do) +# -- see hours/resolve-common.ts's getDayCollects for how a sanctoral +# commemoration renders this whole bundle, falling back to just the bare +# collect (st-clare-collect.yml) for a saint without one of these authored. +id: st-clare-commemoration +text: + la: | + Ant. Símile est regnum cælórum * hómini negotiatóri quærénti bonas margarítas: invénta una pretiósa, dedit ómnia sua, et comparávit eam. + V. Diffúsa est grátia in lábiis tuis. + R. Proptérea benedíxit te Deus in ætérnum. + Orémus. + Exáudi nos, Deus, salutáris noster: ut sicut de beátæ Claræ Vírginis tuæ festivitáte gaudémus; ita piæ devotiónis erudiámur afféctu. + Per Dóminum nostrum Jesum Christum, Fílium tuum: qui tecum vivit et regnat in unitáte Spíritus Sancti, Deus, per ómnia sǽcula sæculórum. + ℟. Amen. + en: | + Ant. The kingdom of heaven is like unto a merchantman * seeking goodly pearls, who, when he had found one pearl of great price, went and sold all that he had, and bought it. + V. Grace is poured into thy lips. + R. Therefore God hath blessed thee for ever. + Let us pray. + Graciously hear us, O God of our salvation; that as we rejoice in the festivity of blessed Clare thy Virgin, we may be instructed in the affections of a loving devotion. + Through Jesus Christ, thy Son our Lord, Who liveth and reigneth with thee, in the unity of the Holy Ghost, God, world without end. + ℟. Amen. +status: + la: verified + en: verified diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index eda28b4..221fa31 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -9,7 +9,14 @@ import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-ps import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { isDoubleOrHigher } from './antiphon'; -import { resolveCommon, getDayCollects, getBenedictusAntiphon, splitNamedAntiphon } from './resolve-common'; +import { + resolveCommon, + getDayCollects, + getBenedictusAntiphon, + splitNamedAntiphon, + resolveOfficeWinner, + ALWAYS_OVERRIDE_TEMPORAL_IDS, +} from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; @@ -35,28 +42,28 @@ interface PsalmodyBlock { } const laudsAntiphons = laudsAntiphonsData as Record; -// Named temporal winners with real standing of their own, unconditionally -// override-eligible (no rank check — they aren't "a saint" competing with -// the weekday default the way one is, and neither carries a FeastClass at -// all since DayWinner's temporal variant never does). Grows one entry at -// a time as each is built, same as everywhere else in this codebase. -const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-the-king']); - /** * A duplex-majus+ saint's own proper psalmody (per-feast, not per-Common — - * explicit choice), or one of the named temporal feasts above. Falls back - * to `undefined` — the plain weekday default — for a duplex-majus+ saint - * with no override authored yet, same honest incremental-content - * convention as everywhere else in this codebase; it's not gated behind - * whether content exists, just behind whether it's *eligible* to override - * at all. + * explicit choice), or one of the named temporal feasts in + * ALWAYS_OVERRIDE_TEMPORAL_IDS. Falls back to `undefined` — the plain + * weekday default — for a duplex-majus+ saint with no override authored + * yet, same honest incremental-content convention as everywhere else in + * this codebase; it's not gated behind whether content exists, just + * behind whether it's *eligible* to override at all. + * + * Checks resolveOfficeWinner(day), not the raw `day.winner` — on an + * octave day this is the octave's own feast (e.g. St. Lawrence, days 2-8 + * of his own octave), which is exactly what should supply the psalmody + * override there too, live-verified alongside the collect/Benedictus- + * antiphon fix (see resolve-common.ts's resolveOfficeWinner doc comment). */ function getPsalmodyOverrideFor(day: LiturgicalDay): LaudsPsalmodyOverride | undefined { - if (day.winner.kind === 'temporal' && ALWAYS_OVERRIDE_TEMPORAL_IDS.has(day.winner.id)) { - return getLaudsPsalmodyOverride(day.winner.id); + const winner = resolveOfficeWinner(day); + if (winner.kind === 'temporal' && ALWAYS_OVERRIDE_TEMPORAL_IDS.has(winner.id)) { + return getLaudsPsalmodyOverride(winner.id); } - if (day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-majus')) { - return getLaudsPsalmodyOverride(day.winner.id); + if (winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus')) { + return getLaudsPsalmodyOverride(winner.id); } return undefined; } @@ -130,7 +137,7 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; const opening = (antiphon: BilingualText) => { const { incipit, full } = splitNamedAntiphon(antiphon); - return isDoubleOrHigher(day.winner) ? full : incipit; + return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; }; const parts: ResolvedPart[] = [ { @@ -226,7 +233,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { return resolveOffice(day); case 'benedictus': { const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day).text); - const opening = isDoubleOrHigher(day.winner) ? full : incipit; + const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return [ { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, { kind: 'antiphon', text: full }, @@ -235,7 +242,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'preces': return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; case 'day-collects': - return getDayCollects(day).map((text) => ({ kind: 'prayer' as const, text })); + return getDayCollects(day); case 'suffrages': { // isDoubleOrHigher only ever looks at a sanctoral rank — Christ the // King is modeled as a named temporal winner (see @@ -310,10 +317,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // temporal identity like Marian Saturday or Christ the King (which // aren't Sunday or a sanctoral winner either, but still aren't a // bare ferial office -- same reasoning as the Cross suffrage's own - // gate just above). + // gate just above). Also excludes any day within an active octave + // -- live-verified (Tridentine 1910, day 3 of St. Lawrence's own + // octave): "Preces Feriales{omittitur}", same exclusion the + // suffrages already have, for the same reason -- an octave day + // isn't a bare ferial office even though nothing else is winning + // it outright. const isVigil = day.winner.kind === 'sanctoral' && day.winner.rank === 'vigil'; const isBareFeria = day.winner.kind === 'temporal' && !getTemporalFeastRecord(day.winner.id); - const isFerialOrVigil = day.weekday !== 'sunday' && (isVigil || isBareFeria); + const isWithinAnOctave = activeOctavesFor(day.date).length > 0; + const isFerialOrVigil = day.weekday !== 'sunday' && !isWithinAnOctave && (isVigil || isBareFeria); const id = isFerialOrVigil ? 'lauds-preces-feriales' : 'lauds-short-litany'; return [{ kind: 'preces', text: resolveCommon(id) }]; } diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index b7fbbb9..f595dec 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -1,8 +1,9 @@ -import type { ResolvedText } from './types'; -import type { LiturgicalDay } from '../calendar/types'; +import type { ResolvedPart, ResolvedText } from './types'; +import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types'; import type { ProperText } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; +import { activeOctavesFor } from '../calendar'; import { splitAntiphon } from './antiphon'; function toResolvedText(proper: ProperText): ResolvedText { @@ -15,6 +16,59 @@ export function resolveCommon(id: string): ResolvedText { return toResolvedText(getCommonProper(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 +// same "does this temporal identity carry its own real office" question. +export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-the-king']); + +/** + * Which identity's own propers actually supply the office's content + * (collect, Benedictus antiphon, psalmody override) — usually just + * `day.winner`, but *not* on a day within an active octave where the + * temporal day itself has no real standing of its own (`ordinary-feria`) + * and the winner stayed temporal — an ordinary day within St. Lawrence's + * own octave, e.g.: live-verified (Tridentine 1910, 2026-08-12) that the + * chapter/responsory/hymn/versicle, psalms' antiphons, Benedictus + * antiphon, and day collect all come from the octave's own feast + * ("{ex Commune aut Festo}" / "{ex Proprio Sanctorum}"), not from the + * plain temporal day underneath it. + * + * Deliberately gated on `ordinary-feria` specifically, not just "an + * octave is active": the Christmas Octave's own stacked octaves + * (Christmas + Stephen + John + Holy Innocents, e.g. on Dec 30) are the + * live-verified counterexample — that day's own temporal identity + * ("Dominica Infra Octavam Nativitatis", `privileged-feria-minor`) is + * itself a real, named standing, and *keeps* the office + * ("{ex Proprio de Tempore}"); the four octaves there each become their + * own separate "Commemoratio Octavæ ..." block instead (not yet modeled + * — see TODO.md), rather than any one of them taking over content the + * way Lawrence's octave does. The dividing line is real standing, not + * merely "is an octave active." + * + * `day.winner`/`day.commemorations` themselves stay exactly as + * calendar/index.ts computed them either way — this is purely a + * content-lookup detail, not a recomputation of who "wins." Picks the + * oldest-started active octave with an authored saint record + * (activeOctavesFor's own ordering) — matches calendar/day-label.ts's + * same choice for the display label, for the same reason. + */ +export function resolveOfficeWinner(day: LiturgicalDay): DayWinner { + if (day.winner.kind === 'sanctoral' || ALWAYS_OVERRIDE_TEMPORAL_IDS.has(day.winner.id)) { + return day.winner; + } + if (day.temporalCategory !== 'ordinary-feria') { + return day.winner; + } + for (const octave of activeOctavesFor(day.date)) { + const saint = getSaintRecord(octave.id); + if (saint) { + return { kind: 'sanctoral', id: saint.id, name: saint.name, rank: saint.rank }; + } + } + return day.winner; +} + /** * The day's own collect — real for the vast majority of days (a temporal * winner always resolves, since all 52 Sunday collects are authored and @@ -23,18 +77,43 @@ export function resolveCommon(id: string): ResolvedText { * that saint's own propers are authored (`saints/.yml`'s `propers` * field is still `null` for all of them today) — same "resolve as * pending" convention as everywhere else, not a special case to handle. + * Uses resolveOfficeWinner, not the raw `day.winner` — see its own doc + * comment for why those two differ on an octave day. */ export function getDayCollect(day: LiturgicalDay): ResolvedText { - if (day.winner.kind === 'temporal') { - return toResolvedText(getTemporalProper(`${day.winner.id}-collect`)); + const winner = resolveOfficeWinner(day); + if (winner.kind === 'temporal') { + return toResolvedText(getTemporalProper(`${winner.id}-collect`)); } - const saint = getSaintRecord(day.winner.id); + const saint = getSaintRecord(winner.id); if (saint?.propers) { return resolveCommon(`${saint.propers}-collect`); } return { text: {}, status: { la: 'missing', en: 'missing' } }; } +/** A sanctoral commemoration's own rendering: the fuller Ant+V/R+collect + * bundle (`${propers}-commemoration`, e.g. st-clare-commemoration.yml) + * when authored — live-verified this is the real shape a commemoration + * takes, not a bare collect — falling back to just the collect alone + * (still labeled, unlike the old unlabeled bare-collect rendering this + * replaces) when only that's been authored, and to an honestly labeled + * "missing" block when neither has. Always labeled ("Commemoration of St. + * X") so an unauthored one reads as "this saint's commemoration isn't + * written up yet," not as a mystery blank prayer. */ +function sanctoralCommemorationPart(commemoration: Extract): ResolvedPart { + const label = `Commemoration of ${commemoration.name}`; + const saint = getSaintRecord(commemoration.id); + if (saint?.propers) { + const combined = resolveCommon(`${saint.propers}-commemoration`); + if (combined.status.la !== 'missing' || combined.status.en !== 'missing') { + return { kind: 'preces', text: combined, label }; + } + return { kind: 'preces', text: resolveCommon(`${saint.propers}-collect`), label }; + } + return { kind: 'preces', text: { text: {}, status: { la: 'missing', en: 'missing' } }, label }; +} + /** * The day's own collect, plus one more per commemoration (calendar/ * types.ts's LiturgicalDay.commemorations) — Lauds/Vespers say all of @@ -49,27 +128,20 @@ export function getDayCollect(day: LiturgicalDay): ResolvedText { * stripping that back out per-collect to chain them properly would need * text surgery this doesn't attempt — so on a commemorated day, each * collect here renders as its own complete, separate block instead. An - * octave commemoration never contributes a collect (none authored — the - * octave content pull only ever sourced Matins readings, see propers/ - * octave-readings.ts), so those are silently skipped, same as a - * not-yet-authored saint's own collect resolves to "missing" rather than - * throwing. + * octave commemoration never contributes anything of its own here — once + * resolveOfficeWinner is in play, its content is already the primary + * collect above, so a separate entry would just be a redundant repeat. */ -export function getDayCollects(day: LiturgicalDay): ResolvedText[] { - const collects = [getDayCollect(day)]; +export function getDayCollects(day: LiturgicalDay): ResolvedPart[] { + const parts: ResolvedPart[] = [{ kind: 'prayer', text: getDayCollect(day) }]; for (const commemoration of day.commemorations) { if (commemoration.kind === 'temporal') { - collects.push(toResolvedText(getTemporalProper(`${commemoration.id}-collect`))); + parts.push({ kind: 'prayer', text: toResolvedText(getTemporalProper(`${commemoration.id}-collect`)) }); } else if (commemoration.kind === 'sanctoral') { - const saint = getSaintRecord(commemoration.id); - collects.push( - saint?.propers - ? resolveCommon(`${saint.propers}-collect`) - : { text: {}, status: { la: 'missing', en: 'missing' } }, - ); + parts.push(sanctoralCommemorationPart(commemoration)); } } - return collects; + return parts; } /** @@ -78,13 +150,19 @@ export function getDayCollects(day: LiturgicalDay): ResolvedText[] { * pull, sourced from each saint's own raw [Ant 1] — see e.g. * st-lawrence-antiphon.yml) for a sanctoral winner, `${id}-benedictus- * antiphon` (not authored yet for any temporal id — resolves "missing", - * same pending convention as everywhere else) for a temporal one. + * same pending convention as everywhere else) for a temporal one. Uses + * resolveOfficeWinner, not the raw `day.winner` — on an octave day this + * is the octave's own feast's antiphon (live-verified: St. Lawrence's + * "In cratícula", not the commemorated St. Clare's, and not the plain + * temporal day's), not a *commemorated* saint's, which is a separate, + * weaker standing (see getDayCollects/sanctoralCommemorationPart). */ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText { - if (day.winner.kind === 'temporal') { - return toResolvedText(getTemporalProper(`${day.winner.id}-benedictus-antiphon`)); + const winner = resolveOfficeWinner(day); + if (winner.kind === 'temporal') { + return toResolvedText(getTemporalProper(`${winner.id}-benedictus-antiphon`)); } - const saint = getSaintRecord(day.winner.id); + const saint = getSaintRecord(winner.id); if (saint?.propers) { return resolveCommon(`${saint.propers}-antiphon`); } diff --git a/tests/hours/lauds-psalmody-overrides.test.ts b/tests/hours/lauds-psalmody-overrides.test.ts index aeb4ee5..c22c1c6 100644 --- a/tests/hours/lauds-psalmody-overrides.test.ts +++ b/tests/hours/lauds-psalmody-overrides.test.ts @@ -73,13 +73,27 @@ describe('Lauds psalmody override (duplex-majus+ sanctoral, and Marian Saturday) 'He hath made us', ); - // Two extra collects: the commemorated Sunday's own, and the Simplex - // saint already commemorated under that Sunday before Christ the King - // displaced it too -- see tests/calendar/christ-the-king.test.ts. + // One extra collect (the commemorated Sunday's own, still an + // unlabeled 'prayer' -- a temporal commemoration, not a sanctoral + // one) plus the Simplex saint already commemorated under that Sunday + // before Christ the King displaced it too, now a labeled 'preces' + // block rather than a third bare 'prayer' -- see + // tests/calendar/christ-the-king.test.ts. const collects = ordo.parts.filter((p) => p.kind === 'prayer'); - expect(collects.length).toBe(3); + expect(collects.length).toBe(2); + const sanctoralCommemoration = ordo.parts.find( + (p) => p.kind === 'preces' && p.label?.startsWith('Commemoration of'), + ); + expect(sanctoralCommemoration).toBeDefined(); - const suffrages = ordo.parts.filter((p) => p.kind === 'preces' && p.label && p.label !== 'Salve Regina'); + const SUFFRAGE_LABELS = new Set([ + 'Of the Holy Cross', + 'Of the Blessed Virgin Mary', + 'Of St. Joseph', + 'Of the Holy Apostles Peter and Paul', + 'For Peace', + ]); + const suffrages = ordo.parts.filter((p) => p.kind === 'preces' && p.label && SUFFRAGE_LABELS.has(p.label)); expect(suffrages).toEqual([]); }); }); diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index 934f11b..6baddc2 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -194,20 +194,36 @@ describe('resolveOrdo("lauds", ...)', () => { it('resolves both the winners collect and a commemorated saints collect on a day with a real commemoration', () => { // 2025-11-30: Advent I (the winner) with St. Andrew, Duplex II. classis, // commemorated alongside it (see tests/calendar/day-label.test.ts) -- - // an octave commemoration (like LAWRENCE_OCTAVE_DAY's) never - // contributes a collect (none authored yet), so this needs a real - // sanctoral/temporal commemoration instead to exercise getDayCollects - // pushing a second entry. + // he has only a bare collect authored (no st-andrew-commemoration.yml + // Ant+V/R bundle yet), so this renders as a labeled 'preces' block + // (see resolve-common.ts's sanctoralCommemorationPart), not a second + // unlabeled 'prayer' the way it used to. const ordo = resolveOrdo('lauds', '2025-11-30'); const collects = ordo.parts.filter((p) => p.kind === 'prayer'); - expect(collects.length).toBe(2); - expect(collects[1]?.kind === 'prayer' ? collects[1].text.text.en : undefined).toContain('Andrew'); + expect(collects.length).toBe(1); + const andrewCommemoration = ordo.parts.find((p) => p.kind === 'preces' && p.label?.includes('Andrew')); + expect(andrewCommemoration?.kind === 'preces' ? andrewCommemoration.text.text.en : undefined).toContain( + 'Andrew', + ); }); - it("skips a straight octave commemoration for collect purposes (no octave collect authored)", () => { - const ordo = resolveOrdo('lauds', LAWRENCE_OCTAVE_DAY); + it("gives St. Lawrence's octave day 2 the full Lawrence office (per resolveOfficeWinner), no Clare commemoration since she's only assigned Aug 12 specifically", () => { + const ordo = resolveOrdo('lauds', LAWRENCE_OCTAVE_DAY); // 2026-08-11, day 2 const collects = ordo.parts.filter((p) => p.kind === 'prayer'); expect(collects.length).toBe(1); + expect(collects[0]?.kind === 'prayer' ? collects[0].text.text.en : undefined).toContain('Lawrence'); + expect(ordo.parts.some((p) => p.kind === 'preces' && p.label?.includes('Clare'))).toBe(false); + }); + + it("gives St. Lawrence's octave day 3 (Aug 12) St. Clare's own commemoration bundle alongside Lawrence's own office", () => { + const ordo = resolveOrdo('lauds', '2026-08-12'); + const collects = ordo.parts.filter((p) => p.kind === 'prayer'); + expect(collects.length).toBe(1); + expect(collects[0]?.kind === 'prayer' ? collects[0].text.text.en : undefined).toContain('Lawrence'); + const clareCommemoration = ordo.parts.find((p) => p.kind === 'preces' && p.label?.includes('Clare')); + expect(clareCommemoration?.kind === 'preces' ? clareCommemoration.text.text.en : undefined).toContain( + 'kingdom of heaven', + ); }); it('includes all five suffrages, in order, on an ordinary ferial day -- Cross from Tridentine 1906/1910, the other four from Monastic 1617', () => {