calendar: model Our Lady's Saturday as a real occurrence outcome
Deploy / deploy (push) Successful in 49s
Deploy / deploy (push) Successful in 49s
On a free Saturday (nothing privileged already claims it, no active octave), the day's own identity is now "Our Lady's Saturday" rather than an anonymous ordinary-feria -- mirrors privileged-feria-minor exactly, per direct instruction: Semiduplex-or-higher still wins outright (Marian Saturday doesn't apply at all); below that (Simplex, and Vigil, both under semiduplex on the FeastClass scale) loses and is commemorated instead. Layered additively after decideOccurrence, same shape as applyOctaves -- never touches decideOccurrence's own rules. Needed a real winner identity (not just the plain temporal feria id) so day-label/antiphon/collect sourcing can recognize it -- added a temporal-feasts record purely for that, and taught getDayLabel to check it for any named temporal winner (a small, free improvement for Christmas/Pentecost's own labels too, previously unhandled). hours: build the duplex-majus+ Lauds psalmody override (per-feast) lauds-psalmody no longer unconditionally uses the plain weekday default: a duplex-majus-or-higher sanctoral winner, or Our Lady's Saturday (unconditional, no rank threshold -- it isn't competing with the weekday default the way a saint is), now substitutes its own proper psalm groups/antiphons/canticle. Per-feast, not per-Common, per direct instruction, even though most duplex-majus+ saints across the year don't have one authored yet and fall back to the plain weekday default until they do. Content for the worked example (St. Lawrence's own day) surfaced a real bug while sourcing it: st-lawrence-antiphon.yml had been read from the Roman/secular rite's own [Ant 1], not Monastic 1617's actual Benedictus antiphon for that day -- corrected from a fresh live query. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import type { LiturgicalDay } from './types';
|
||||
import { easterSunday } from './easter';
|
||||
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
|
||||
import { addDays, daysBetween, toIsoDate } from './date-math';
|
||||
import { getTemporalFeastRecord } from './temporal-feasts';
|
||||
|
||||
function capitalize(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
@@ -136,6 +137,16 @@ export function getDayLabel(day: LiturgicalDay): string {
|
||||
return day.winner.name;
|
||||
}
|
||||
|
||||
// A named temporal feast (Christmas, Pentecost, Marian Saturday, ...)
|
||||
// shows its own name rather than the ordinal week label — same
|
||||
// "winner displaces, doesn't combine" rule a sanctoral winner gets
|
||||
// above. Most temporal ids don't have a record at all (see
|
||||
// temporal-feasts.ts) and fall through to the ordinal label as before.
|
||||
const namedFeast = getTemporalFeastRecord(day.winner.id);
|
||||
if (namedFeast) {
|
||||
return namedFeast.name;
|
||||
}
|
||||
|
||||
const temporal = temporalLabel(day);
|
||||
const commemorated = day.commemorations.find((c) => c.kind === 'sanctoral');
|
||||
return commemorated ? `${commemorated.name} — ${temporal}` : temporal;
|
||||
|
||||
@@ -101,10 +101,56 @@ export function resolveDay(isoDate: string): LiturgicalDay {
|
||||
}
|
||||
|
||||
winner = applyOctaves(isoDate, winner, commemorations);
|
||||
winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
|
||||
|
||||
return { date: isoDate, weekday, season, temporalCategory, winner, commemorations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layered on top of everything above, same spirit as applyOctaves: never
|
||||
* changes decideOccurrence's own rules, just relabels the result on a free
|
||||
* Saturday. Real standing of its own (per direct instruction, mirrors
|
||||
* privileged-feria-minor exactly) — a Semiduplex-or-higher saint still
|
||||
* wins outright and this never applies; a Simplex (or Vigil) saint loses
|
||||
* to it and is commemorated instead, same as an Advent feria would demote
|
||||
* one. `ordinary-feria` is the only category this touches — every
|
||||
* privileged season/day (Ember Saturdays, Advent, Lent, ...) already has
|
||||
* its own real standing and keeps it untouched.
|
||||
*
|
||||
* The relabel (not just leaving `winner` as the plain temporal id) is
|
||||
* what lets getDayLabel/getBenedictusAntiphon/getDayCollects/getLaudsPsalmodyOverride
|
||||
* all recognize this day as "Our Lady's Saturday" rather than an
|
||||
* anonymous feria — see data/calendar/temporal-feasts/marian-saturday.yml.
|
||||
*
|
||||
* Also gated on no octave being active: `ordinary-feria` isn't only "a
|
||||
* genuinely free Saturday" — Pentecost's own Ember Saturday is
|
||||
* deliberately classified this way too (see data/calendar/temporal-
|
||||
* categories.yml), specifically so the octave layer above can reach it.
|
||||
* Found via a real regression: without this gate, Pentecost's Ember
|
||||
* Saturday got relabeled "Our Lady's Saturday" right out from under the
|
||||
* octave commemoration that's supposed to govern it.
|
||||
*/
|
||||
function applyMarianSaturday(
|
||||
isoDate: string,
|
||||
weekday: ReturnType<typeof weekdayOf>,
|
||||
temporalCategory: TemporalCategory,
|
||||
winner: DayWinner,
|
||||
commemorations: Commemoration[],
|
||||
): DayWinner {
|
||||
if (weekday !== 'saturday' || temporalCategory !== 'ordinary-feria' || activeOctavesFor(isoDate).length > 0) {
|
||||
return winner;
|
||||
}
|
||||
const MARIAN_SATURDAY: DayWinner = { kind: 'temporal', id: 'marian-saturday' };
|
||||
if (winner.kind === 'temporal') {
|
||||
return MARIAN_SATURDAY;
|
||||
}
|
||||
if (isAtLeast(winner.rank, 'semiduplex')) {
|
||||
return winner;
|
||||
}
|
||||
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
|
||||
return MARIAN_SATURDAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layered on top of everything above, not part of it: an octave doesn't
|
||||
* change how a single day's own precedence contest is decided, it just
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Not a real calendar date the way Christmas/Pentecost are — this record
|
||||
# exists purely so getDayLabel (and getBenedictusAntiphon/getDayCollects,
|
||||
# via the same {kind:'temporal', id:'marian-saturday'} winner identity)
|
||||
# have a real name to show. See calendar/index.ts's applyMarianSaturday
|
||||
# for how a day actually becomes this.
|
||||
id: marian-saturday
|
||||
name: "Our Lady's Saturday"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
|
||||
# live query, Sanctæ Mariæ Sabbato (2026-09-05). Applies unconditionally
|
||||
# whenever Marian Saturday wins the day (see calendar/index.ts's
|
||||
# applyMarianSaturday) -- not gated by the duplex-majus+ rank threshold
|
||||
# the sanctoral overrides use, since Marian Saturday isn't itself ranked
|
||||
# in competition with the weekday default the way a saint is.
|
||||
id: marian-saturday
|
||||
groups:
|
||||
- psalms: [92]
|
||||
antiphon:
|
||||
la: "Dum esset Rex * in accúbitu suo, nardus mea dedit odórem suavitátis."
|
||||
en: "While the King was at his repose, * my spikenard sent forth the odour thereof."
|
||||
- psalms: [99]
|
||||
antiphon:
|
||||
la: "Læva ejus * sub cápite meo, et déxtera illíus amplexábitur me."
|
||||
en: "His left hand is under my head, * and his right hand shall embrace me."
|
||||
- psalms: [62]
|
||||
antiphon:
|
||||
la: "Nigra sum, * sed formósa, fíliæ Jerúsalem; ídeo diléxit me Rex, et introdúxit me in cubículum suum."
|
||||
en: "I am black, * but beautiful, O ye daughters of Jerusalem; therefore hath the King loved me, and brought me into his chamber."
|
||||
canticle:
|
||||
id: canticum-trium-puerorum
|
||||
antiphon:
|
||||
la: "Jam hiems tránsiit, * imber ábiit et recéssit: surge, amíca mea, et veni."
|
||||
en: "For winter is now past, * the rain is over and gone: arise, my love, and come."
|
||||
laudate:
|
||||
antiphon:
|
||||
la: "Speciósa * facta es et suávis in delíciis tuis, sancta Dei Génitrix."
|
||||
en: "Beautiful and sweet * thou art become in thy delights, O holy Mother of God."
|
||||
@@ -0,0 +1,31 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
|
||||
# live query, St. Lawrence's own day (2026-08-10, Duplex II. classis --
|
||||
# above the duplex-majus threshold this override mechanism is gated at).
|
||||
# Worked example proving the mechanism end to end; the psalm numbers
|
||||
# happen to be the same 92/99/62 + the Three Young Men that most Common-
|
||||
# of-Martyrs-shaped feasts reuse, but the antiphons are St. Lawrence's own
|
||||
# proper text, not the plain Commune's — that's the whole point of
|
||||
# authoring per-feast rather than per-Common (explicit choice).
|
||||
id: st-lawrence
|
||||
groups:
|
||||
- psalms: [92]
|
||||
antiphon:
|
||||
la: "Lauréntius * ingréssus est Martyr, et conféssus est nomen Dómini Jesu Christi."
|
||||
en: "Lawrence went in to be a martyr, * and acknowledged the name of our Lord Jesus Christ."
|
||||
- psalms: [99]
|
||||
antiphon:
|
||||
la: "Lauréntius * bonum opus operátus est, qui per signum crucis cæcos illuminávit."
|
||||
en: "Lawrence wrought a good work, * in that with the sign of the Cross he gave sight to the blind."
|
||||
- psalms: [62]
|
||||
antiphon:
|
||||
la: "Adhǽsit * ánima mea post te, quia caro mea igne cremáta est pro te, Deus meus."
|
||||
en: "O my God, my soul cleaveth * fast after thee, for my flesh hath been burnt with fire for thy sake."
|
||||
canticle:
|
||||
id: canticum-trium-puerorum
|
||||
antiphon:
|
||||
la: "Misit Dóminus * Angelum suum, et liberávit me de médio ignis, et non sum æstuátus."
|
||||
en: "The Lord hath sent His Angel, * and hath delivered me out of the midst of the fire, so that I am not scorched."
|
||||
laudate:
|
||||
antiphon:
|
||||
la: "Beátus Lauréntius * orábat, dicens: Grátias tibi ago, Dómine, quia jánuas tuas íngredi mérui."
|
||||
en: "The blessed Lawrence prayed * and said: I give thee thanks, O Lord, that Thou hast made me worthy to enter within thy gates."
|
||||
@@ -1,9 +1,14 @@
|
||||
# Verified against Divinum Officium (Latin/English Sancti/08-10.txt's own
|
||||
# [Ant 1] — the Lauds-hour antiphon, not Terce's.
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
|
||||
# live query, St. Lawrence's own day (2026-08-10) -- the Benedictus
|
||||
# antiphon. Corrects an earlier version of this file, which had been
|
||||
# sourced from the Roman/secular rite's Sancti/08-10.txt's own [Ant 1]
|
||||
# instead: real, wrong text for this project's Monastic base (found while
|
||||
# building the duplex-majus+ Lauds psalmody override, which needed a real
|
||||
# worked example and so needed this checked directly rather than assumed).
|
||||
id: st-lawrence-antiphon
|
||||
text:
|
||||
la: "Levíta Lauréntius * bonum opus operátus est, qui per signum crucis cæcos illuminávit, et thesáuros Ecclésiæ dedit paupéribus."
|
||||
en: "Lawrence the Deacon * performed a pious act by giving sight to the blind through the Sign of the Cross, and by bestowing on the poor the riches of the Church."
|
||||
la: "In cratícula * te Deum non negávi, et ad ignem applicátus te Christum conféssus sum: probásti cor meum, et visitásti nocte: igne me examinásti, et non est invénta in me iníquitas."
|
||||
en: "Upon the bars I denied thee not, O God. * And when they put me to the fire, I acknowledged thee to be the Lord, O Christ. Thou hast proved mine heart, and visited it by night; Thou hast tried me with fire, and found no wickedness in me."
|
||||
status:
|
||||
la: verified
|
||||
en: verified
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
|
||||
# live query, Sanctæ Mariæ Sabbato (2026-09-05).
|
||||
id: marian-saturday-benedictus-antiphon
|
||||
text:
|
||||
la: "Beáta Dei Génitrix * María, Virgo perpétua, templum Dómini, sacrárium Spíritus Sancti, sola sine exémplo placuísti Dómino nostro Jesu Christo: ora pro pópulo, intérveni pro clero, intercéde pro devóto femíneo sexu."
|
||||
en: "Blessed Mother of God, * Mary, ever Virgin, temple of the Lord, shrine of the Holy Ghost, thou alone without example hast pleased our Lord Jesus Christ: pray for the people, plead for the clergy, make intercession for all women vowed to God."
|
||||
status:
|
||||
la: verified
|
||||
en: verified
|
||||
@@ -0,0 +1,19 @@
|
||||
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
|
||||
# live query, Sanctæ Mariæ Sabbato (2026-09-05). Same collect text as the
|
||||
# Suffragium's own "De Beata Maria" (lauds-suffrage-bvm.yml) -- real
|
||||
# practice reuses it for both purposes.
|
||||
id: marian-saturday-collect
|
||||
text:
|
||||
la: |
|
||||
Orémus.
|
||||
Concéde nos fámulos tuos, quǽsumus, Dómine Deus, perpétua mentis et córporis sanitáte gaudére: et, gloriósa beátæ Maríæ semper Vírginis intercessióne, a præsénti liberári tristítia, et ætérna pérfrui lætítia.
|
||||
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.
|
||||
R. Amen.
|
||||
en: |
|
||||
Let us pray.
|
||||
Grant, we beseech thee, O Lord God, unto all thy servants, that they may remain continually in the enjoyment of soundness both of mind and body, and by the glorious intercession of the Blessed Mary, always a Virgin, may be delivered from present sadness, and enter into the joy of thine eternal gladness.
|
||||
Through Jesus Christ, thy Son our Lord, Who liveth and reigneth with thee, in the unity of the Holy Ghost, God, world without end.
|
||||
R. Amen.
|
||||
status:
|
||||
la: verified
|
||||
en: verified
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Same shape as data/hours/lauds-antiphons.yml's per-weekday entries
|
||||
* (see hours/lauds.ts) — a whole-block substitute for the plain ferial
|
||||
* weekday psalmody, keyed by id: either a saint's own id (gated at
|
||||
* duplex-majus+, see hours/lauds.ts's getPsalmodyOverrideFor) or the
|
||||
* literal 'marian-saturday' (applies unconditionally whenever it wins).
|
||||
* Deliberately per-feast, not per-Common — explicit choice, even though
|
||||
* it means most duplex-majus+ saints across the year don't have one of
|
||||
* these authored yet and fall back to the plain weekday default until
|
||||
* they do (same "mechanism first, content incrementally" pattern as
|
||||
* everywhere else — not a bug, an expected, honest gap). */
|
||||
export interface LaudsPsalmodyOverride {
|
||||
id: string;
|
||||
groups: { psalms: number[]; antiphon: Partial<Record<string, string>> }[];
|
||||
canticle: { id: string; antiphon: Partial<Record<string, string>> };
|
||||
laudate: { antiphon: Partial<Record<string, string>> };
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<{ default: LaudsPsalmodyOverride }>(
|
||||
'../data/hours/lauds-psalmody-overrides/*.yml',
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const overrides = new Map<string, LaudsPsalmodyOverride>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
overrides.set(mod.default.id, mod.default);
|
||||
}
|
||||
|
||||
export function getLaudsPsalmodyOverride(id: string): LaudsPsalmodyOverride | undefined {
|
||||
return overrides.get(id);
|
||||
}
|
||||
+45
-9
@@ -1,9 +1,10 @@
|
||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||
import { resolveDay } from '../calendar';
|
||||
import { resolveDay, isAtLeast } from '../calendar';
|
||||
import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getCanticle } from './lauds-canticles';
|
||||
import { getLaudsPsalmodyOverride } from './lauds-psalmody-overrides';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
@@ -18,16 +19,40 @@ interface LaudsGroup {
|
||||
psalms: number[];
|
||||
antiphon: BilingualText;
|
||||
}
|
||||
interface LaudsWeekdayAntiphons {
|
||||
/** Shared by both the plain per-weekday default (data/hours/lauds-
|
||||
* antiphons.yml) and a duplex-majus+/Marian-Saturday override (hours/
|
||||
* lauds-psalmody-overrides.ts) — resolvePsalmody doesn't care which one
|
||||
* produced it. */
|
||||
interface PsalmodyBlock {
|
||||
groups: LaudsGroup[];
|
||||
// `split`: verse count of the canticle's first part, when it's said in
|
||||
// two pieces (own Gloria Patri each) rather than continuously — see
|
||||
// lauds-antiphons.yml's saturday.canticle comment. Absent every other
|
||||
// weekday.
|
||||
// lauds-antiphons.yml's saturday.canticle comment. Only Saturday's
|
||||
// plain default uses this today; no override needs it yet.
|
||||
canticle: { id: string; antiphon: BilingualText; split?: number };
|
||||
laudate: { antiphon: BilingualText };
|
||||
}
|
||||
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, LaudsWeekdayAntiphons>;
|
||||
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, PsalmodyBlock>;
|
||||
|
||||
/**
|
||||
* A duplex-majus+ saint's own proper psalmody (per-feast, not per-Common —
|
||||
* explicit choice), or Marian Saturday's (unconditional whenever it wins,
|
||||
* no rank threshold — it isn't "a saint" competing with the weekday
|
||||
* default the way one is). 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.
|
||||
*/
|
||||
function getPsalmodyOverrideFor(day: LiturgicalDay): PsalmodyBlock | undefined {
|
||||
if (day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday') {
|
||||
return getLaudsPsalmodyOverride('marian-saturday');
|
||||
}
|
||||
if (day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-majus')) {
|
||||
return getLaudsPsalmodyOverride(day.winner.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Mon-Fri share one capitulum verbatim (confirmed against the live engine
|
||||
// — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their
|
||||
@@ -92,9 +117,10 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
|
||||
}
|
||||
|
||||
/** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody'
|
||||
* doc comment for the scope and the known Commune-override gap. */
|
||||
* doc comment for the scope, and getPsalmodyOverrideFor above for when the
|
||||
* plain weekday default below gets substituted. */
|
||||
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
|
||||
const wd = laudsAntiphons[day.weekday];
|
||||
const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday];
|
||||
const opening = (antiphon: BilingualText) => {
|
||||
const { incipit, full } = splitNamedAntiphon(antiphon);
|
||||
return isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
@@ -177,15 +203,25 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
|
||||
case 'day-collects':
|
||||
return getDayCollects(day).map((text) => ({ kind: 'prayer' as const, text }));
|
||||
case 'suffrages':
|
||||
case 'suffrages': {
|
||||
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
|
||||
return [];
|
||||
}
|
||||
return SUFFRAGES.map((id) => ({
|
||||
// Confirmed live: Our Lady's Saturday drops both "Of the Holy Cross"
|
||||
// and "Of the Blessed Virgin Mary" (the latter redundant with the
|
||||
// day's own theme; the former for reasons the reference engine
|
||||
// doesn't explain and this doesn't try to re-derive), keeping only
|
||||
// the Apostles and Peace suffrages.
|
||||
const ids =
|
||||
day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday'
|
||||
? SUFFRAGES.filter((id) => id !== 'lauds-suffrage-cross' && id !== 'lauds-suffrage-bvm')
|
||||
: SUFFRAGES;
|
||||
return ids.map((id) => ({
|
||||
kind: 'preces' as const,
|
||||
text: resolveCommon(id),
|
||||
label: SUFFRAGE_LABELS[id],
|
||||
}));
|
||||
}
|
||||
case 'versicle':
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
|
||||
+10
-7
@@ -89,13 +89,16 @@ export type HourPart =
|
||||
// number of antiphoned psalm groups, then a weekday-variable OT canticle,
|
||||
// then the Laudate psalms under one more shared antiphon) doesn't fit any
|
||||
// existing part kind, and hardcoding psalm numbers in lauds.yml can't
|
||||
// work since they differ by weekday. Known, deliberate gap: does NOT
|
||||
// model the Commune/feast override of this whole block (real practice
|
||||
// substitutes an entirely different set of psalms+antiphons+canticle on
|
||||
// a Semiduplex-or-higher day) — every day currently gets the plain
|
||||
// ferial weekday distribution regardless of who wins. Fixing that is a
|
||||
// separate, larger propers-authoring task (a full Common-of-Saints psalm
|
||||
// set per Common), not started yet.
|
||||
// work since they differ by weekday. A duplex-majus-or-higher sanctoral
|
||||
// winner, or Marian Saturday, substitutes its own proper psalmody
|
||||
// instead — see hours/lauds-psalmody-overrides.ts's
|
||||
// getPsalmodyOverrideFor, gated by direct instruction at duplex-majus+
|
||||
// rather than every rank real historical practice would (most saints
|
||||
// were added in ways that override the daily psalms — not wanted here).
|
||||
// Authored per-feast, not per-Common (explicit choice) — known,
|
||||
// deliberate gap: most duplex-majus+ saints across the year don't have
|
||||
// one of these authored yet and fall back to the plain weekday default
|
||||
// until they do (same incremental-content pattern as everywhere else).
|
||||
| { kind: 'lauds-psalmody' }
|
||||
// Lauds only. The weekday-resolved chapter/short-responsory/hymn/closing
|
||||
// versicle bundle that follows the psalmody — see hours/lauds.ts and
|
||||
|
||||
@@ -53,7 +53,10 @@ describe('August sanctoral pull (first pass)', () => {
|
||||
});
|
||||
|
||||
it("the Vigil of St. Lawrence's own vigil rank forces primacy over a real, otherwise-unranked secondary saint", () => {
|
||||
const day = resolveDay('2025-08-09');
|
||||
// 2025-08-09 is a Saturday -- collides with the generic Saturday-of-
|
||||
// Mary votive this test isn't about (see tests/calendar/marian-
|
||||
// saturday.test.ts); 2024 keeps it a plain midweek day.
|
||||
const day = resolveDay('2024-08-09');
|
||||
expect(day.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'vigil-of-st-lawrence',
|
||||
|
||||
@@ -40,8 +40,11 @@ describe('June sanctoral pull (first pass)', () => {
|
||||
});
|
||||
|
||||
it('the Nativity of St. John the Baptist wins outright at Duplex I. classis, distinct from his Vigil the day before', () => {
|
||||
const vigil = resolveDay('2035-06-23');
|
||||
const nativity = resolveDay('2035-06-24');
|
||||
// 2035-06-23 is a Saturday -- collides with the generic Saturday-of-
|
||||
// Mary votive this test isn't about (see tests/calendar/marian-
|
||||
// saturday.test.ts); 2027 keeps both dates on plain midweek days.
|
||||
const vigil = resolveDay('2027-06-23');
|
||||
const nativity = resolveDay('2027-06-24');
|
||||
expect(vigil.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'vigil-of-st-john-the-baptist',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveDay } from '../../src/calendar';
|
||||
import { getDayLabel } from '../../src/calendar/day-label';
|
||||
|
||||
// Direct instruction: on a free Saturday, "Our Lady's Saturday" mirrors
|
||||
// privileged-feria-minor exactly — Semiduplex-or-higher still wins
|
||||
// outright (Marian Saturday doesn't happen); below that (Simplex, and
|
||||
// Vigil, since both are below semiduplex in the FeastClass scale), the
|
||||
// saint loses and is commemorated instead.
|
||||
describe('Marian Saturday (applyMarianSaturday)', () => {
|
||||
it('wins outright, with no commemoration, on a Saturday with nothing else at all going on', () => {
|
||||
const day = resolveDay('2026-10-24');
|
||||
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
||||
expect(day.commemorations).toEqual([]);
|
||||
expect(getDayLabel(day)).toBe("Our Lady's Saturday");
|
||||
});
|
||||
|
||||
it('wins over a Simplex saint, who is commemorated instead of winning outright', () => {
|
||||
const day = resolveDay('2026-06-20'); // St. Silverius, Pope and Martyr, Simplex
|
||||
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
||||
expect(day.commemorations).toEqual([
|
||||
{ kind: 'sanctoral', id: 'st-silverius', name: 'St. Silverius, Pope and Martyr', rank: 'simplex' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('wins over a Vigil too (below semiduplex, same as Simplex) -- can commemorate more than one loser at once', () => {
|
||||
const day = resolveDay('2025-11-29'); // St. Saturninus (Simplex) + Vigil of St. Andrew
|
||||
expect(day.winner).toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
||||
expect(day.commemorations).toEqual([
|
||||
{ kind: 'sanctoral', id: 'st-saturninus', name: 'St. Saturninus, Martyr', rank: 'simplex' },
|
||||
{ kind: 'sanctoral', id: 'vigil-of-st-andrew', name: 'Vigil of St. Andrew', rank: 'vigil' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('loses outright to a Semiduplex-or-higher saint -- Marian Saturday does not apply at all', () => {
|
||||
const day = resolveDay('2026-09-19'); // St. Januarius, Semiduplex
|
||||
expect(day.winner).toEqual({
|
||||
kind: 'sanctoral',
|
||||
id: 'st-januarius',
|
||||
name: 'St. Januarius, Bishop, and Companions, Martyrs',
|
||||
rank: 'semiduplex',
|
||||
});
|
||||
});
|
||||
|
||||
it('never applies to a Saturday with real standing of its own (privileged season, Ember days, ...)', () => {
|
||||
const day = resolveDay('2026-02-28'); // Lenten Ember Saturday
|
||||
expect(day.winner).not.toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
||||
expect(day.temporalCategory).toBe('privileged-feria');
|
||||
});
|
||||
|
||||
it('never applies while an octave is active, even on an otherwise-eligible Saturday (Pentecost Ember Saturday stays governed by its octave)', () => {
|
||||
const day = resolveDay('2026-05-30');
|
||||
expect(day.winner).toEqual({ kind: 'temporal', id: 'pentecost-sunday' });
|
||||
});
|
||||
|
||||
it('never applies on a weekday other than Saturday', () => {
|
||||
const day = resolveDay('2026-10-23'); // the Friday before the clean Marian Saturday above
|
||||
expect(day.winner).not.toEqual({ kind: 'temporal', id: 'marian-saturday' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOrdo } from '../../src/hours';
|
||||
|
||||
describe('Lauds psalmody override (duplex-majus+ sanctoral, and Marian Saturday)', () => {
|
||||
it("substitutes St. Lawrence's own proper psalmody on his own day (Duplex II. classis, above the threshold)", () => {
|
||||
const ordo = resolveOrdo('lauds', '2026-08-10');
|
||||
const psalmNumbers = ordo.parts
|
||||
.filter((p) => p.kind === 'psalm')
|
||||
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
expect(psalmNumbers).toEqual([66, 92, 99, 62, 148, 149, 150]);
|
||||
|
||||
const ps92 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 92);
|
||||
expect(ps92?.kind === 'psalm' ? ps92.antiphon?.text.en : undefined).toContain('Lawrence went in to be a martyr');
|
||||
|
||||
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus');
|
||||
expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum');
|
||||
});
|
||||
|
||||
it("doesn't override a saint below duplex-majus (e.g. Semiduplex) -- plain weekday psalmody still used", () => {
|
||||
// 2026-09-19: St. Januarius, Semiduplex (below duplex-majus) -- see
|
||||
// tests/calendar/marian-saturday.test.ts for confirming he wins
|
||||
// outright as the day's winner despite it being a Saturday.
|
||||
const ordo = resolveOrdo('lauds', '2026-09-19');
|
||||
const psalmNumbers = ordo.parts
|
||||
.filter((p) => p.kind === 'psalm')
|
||||
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
// Saturday's own plain default (Ps 50 + 142), not the Common-shaped
|
||||
// override -- no lauds-psalmody-overrides/st-januarius.yml exists.
|
||||
expect(psalmNumbers).toEqual([66, 50, 142, 148, 149, 150]);
|
||||
});
|
||||
|
||||
it("substitutes Our Lady's Saturday own proper psalmody, unconditionally, whenever it wins", () => {
|
||||
const ordo = resolveOrdo('lauds', '2026-10-24'); // a clean, otherwise-empty Marian Saturday
|
||||
const psalmNumbers = ordo.parts
|
||||
.filter((p) => p.kind === 'psalm')
|
||||
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
expect(psalmNumbers).toEqual([66, 92, 99, 62, 148, 149, 150]);
|
||||
|
||||
const ps92 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 92);
|
||||
expect(ps92?.kind === 'psalm' ? ps92.antiphon?.text.la : undefined).toContain('Dum esset Rex');
|
||||
|
||||
const benedictus = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus');
|
||||
expect(benedictus?.kind === 'canticle' ? benedictus.antiphon?.text.la : undefined).toContain('Beáta Dei Génitrix');
|
||||
|
||||
const collect = ordo.parts.find((p) => p.kind === 'prayer');
|
||||
expect(collect?.kind === 'prayer' ? collect.text.text.en : undefined).toContain('glorious intercession of the Blessed Mary');
|
||||
});
|
||||
|
||||
it("drops the Holy Cross and Blessed Virgin Mary suffrages on Our Lady's Saturday, keeping Apostles and Peace", () => {
|
||||
const ordo = resolveOrdo('lauds', '2026-10-24');
|
||||
const labels = ordo.parts.filter((p) => p.kind === 'preces' && p.label).map((p) => (p.kind === 'preces' ? p.label : undefined));
|
||||
expect(labels).toEqual(['Of the Holy Apostles Peter and Paul', 'For Peace', 'Salve Regina']);
|
||||
});
|
||||
});
|
||||
@@ -58,12 +58,13 @@ describe('resolveOrdo("lauds", ...)', () => {
|
||||
});
|
||||
|
||||
it("resolves Saturday's own psalmody: only one weekday psalm (142), and the Canticle of Moses said in two pieces (RB 13's own division), one shared antiphon framing both", () => {
|
||||
// Any Saturday works here: lauds-psalmody doesn't yet model the
|
||||
// Commune/feast override real practice would apply on a Marian-
|
||||
// Sabbato Saturday (see hours/types.ts's 'lauds-psalmody' doc comment)
|
||||
// -- the plain weekday distribution is always used, so this is
|
||||
// exercising the real, current behavior, not a stand-in for it.
|
||||
const ordo = resolveOrdo('lauds', '2026-06-20');
|
||||
// Needs a Saturday that ISN'T Our Lady's Saturday, which now wins
|
||||
// (and substitutes its own psalmody) on every ordinary-feria Saturday
|
||||
// — see calendar/index.ts's applyMarianSaturday and tests/calendar/
|
||||
// marian-saturday.test.ts. A privileged-feria Saturday (Lenten Ember
|
||||
// Saturday, 2026-02-28) has real standing of its own that Marian
|
||||
// Saturday doesn't touch, so it still shows the plain weekday default.
|
||||
const ordo = resolveOrdo('lauds', '2026-02-28');
|
||||
const psalmNumbers = ordo.parts
|
||||
.filter((p) => p.kind === 'psalm')
|
||||
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
@@ -139,10 +140,10 @@ describe('resolveOrdo("lauds", ...)', () => {
|
||||
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.status?.la : undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves a real Benedictus antiphon on St. Lawrence's own octave day, from the saint's already-authored proper", () => {
|
||||
it("resolves a real Benedictus antiphon on St. Lawrence's own day, from the saint's already-authored proper", () => {
|
||||
const ordo = resolveOrdo('lauds', '2026-08-10'); // St. Lawrence's own day
|
||||
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus');
|
||||
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.text.la : undefined).toContain('Levíta Lauréntius');
|
||||
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.text.la : undefined).toContain('In cratícula');
|
||||
});
|
||||
|
||||
it('includes the short litany (Kyrie + Our Father) right after the Benedictus, before the day collect', () => {
|
||||
|
||||
Reference in New Issue
Block a user