calendar: model Our Lady's Saturday as a real occurrence outcome
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:
2026-08-11 14:02:55 -04:00
parent 4548042b65
commit 747a078580
16 changed files with 378 additions and 31 deletions
+11
View File
@@ -13,6 +13,7 @@ import type { LiturgicalDay } from './types';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math'; import { addDays, daysBetween, toIsoDate } from './date-math';
import { getTemporalFeastRecord } from './temporal-feasts';
function capitalize(text: string): string { function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
@@ -136,6 +137,16 @@ export function getDayLabel(day: LiturgicalDay): string {
return day.winner.name; 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 temporal = temporalLabel(day);
const commemorated = day.commemorations.find((c) => c.kind === 'sanctoral'); const commemorated = day.commemorations.find((c) => c.kind === 'sanctoral');
return commemorated ? `${commemorated.name}${temporal}` : temporal; return commemorated ? `${commemorated.name}${temporal}` : temporal;
+46
View File
@@ -101,10 +101,56 @@ export function resolveDay(isoDate: string): LiturgicalDay {
} }
winner = applyOctaves(isoDate, winner, commemorations); winner = applyOctaves(isoDate, winner, commemorations);
winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
return { date: isoDate, weekday, season, 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 * 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 * 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 # Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
# [Ant 1] — the Lauds-hour antiphon, not Terce's. # 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 id: st-lawrence-antiphon
text: 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." 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: "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." 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: status:
la: verified la: verified
en: 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
+30
View File
@@ -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
View File
@@ -1,9 +1,10 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types'; import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay } from '../calendar'; import { resolveDay, isAtLeast } from '../calendar';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getCanticle } from './lauds-canticles'; import { getCanticle } from './lauds-canticles';
import { getLaudsPsalmodyOverride } from './lauds-psalmody-overrides';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher } from './antiphon'; import { isDoubleOrHigher } from './antiphon';
@@ -18,16 +19,40 @@ interface LaudsGroup {
psalms: number[]; psalms: number[];
antiphon: BilingualText; 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[]; groups: LaudsGroup[];
// `split`: verse count of the canticle's first part, when it's said in // `split`: verse count of the canticle's first part, when it's said in
// two pieces (own Gloria Patri each) rather than continuously — see // two pieces (own Gloria Patri each) rather than continuously — see
// lauds-antiphons.yml's saturday.canticle comment. Absent every other // lauds-antiphons.yml's saturday.canticle comment. Only Saturday's
// weekday. // plain default uses this today; no override needs it yet.
canticle: { id: string; antiphon: BilingualText; split?: number }; canticle: { id: string; antiphon: BilingualText; split?: number };
laudate: { antiphon: BilingualText }; 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 // Mon-Fri share one capitulum verbatim (confirmed against the live engine
// — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their // — 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' /** 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[] { function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
const wd = laudsAntiphons[day.weekday]; const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday];
const opening = (antiphon: BilingualText) => { const opening = (antiphon: BilingualText) => {
const { incipit, full } = splitNamedAntiphon(antiphon); const { incipit, full } = splitNamedAntiphon(antiphon);
return isDoubleOrHigher(day.winner) ? full : incipit; 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 }]; return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
case 'day-collects': case 'day-collects':
return getDayCollects(day).map((text) => ({ kind: 'prayer' as const, text })); return getDayCollects(day).map((text) => ({ kind: 'prayer' as const, text }));
case 'suffrages': case 'suffrages': {
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
return []; 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, kind: 'preces' as const,
text: resolveCommon(id), text: resolveCommon(id),
label: SUFFRAGE_LABELS[id], label: SUFFRAGE_LABELS[id],
})); }));
}
case 'versicle': case 'versicle':
case 'chapter': case 'chapter':
case 'responsory': case 'responsory':
+10 -7
View File
@@ -89,13 +89,16 @@ export type HourPart =
// number of antiphoned psalm groups, then a weekday-variable OT canticle, // number of antiphoned psalm groups, then a weekday-variable OT canticle,
// then the Laudate psalms under one more shared antiphon) doesn't fit any // 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 // existing part kind, and hardcoding psalm numbers in lauds.yml can't
// work since they differ by weekday. Known, deliberate gap: does NOT // work since they differ by weekday. A duplex-majus-or-higher sanctoral
// model the Commune/feast override of this whole block (real practice // winner, or Marian Saturday, substitutes its own proper psalmody
// substitutes an entirely different set of psalms+antiphons+canticle on // instead — see hours/lauds-psalmody-overrides.ts's
// a Semiduplex-or-higher day) — every day currently gets the plain // getPsalmodyOverrideFor, gated by direct instruction at duplex-majus+
// ferial weekday distribution regardless of who wins. Fixing that is a // rather than every rank real historical practice would (most saints
// separate, larger propers-authoring task (a full Common-of-Saints psalm // were added in ways that override the daily psalms — not wanted here).
// set per Common), not started yet. // 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' } | { kind: 'lauds-psalmody' }
// Lauds only. The weekday-resolved chapter/short-responsory/hymn/closing // Lauds only. The weekday-resolved chapter/short-responsory/hymn/closing
// versicle bundle that follows the psalmody — see hours/lauds.ts and // versicle bundle that follows the psalmody — see hours/lauds.ts and
+4 -1
View File
@@ -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", () => { 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({ expect(day.winner).toEqual({
kind: 'sanctoral', kind: 'sanctoral',
id: 'vigil-of-st-lawrence', id: 'vigil-of-st-lawrence',
+5 -2
View File
@@ -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', () => { 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'); // 2035-06-23 is a Saturday -- collides with the generic Saturday-of-
const nativity = resolveDay('2035-06-24'); // 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({ expect(vigil.winner).toEqual({
kind: 'sanctoral', kind: 'sanctoral',
id: 'vigil-of-st-john-the-baptist', id: 'vigil-of-st-john-the-baptist',
+60
View File
@@ -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']);
});
});
+9 -8
View File
@@ -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", () => { 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 // Needs a Saturday that ISN'T Our Lady's Saturday, which now wins
// Commune/feast override real practice would apply on a Marian- // (and substitutes its own psalmody) on every ordinary-feria Saturday
// Sabbato Saturday (see hours/types.ts's 'lauds-psalmody' doc comment) // — see calendar/index.ts's applyMarianSaturday and tests/calendar/
// -- the plain weekday distribution is always used, so this is // marian-saturday.test.ts. A privileged-feria Saturday (Lenten Ember
// exercising the real, current behavior, not a stand-in for it. // Saturday, 2026-02-28) has real standing of its own that Marian
const ordo = resolveOrdo('lauds', '2026-06-20'); // Saturday doesn't touch, so it still shows the plain weekday default.
const ordo = resolveOrdo('lauds', '2026-02-28');
const psalmNumbers = ordo.parts const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm') .filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); .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(); 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 ordo = resolveOrdo('lauds', '2026-08-10'); // St. Lawrence's own day
const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); 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', () => { it('includes the short litany (Kyrie + Our Father) right after the Benedictus, before the day collect', () => {