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', () => {