Compare commits

...

2 Commits

Author SHA1 Message Date
will cf4edfae2b Fix Matins hymn to actually keep the Assumption's octave hymn all week
Deploy / deploy (push) Successful in 1m31s
resolveMatinsHymn's own doc comment already claimed the octave "keeps
the feast's own proper hymn all week," but the code never implemented
it -- it only matched when the day's own winner literally was
`assumption` (Aug 15 itself). Every other octave day (16, 17, 19, 20,
21, none of whom has a Matins hymn of their own authored) fell straight
through to the plain ferial hymn instead.

Adds a real octave-fallback tier between the existing per-feast override
and the seasonal tier. A deliberate departure from the reference engine,
which doesn't do this either (Monastic 1617's octave days have no
[Hymnus Matutinum] override at all) -- consistent with how this project
already treats octave readings/commemorations more generously than any
one source track.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA
2026-08-22 06:00:42 -04:00
will 224f34908c Relocate Immaculate Heart of Mary off Aug 22 to Easter+69
Fixed Aug 22 collided outright with the Assumption's own octave-closing
day (both this project's blended calendar tracks are kept deliberately,
so one shouldn't permanently suppress the other). Moved IHM to the
Saturday after the Feast of the Sacred Heart -- its real diocesan date
from 1914 until Pius XII's 1944 fixed-date decree, and also the date the
1969 reform returned to.

Required real new mechanism, not just a data move: a new bespoke
calendar/index.ts override (applyImmaculateHeart, following the existing
applyMarianSaturday/applyChristTheKing precedent), a move from the
sanctoral saints store to the temporal-feasts store (different propers
lookup entirely), and a real bug fix in matins.ts's nocturnReadingIds,
which only ever picked up a *sanctoral* winner's own reading file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA
2026-08-22 05:59:33 -04:00
13 changed files with 287 additions and 37 deletions
+79
View File
@@ -2249,6 +2249,85 @@ dropped *opening* versicle (and Ps 3, which it precedes) is gone, via `ordo.part
nocturn's own first psalm, rather than asserting zero versicles exist. `npm test` (421 tests, nocturn's own first psalm, rather than asserting zero versicles exist. `npm test` (421 tests,
including the Sunday-versicle regression test above) and `tsc --noEmit` both pass. including the Sunday-versicle regression test above) and `tsc --noEmit` both pass.
### Immaculate Heart of Mary relocated off Aug 22 (2026-08-22)
User-flagged, same day: Aug 22 (the Assumption's octave-closing day) was being won outright by
Immaculate Heart of Mary (Duplex II classis, fixed there since Pius XII's 1944 decree),
permanently demoting the octave day to a commemoration — a real collision between this
project's two blended calendar tracks (Divino Afflatu fixes IHM there; the older Tridentine
track has no IHM at all, just the octave day). Per this project's "generous, not
reconstructionist" design, the fix is relocation, not picking a permanent winner.
Researched the real history rather than guessing: Immaculate Heart of Mary was actually
celebrated on the **Saturday after the Feast of the Sacred Heart** from 1914 until the 1944
fixed-date decree, and that's also the date the 1969 reform returned to — continuity on both
sides of the Aug-22 interlude, unlike an initially-considered but unverifiable "Saturday after
the *octave* of Sacred Heart" guess (no such rule was ever real, and Sacred Heart itself has no
octave modeled in this codebase — see `data/calendar/easter-offsets.yml`, where it's only a
season-label entry, `68: sacred-heart`).
Moved: `data/calendar/saints/immaculate-heart-of-mary.yml` deleted, its `sanctoral-calendar.yml`
`"08-22"` entry removed. New `data/calendar/temporal-feasts/immaculate-heart-of-mary.yml`
(id/name/rank only, no octave). New `calendar/index.ts` function `applyImmaculateHeart`
computes Easter+69 (Sacred Heart, Easter+68, is always a Friday; this is the Saturday right
after), overriding the day's winner there via the same rank-compared, commemorate-the-loser
pattern `applyChristTheKing`/`applyMarianSaturday` already use, run last in `resolveDay`'s chain
so a specific named feast always supersedes the generic "Our Lady's Saturday" default rather
than losing to whichever ran first. Added `immaculate-heart-of-mary` to `hours/resolve-
common.ts`'s `ALWAYS_OVERRIDE_TEMPORAL_IDS` so its own chapter/responsory/hymn/versicle/
antiphon overrides are actually eligible (a temporal-kind winner is otherwise ignored by
`getOfficeOverrideId`).
Propers content moved stores, not just files — temporal feasts resolve via a physically
different store than sanctoral saints (`getTemporalProper`/`data/propers/temporal/*.yml`, keyed
`${id}-${suffix}`, vs. sanctoral's `common`/`propers`-field indirection into `data/propers/
common/*.yml`). Collect moved as-is (suffix already matched); the single shared antiphon file
was split into `-benedictus-antiphon`/`-magnificat-antiphon` (temporal's two-suffix convention,
vs. sanctoral's one file reused for both) — same duplicated text, no new content authored.
Nocturn readings needed no file change, but did surface a real bug in `hours/matins.ts`'s
`nocturnReadingIds`: it only ever added `day.winner.id` when `day.winner.kind === 'sanctoral'`,
so a named *temporal* override (IHM now, or `marian-saturday`/`christ-the-king` already, neither
of which happened to have nocturn-readings content authored yet to expose this) would never
have picked up its own reading file — fixed by adding `day.winner.id` unconditionally (harmless
dedup via the existing `Set` on an ordinary day, where it already equals the plain temporal id).
`tests/calendar/immaculate-heart-of-mary.test.ts` (new) proves the relocation end-to-end across
two years' Easter dates, and `tests/calendar/august-sanctoral.test.ts`'s existing Aug 22 test
was updated to match (the day now reverts to the plain Assumption octave, same shape as St.
Lawrence's own Aug 12 case). `npm test` (429 tests) and `tsc --noEmit` both pass.
### Assumption-octave Matins hymn fixed (2026-08-22)
Separately, the "no antiphons and a weird hymn today" complaint that prompted the investigation
above turned out to be a distinct, narrower bug: `resolveMatinsHymn`'s own doc comment
already claimed the Assumption's octave "keeps the feast's own proper hymn all week," but the
code never implemented that — it only fired when the day's own winner literally *was*
`assumption` (Aug 15 only). Every other octave day (16, 17, 19, 20, 21 — St. Joachim, St.
Hyacinth, St. John Eudes, St. Bernard, St. Jane Frances de Chantal, none of whom has a Matins
hymn of their own authored) fell straight to the plain ferial hymn. Fixed by adding a real
octave-fallback tier (`resolveActiveOctave(day.date)``matins-hymn-${octave.id}`) between the
existing per-feast override and the seasonal tier. Confirmed against the reference engine that
Monastic 1617 itself doesn't do this either (no octave day there has its own `[Hymnus
Matutinum]` override) — this is a deliberate departure, not a restoration, consistent with how
this project already treats octave readings/commemorations more generously than any one source
track. Aug 18 (no sanctoral winner) and Aug 22 (once IHM moved) needed no change — both already
reached `matins-hymn-assumption` via the pre-existing `resolveOfficeWinner` octave-substitution
path, which only fires when the day's own winner isn't sanctoral.
**Not fixed, logged instead** (user decision): the deeper reason Matins had *zero* nocturn
antiphons at all on Aug 22 — no `matins-psalmody-overrides` entry exists for `assumption` or
`immaculate-heart-of-mary` (or, generally, any Duplex-2-classis+ feast lacking one), so Matins
falls all the way to the bare ferial psalm table, which by design carries no antiphons — is a
broader content gap than today's date, tracked in the "Matins psalmody overrides" bullet above
rather than authored now. Also newly true because of this change: `hours/matins.ts`'s psalmody
override lookup is hard-gated to `winner.kind === 'sanctoral'`, so even if IHM's psalmody were
authored later, it wouldn't apply as a temporal-kind winner without that gate being loosened
too — noted for whoever picks this up.
New tests in `tests/hours/matins.test.ts` cover the fallback across Aug 16-22, including the
Aug 17 edge case (correctly still ferial, since that day is actually governed by St. Lawrence's
own octave, not the Assumption's). `npm test` (430 tests) and `tsc --noEmit` both pass.
## Known, deliberate simplifications (not bugs — working as designed) ## Known, deliberate simplifications (not bugs — working as designed)
- `getDayCollects`: each collect in a multi-collect day renders as its own - `getDayCollects`: each collect in a multi-collect day renders as its own
+49
View File
@@ -105,6 +105,7 @@ export function resolveDay(isoDate: string): LiturgicalDay {
winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations); winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
winner = applyChristTheKing(isoDate, winner, commemorations); winner = applyChristTheKing(isoDate, winner, commemorations);
winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations);
winner = applyImmaculateHeart(isoDate, winner, commemorations);
applyEpiphany6Commemoration(isoDate, commemorations); applyEpiphany6Commemoration(isoDate, commemorations);
return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; return { date: isoDate, weekday, season, temporalCategory, winner, commemorations };
@@ -244,6 +245,54 @@ function applyChristmasOctaveSunday(
return winner; return winner;
} }
/**
* User decision, 2026-08-22: relocated off the fixed Aug 22 date (which
* collided outright with the Assumption's own octave-closing day —
* Pius XII's 1944 decree fixed it there, but this project keeps the
* older Tridentine "Octave Day of the Assumption" too) to the *other*
* well-documented historical assignment: the Saturday after the Feast
* of the Sacred Heart. Real history (not a guess): this was the
* feast's actual diocesan date from 1914 until the 1944 fixed-date
* decree, and is also the date Paul VI's 1969 reform returned to — so
* it has continuity on both sides of the Aug-22 interlude, unlike
* inventing a "Saturday after the octave of Sacred Heart" pattern (no
* such rule was ever real; Sacred Heart itself doesn't even have an
* octave modeled in this codebase). Sacred Heart is Easter+68 (always
* a Friday, see data/calendar/easter-offsets.yml's own derivation
* comment) — this is the very next day, Easter+69.
*
* Runs last, after applyMarianSaturday, deliberately: Easter+69 is
* always a Saturday, so absent this override the day would otherwise
* just fall to the generic "Our Lady's Saturday" default — a specific
* named Marian feast should always supersede that generic filler, not
* lose to whichever ran first. Any temporal winner reaching here
* (marian-saturday or a plain post-Pentecost Sunday-of-the-week id)
* has no real standing of its own and is simply superseded, same as
* applyChristTheKing's unconditional-when-temporal branch. Rank
* compared via compareFeastClass, same as every other override in this
* file — a duplex-2-classis feast displaces anything weaker, but a
* higher-ranked sanctoral saint who happens to land on the same
* Saturday keeps the day and Immaculate Heart is commemorated instead.
*/
function applyImmaculateHeart(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
const year = Number(isoDate.slice(0, 4));
const targetDate = addDays(toIsoDate(easterSunday(year)), 69);
if (isoDate !== targetDate) {
return winner;
}
const IMMACULATE_HEART: DayWinner = { kind: 'temporal', id: 'immaculate-heart-of-mary' };
if (winner.kind === 'temporal') {
commemorations.push({ kind: 'temporal', id: winner.id });
return IMMACULATE_HEART;
}
if (compareFeastClass(winner.rank, 'duplex-2-classis') >= 0) {
commemorations.push({ kind: 'temporal', id: 'immaculate-heart-of-mary' });
return winner;
}
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank });
return IMMACULATE_HEART;
}
/** /**
* Layered on top of everything above, same spirit as applyOctaves: never * Layered on top of everything above, same spirit as applyOctaves: never
* changes decideOccurrence's own rules, just relabels the result on a free * changes decideOccurrence's own rules, just relabels the result on a free
@@ -1,13 +0,0 @@
# Read directly from the reference engine: untagged/default [Rank]
# block of `web/www/horas/Latin/Sancti/08-22.txt` -- "Immaculati Cordis
# Beatæ Mariæ Virginis;;Duplex II classis;;5.1;;ex C11". Own real
# [Oratio] and own [Ant 1]. Own Nocturn 2-3 content (Nocturn 3 is St.
# Robert Bellarmine's own homily on the seven words of Christ on the
# Cross, not a cross-reference). Instituted 1942 by Pius XII (see the
# feast's own Lectio6, quoted in nocturn-readings, drawn from
# ecclesiastical documents).
id: immaculate-heart-of-mary
name: "The Immaculate Heart of the Blessed Virgin Mary"
rank: duplex-2-classis
common: common-of-the-bvm
propers: "immaculate-heart-of-mary"
-1
View File
@@ -269,7 +269,6 @@ days:
"08-19": [st-john-eudes] "08-19": [st-john-eudes]
"08-20": [st-bernard] "08-20": [st-bernard]
"08-21": [st-jane-frances-de-chantal] "08-21": [st-jane-frances-de-chantal]
"08-22": [immaculate-heart-of-mary]
"08-23": [vigil-of-st-bartholomew] "08-23": [vigil-of-st-bartholomew]
"08-24": [st-bartholomew] "08-24": [st-bartholomew]
"08-25": [st-louis] "08-25": [st-louis]
@@ -0,0 +1,14 @@
# Relocated off the fixed Aug 22 date (2026-08-22 user decision) -- that
# date collided outright with the Assumption's own octave-closing day,
# which this project also deliberately keeps (see saints/assumption.yml).
# Moved to the Saturday after the Feast of the Sacred Heart (Easter+69,
# see calendar/index.ts's applyImmaculateHeart) -- the feast's own real
# diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and
# also the date Paul VI's 1969 reform returned to, so it has continuity
# on both sides of the Aug-22 interlude. Rank/name still sourced from
# `web/www/horas/Latin/Sancti/08-22.txt`'s own [Rank] block, same as
# before the move -- only the date assignment changed, not the office's
# own content.
id: immaculate-heart-of-mary
name: "The Immaculate Heart of the Blessed Virgin Mary"
rank: duplex-2-classis
@@ -1,7 +1,11 @@
# Read directly from web/www/horas/Latin/Sancti/08-22.txt's own # Read directly from web/www/horas/Latin/Sancti/08-22.txt's own
# [Ant 1] and its English counterpart -- a real, proper antiphon of # [Ant 1] and its English counterpart -- a real, proper antiphon of
# this feast, not a Common substitution. # this feast, not a Common substitution. Duplicated as both Benedictus
id: immaculate-heart-of-mary-antiphon # and Magnificat antiphon (see the -magnificat- sibling file): the
# source only carries this single [Ant 1], and the temporal propers
# store keys the two separately, unlike the sanctoral store's single
# shared `${propers}-antiphon`.
id: immaculate-heart-of-mary-benedictus-antiphon
text: text:
la: "Exsultávit cor meum * in Dómino, et exaltátum est cornu meum in Deo meo, quia lætáta sum in salutári tuo." la: "Exsultávit cor meum * in Dómino, et exaltátum est cornu meum in Deo meo, quia lætáta sum in salutári tuo."
en: "My heart hath rejoiced in the Lord, * and my horn is exalted in my God: because I have joyed in thy salvation." en: "My heart hath rejoiced in the Lord, * and my horn is exalted in my God: because I have joyed in thy salvation."
@@ -0,0 +1,14 @@
# Read directly from web/www/horas/Latin/Sancti/08-22.txt's own
# [Ant 1] and its English counterpart -- a real, proper antiphon of
# this feast, not a Common substitution. Duplicated as both Benedictus
# and Magnificat antiphon (see the -benedictus- sibling file): the
# source only carries this single [Ant 1], and the temporal propers
# store keys the two separately, unlike the sanctoral store's single
# shared `${propers}-antiphon`.
id: immaculate-heart-of-mary-magnificat-antiphon
text:
la: "Exsultávit cor meum * in Dómino, et exaltátum est cornu meum in Deo meo, quia lætáta sum in salutári tuo."
en: "My heart hath rejoiced in the Lord, * and my horn is exalted in my God: because I have joyed in thy salvation."
status:
la: verified
en: verified
+36 -9
View File
@@ -62,7 +62,7 @@
// full-calendar content pass. See TODO.md for what's deferred. // full-calendar content pass. See TODO.md for what's deferred.
import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './types'; import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './types';
import type { LiturgicalDay } from '../calendar/types'; import type { LiturgicalDay } from '../calendar/types';
import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar'; import { resolveDay, resolveTemporalId, activeOctavesFor, resolveActiveOctave } from '../calendar';
import { isInTriduum } from '../calendar/temporal'; import { isInTriduum } from '../calendar/temporal';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
@@ -215,14 +215,28 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
/** The Matins hymn — a duplex-majus+ saint's or eligible named temporal /** The Matins hymn — a duplex-majus+ saint's or eligible named temporal
* feast's own proper hymn (`matins-hymn-${overrideId}`, via the same * feast's own proper hymn (`matins-hymn-${overrideId}`, via the same
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses), * getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
* when authored, else falls to a *seasonal* default (Advent/Lent/ * when authored; else, on a day within an active octave whose own feast
* has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) —
* see below; else falls to a *seasonal* default (Advent/Lent/
* Passiontide/Paschaltide, none authored yet), else the plain year-round * Passiontide/Paschaltide, none authored yet), else the plain year-round
* ferial hymn — same override > season > ferial precedence every other * ferial hymn — same override > octave > season > ferial precedence every
* hour's own office bundle uses (see lauds.ts's resolveOffice), just never * other hour's own office bundle uses (see lauds.ts's resolveOffice), just
* wired up here before. Concrete motivating case (user, 2026-08-21): the * never fully wired up here before.
* Assumption's octave (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/ *
* 08-15`) genuinely keeps the feast's own proper hymn all week, not the * Concrete motivating case (user, 2026-08-21/22): the Assumption's octave
* ferial one this always rendered previously. */ * (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps
* the feast's own proper hymn all week — a claim this comment already
* made before the octave tier below actually existed, which only ever
* fired on Aug 15 itself (the one day `overrideId` literally *is*
* `assumption`). Every other octave day (16, 17, 19, 20, 21, each with
* its own named saint who has no Matins hymn of their own authored) fell
* straight through to the plain ferial hymn instead. This is a deliberate
* departure from the reference engine, which doesn't do this either
* (Monastic 1617's own octave days have no `[Hymnus Matutinum]` override
* at all, live-checked 2026-08-22) — not a restoration of source
* behavior, just this project's own generous octave design (see
* "Not a reconstruction" in the repo's CLAUDE.md) applied to the hymn the
* same way it's already applied to readings/commemorations elsewhere. */
function resolveMatinsHymn(day: LiturgicalDay): ResolvedText { function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
const overrideId = getOfficeOverrideId(day); const overrideId = getOfficeOverrideId(day);
if (overrideId) { if (overrideId) {
@@ -231,6 +245,13 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
return proper; return proper;
} }
} }
const octave = resolveActiveOctave(day.date);
if (octave) {
const octaveHymn = resolveCommon(`matins-hymn-${octave.id}`);
if (octaveHymn.status.la !== 'missing' || octaveHymn.status.en !== 'missing') {
return octaveHymn;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season); const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) { if (seasonSuffix) {
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`); const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);
@@ -300,7 +321,13 @@ function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], Resol
* user's own "be generous, not winner-takes-all" instruction (2026-08). */ * user's own "be generous, not winner-takes-all" instruction (2026-08). */
function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] { function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] {
const ids = new Set<string>(); const ids = new Set<string>();
if (day.winner.kind === 'sanctoral') ids.add(day.winner.id); // Not gated to `kind === 'sanctoral'` -- a named temporal override (e.g.
// Immaculate Heart of Mary, calendar/index.ts's applyImmaculateHeart)
// has its own authored nocturn-readings file keyed by its own id too,
// distinct from the plain governing-Sunday `temporalId` added below.
// Harmless to include unconditionally: on an ordinary day `day.winner.id`
// already equals `temporalId`, so the Set just dedupes.
ids.add(day.winner.id);
for (const c of day.commemorations) { for (const c of day.commemorations) {
if (c.kind === 'sanctoral') ids.add(c.id); if (c.kind === 'sanctoral') ids.add(c.id);
} }
+6 -1
View File
@@ -35,7 +35,12 @@ export function resolveCommon(id: string): ResolvedText {
// override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which // override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which
// shares this set (imported from here, not duplicated) since it's the // shares this set (imported from here, not duplicated) since it's the
// same "does this temporal identity carry its own real office" question. // same "does this temporal identity carry its own real office" question.
export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-the-king', 'christmas-octave-sunday']); export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set([
'marian-saturday',
'christ-the-king',
'christmas-octave-sunday',
'immaculate-heart-of-mary',
]);
/** /**
* Which identity's own propers actually supply the office's content * Which identity's own propers actually supply the office's content
+10 -11
View File
@@ -91,18 +91,17 @@ describe('August sanctoral pull (first pass)', () => {
// Aug 22 is the Assumption's own octave day (In Octava Assumptionis), // Aug 22 is the Assumption's own octave day (In Octava Assumptionis),
// Duplex, blocking Ss. Timothy/Hippolytus/Symphorian the same way -- // Duplex, blocking Ss. Timothy/Hippolytus/Symphorian the same way --
// it's also day 8 of the Assumption's own octave. Those three still // it's also day 8 of the Assumption's own octave. Those three still
// aren't modeled -- but Aug 22 itself is also now the Immaculate // aren't modeled. The Immaculate Heart of the Blessed Virgin Mary used
// Heart of the Blessed Virgin Mary's own fixed day (Duplex II // to be fixed here too (Pius XII's 1944 decree), but was relocated
// Classis, added per the full-year sanctoral import), which wins // (2026-08-22 user decision, see TODO.md) to Easter+69 -- the
// outright under the ordinary-feria rule, with the octave still // Saturday after the Feast of the Sacred Heart, its own real
// commemorated alongside her. // historical date on both sides of that fixed-date interlude -- so it
// no longer contests this date at all. With no sanctoral candidate
// left here, the day's own temporal identity wins outright and the
// octave is commemorated alongside it, same shape as St. Lawrence's
// own Aug 12 case just above.
const assumptionOctaveDay = resolveDay('2025-08-22'); const assumptionOctaveDay = resolveDay('2025-08-22');
expect(assumptionOctaveDay.winner).toEqual({ expect(assumptionOctaveDay.winner.kind).toBe('temporal');
kind: 'sanctoral',
id: 'immaculate-heart-of-mary',
name: 'The Immaculate Heart of the Blessed Virgin Mary',
rank: 'duplex-2-classis',
});
expect(assumptionOctaveDay.commemorations).toEqual([ expect(assumptionOctaveDay.commemorations).toEqual([
{ kind: 'octave', id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary' }, { kind: 'octave', id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary' },
]); ]);
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { resolveDay } from '../../src/calendar';
import { getDayCollect, getBenedictusAntiphon, getMagnificatAntiphon } from '../../src/hours/resolve-common';
// Relocated off the fixed Aug 22 date (2026-08-22 user decision, see
// TODO.md) -- that date collided outright with the Assumption's own
// octave-closing day. Moved to Easter+69: the Saturday after the Feast of
// the Sacred Heart (Easter+68, always a Friday), the feast's own real
// diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and
// also the date the 1969 reform returned to.
describe('Immaculate Heart of Mary (applyImmaculateHeart)', () => {
it('wins outright on Easter+69, with real content resolved via the temporal propers store', () => {
const day = resolveDay('2026-06-13'); // Easter 2026-04-05 + 69 days
expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' });
expect(getDayCollect(day).status.en).toBe('verified');
expect(getBenedictusAntiphon(day).status.en).toBe('verified');
expect(getMagnificatAntiphon(day).status.en).toBe('verified');
});
it('correctly finds Easter+69 in a different year (early Easter)', () => {
const day = resolveDay('2027-06-05'); // Easter 2027-03-28 + 69 days
expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' });
});
it('does not apply on the Saturday before or after', () => {
const before = resolveDay('2026-06-06');
const after = resolveDay('2026-06-20');
expect(before.winner).not.toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' });
expect(after.winner).not.toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' });
});
it('no longer wins or appears anywhere on Aug 22, which reverts to the Assumption octave alone', () => {
const day = resolveDay('2026-08-22');
expect(day.winner).not.toEqual(expect.objectContaining({ id: 'immaculate-heart-of-mary' }));
expect(day.commemorations).not.toContainEqual(expect.objectContaining({ id: 'immaculate-heart-of-mary' }));
});
});
+36
View File
@@ -371,3 +371,39 @@ describe('resolveOrdo("matins", ...) Sacred Triduum', () => {
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 }); expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
}); });
}); });
describe('resolveOrdo("matins", ...) Assumption-octave hymn fallback (2026-08-22 fix)', () => {
// Real bug: resolveMatinsHymn's own doc comment already claimed the
// Assumption's octave "keeps the feast's own proper hymn all week," but
// the code only ever matched when the day's own winner literally *was*
// `assumption` (Aug 15 itself) -- every other octave day fell straight
// through to the plain ferial hymn instead. Fixed by adding a real
// octave-fallback tier (resolveActiveOctave -> `matins-hymn-${octave.id}`).
function hymnLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const hymn = ordo.parts.find((p) => p.kind === 'hymn') as { text: { text: { la: string } } } | undefined;
return hymn?.text.text.la;
}
it("St. Bernard's own day (Aug 20, no hymn of his own authored) picks up the Assumption's octave hymn, not the plain ferial one", () => {
expect(hymnLatin('2026-08-20')).toContain('Surge');
});
it('every other interior day governed by the Assumption octave (16, 19, 21 — none with a hymn of their own) also picks up the octave hymn', () => {
for (const date of ['2026-08-16', '2026-08-19', '2026-08-21']) {
expect(hymnLatin(date)).toContain('Surge');
}
});
it("Aug 17 is instead governed by St. Lawrence's own octave (its own elevated closing day, outranking the Assumption's ordinary day here) — no hymn authored for it, so it still falls to the plain ferial one, correctly", () => {
expect(hymnLatin('2026-08-17')).toContain('Somno');
});
it('Aug 22, the octave-closing day (Immaculate Heart of Mary relocated away), also gets the Assumption hymn via the pre-existing sanctoral-substitution path', () => {
expect(hymnLatin('2026-08-22')).toContain('Surge');
});
it("a plain ferial day outside the octave still gets the plain ferial hymn, unaffected", () => {
expect(hymnLatin(FERIAL_DATE)).toContain('Somno');
});
});