From 3f07d6e8d3b418f1c4252628aacb079e942ef091 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Tue, 18 Aug 2026 08:49:51 -0400 Subject: [PATCH] Fix octave day labels: correct commemoration phrasing, closing-day title, and privileged-season precedence Three related day-label bugs, all found via Aug 19's mislabeled Assumption octave commemoration: - A sanctoral winner that displaces the only active octave now shows the octave's real "Nth Day within the Octave of X" phrasing (no rank, since it's riding along under the winning feast), not a bare feast name. - An octave's own closing day now titles itself "Octave of X", matching the real DO's "in octava" vs. "infra octavam" distinction, instead of "8th Day within the Octave of X". - A sufficiently-ranked octave can now outrank a privileged temporal season (e.g. the Immaculate Conception's octave outright winning several of its days against Advent, live-verified against Divino Afflatu 1954), via a new shared octaveGoverningPrivilegedDay helper used by both the day label and the actual office-content resolver. Deliberately excludes Christmastide, whose own stacked octaves are structurally already that season's temporal content rather than a foreign add-on. Updates a Matins test fixture that had unknowingly relied on the Dec 15 bug (Advent ferial + a lone commemorated saint) and splits it into a clean ferial case plus a dedicated commemorated-saint case. Co-Authored-By: Claude Sonnet 5 --- src/calendar/commemorations.ts | 28 ++++++++++ src/calendar/day-label.ts | 93 ++++++++++++++++++++++++-------- src/calendar/index.ts | 4 +- src/calendar/octaves.ts | 44 ++++++++++++++- src/hours/resolve-common.ts | 46 +++++++++------- tests/calendar/day-label.test.ts | 54 ++++++++++++++++++- tests/hours/matins.test.ts | 36 +++++++++---- 7 files changed, 251 insertions(+), 54 deletions(-) diff --git a/src/calendar/commemorations.ts b/src/calendar/commemorations.ts index 5aa684d..e2949e4 100644 --- a/src/calendar/commemorations.ts +++ b/src/calendar/commemorations.ts @@ -38,6 +38,34 @@ export function isAtLeast(rank: FeastClass, threshold: FeastClass): boolean { return compareFeastClass(rank, threshold) >= 0; } +/** + * The minimum rank a real sanctoral candidate needs to win each temporal + * category outright, per `decideOccurrence`'s own branches above — + * factored out so calendar/octaves.ts can ask the identical question of an + * *octave*'s own effective standing (not a real occurring saint) on a day + * where nothing else won. `undefined` where nothing ever wins outright no + * matter how high-ranked (`privileged-feria-major`/`privileged-sunday` — + * Ash Wednesday, Holy Week, a privileged Sunday — only ever get a + * commemoration at best, per those branches above). `ordinary-feria` has + * no real threshold at all (any real content wins), included here as + * `'simplex'` (the weakest real rank) only for completeness — callers + * needing that case already have their own unconditional path and don't + * need to call this. */ +export function minimumOutrightWinningRank(category: TemporalCategory): FeastClass | undefined { + switch (category) { + case 'ordinary-feria': + return 'simplex'; + case 'privileged-feria-minor': + return 'semiduplex'; + case 'privileged-feria': + case 'ordinary-sunday': + return 'duplex'; + case 'privileged-feria-major': + case 'privileged-sunday': + return undefined; + } +} + /** Vigils belong to the day *before* their feast, so an impeded vigil is * shifted backward rather than forward like everything else — see * calendar/transfer.ts. */ diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 2045d8f..43e4ff7 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -14,7 +14,7 @@ import { easterSunday } from './easter'; import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal'; import { addDays, daysBetween, toIsoDate } from './date-math'; import { getTemporalFeastRecord } from './temporal-feasts'; -import { resolveActiveOctave, type ActiveOctave } from './octaves'; +import { activeOctavesFor, octaveGoverningPrivilegedDay, resolveActiveOctave, type ActiveOctave } from './octaves'; import { resolveTemporalId } from './temporal-id'; function capitalize(text: string): string { @@ -210,15 +210,36 @@ function temporalLabel(day: LiturgicalDay): string { return `${weekdayName} in the ${ordinalStr} week ${config.preposition} ${config.ordinalName}`; } -/** "Third Day within the Octave of St. Lawrence" — the real DO title an - * octave day carries on its own (e.g. "Tertia die infra Octavam S. - * Laurentii Martyris") when nothing else has displaced it. Day 1 shouldn't - * normally reach this (that day's own winner is the feast itself, handled +/** "Third Day within the Octave of St. Lawrence" for an ordinary mid-octave + * day (e.g. "Tertia die infra Octavam S. Laurentii Martyris"), or plain + * "Octave of St. Lawrence" for the octave's own closing day — real DO + * Latin distinguishes "infra octavam" (within the octave, days 2-7) from + * "in octava" (on the octave day itself, day 8), live-verified: Aug 17 + * (St. Lawrence's closing day) titles itself "In Octava S. Laurentii + * Martyris", not "Octava die infra Octavam...". Day 1 shouldn't normally + * reach either branch (that day's own winner is the feast itself, handled * above before this is ever called) — kept simple rather than * special-cased for that rare edge case (see applyOctaves's own * `isOwnStartDay` comment in calendar/index.ts for when it can happen). */ +function octaveCoreName(octave: ActiveOctave): string { + return octave.isClosingDay ? `Octave of ${octave.name}` : `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name}`; +} + function octaveLabel(octave: ActiveOctave): string { - return `${ordinal(octave.dayNumber)} Day within the Octave of ${octave.name} (${formatRank(octave.wins)})`; + return `${octaveCoreName(octave)} (${formatRank(octave.wins)})`; +} + +/** Same phrasing as `octaveLabel`, but without the parenthesized rank — + * the real DO commemoration line for a displaced octave day carries no + * rank at all (e.g. Aug 19's real Divino Afflatu 1954 commemoration reads + * plain "Quinta die infra Octavam S. Assumptionis Beatæ Mariæ Virginis", + * no "~ Semiduplex"). `octaveLabel`'s own rank parenthetical only belongs + * to an octave day when it's the day's own primary winner (rank is what + * it *won as*, not a fact worth restating once a duplex-or-higher saint + * has displaced it and it's just riding along as a commemoration + * instead). */ +function octaveCommemorationLabel(octave: ActiveOctave): string { + return octaveCoreName(octave); } /** Every `kind: 'octave'` commemoration on `day` other than `excludeId` — @@ -239,6 +260,27 @@ function otherActiveOctaveNames(day: LiturgicalDay, excludeId: string | undefine .map((c) => c.name); } +/** Same commemoration-filtering as `otherActiveOctaveNames`, but rendered + * with `octaveCommemorationLabel`'s fuller "Nth Day within the Octave of X" + * phrasing rather than a plain name — the real DO title an octave day + * carries even when it *lost* outright to an occurring saint (e.g. Aug 19's + * real Divino Afflatu 1954 title is "S. Joannis Eudes Confessoris ~ Duplex" + * with the commemoration read as "Quinta die infra Octavam S. Assumptionis + * Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed Virgin + * Mary"). Only used from the sanctoral-winner branch below — the + * octave-vs-octave secondary mention (`otherActiveOctaveNames` itself, + * still used in the octave-headline branch further down) keeps its plain + * name on purpose, per that function's own doc comment. */ +function commemoratedOctaveDayLabels(day: LiturgicalDay, excludeId: string | undefined): string[] { + const active = activeOctavesFor(day.date); + return day.commemorations + .filter((c): c is Extract => c.kind === 'octave' && c.id !== excludeId) + .map((c) => { + const octave = active.find((a) => a.id === c.id); + return octave ? octaveCommemorationLabel(octave) : c.name; + }); +} + const RANK_LABELS: Record = { simplex: 'Simplex', vigil: 'Vigil', @@ -295,7 +337,7 @@ export function getDayLabel(day: LiturgicalDay): string { // also day 8 of Pentecost's own octave) never mentions an octave — // same "nobody calls it that" convention anchorDayName's own doc // comment already established for the anchor-day case. - const otherOctaves = day.temporalCategory === 'ordinary-feria' ? otherActiveOctaveNames(day, undefined) : []; + const otherOctaves = day.temporalCategory === 'ordinary-feria' ? commemoratedOctaveDayLabels(day, undefined) : []; const winnerName = `${day.winner.name} (${formatRank(day.winner.rank)})`; return [winnerName, ...otherOctaves].join(' — '); } @@ -326,24 +368,33 @@ export function getDayLabel(day: LiturgicalDay): string { // identity in the live engine, not a footnote — e.g. "Tertia die infra // Octavam S. Laurentii Martyris", not "Wednesday in the 11th week after // Trinity" — but *only* when the temporal day itself has no standing of - // its own (`ordinary-feria`), same gate as hours/resolve-common.ts's + // its own (`ordinary-feria`), or a foreign octave's own effective rank + // is strong enough to clear a privileged day's real threshold anyway + // (`octaveGoverningPrivilegedDay` — the Immaculate Conception's own + // octave outright winning several of its days against Advent's + // privileged-feria-minor ferias, live-verified: Dec 9/10/12/14 at the + // octave's ordinary Semiduplex, Dec 15 at its own elevated Duplex majus + // closing day). Same gate as hours/resolve-common.ts's // resolveOfficeWinner and for the same reason: live-verified // counterexample is the Christmas Octave's own stack (Dec 30, e.g., // `privileged-feria-minor`), where the real title stays the temporal // Sunday's own ("De Dominica Infra Octavam Nativitatis") with no octave - // name in it at all — this label agreeing with resolveOfficeWinner - // about which one wins is what makes "the office is Lawrence's" and - // "the label says Lawrence" consistent instead of two independent - // guesses that can disagree. When more than one octave is active at - // once (resolveActiveOctave), the highest-ranked wins the headline - // (ties broken by whichever started more recently) — every other - // active octave still gets named too (otherActiveOctaveNames), not - // dropped: live-verified real case, Aug 17 -- St. Lawrence's own - // elevated closing day wins the headline, but the Assumption's own - // day 3 (a real, distinct, simultaneously-active octave, not a - // duplicate of Lawrence's) still belongs in the label alongside St. - // Hyacinth's commemoration. - const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined; + // name in it at all, regardless of rank — `octaveGoverningPrivilegedDay` + // excludes `christmastide` for exactly this reason (see its own doc + // comment) — this label agreeing with resolveOfficeWinner about which + // one wins is what makes "the office is Lawrence's" and "the label says + // Lawrence" consistent instead of two independent guesses that can + // disagree. When more than one octave is active at once + // (resolveActiveOctave), the highest-ranked wins the headline (ties + // broken by whichever started more recently) — every other active + // octave still gets named too (otherActiveOctaveNames), not dropped: + // live-verified real case, Aug 17 -- St. Lawrence's own elevated + // closing day wins the headline, but the Assumption's own day 3 (a + // real, distinct, simultaneously-active octave, not a duplicate of + // Lawrence's) still belongs in the label alongside St. Hyacinth's + // commemoration. + const activeOctave = + day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : octaveGoverningPrivilegedDay(day); if (activeOctave) { const primary = octaveLabel(activeOctave); const rest = [...otherActiveOctaveNames(day, activeOctave.id), ...(commemoratedSaint ? [commemoratedSaint.name] : [])]; diff --git a/src/calendar/index.ts b/src/calendar/index.ts index aad0914..cca3c51 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -356,10 +356,10 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean { return day.weekday === 'sunday' || day.winner.kind === 'sanctoral'; } -export { compareFeastClass, isAtLeast, decideOccurrence } from './commemorations'; +export { compareFeastClass, isAtLeast, decideOccurrence, minimumOutrightWinningRank } from './commemorations'; export { resolveCollision } from './collision'; export { resolveTemporalId } from './temporal-id'; -export { activeOctavesFor, strictestThreshold, resolveActiveOctave } from './octaves'; +export { activeOctavesFor, strictestThreshold, resolveActiveOctave, octaveGoverningPrivilegedDay } from './octaves'; export type { ActiveOctave } from './octaves'; export type { LiturgicalDay, diff --git a/src/calendar/octaves.ts b/src/calendar/octaves.ts index f3837d1..dbbed65 100644 --- a/src/calendar/octaves.ts +++ b/src/calendar/octaves.ts @@ -8,11 +8,11 @@ // contest is decided, it just adds commemorations on top of whatever that // contest already produced, and occasionally overrides the winner when a // too-minor saint would otherwise have taken the day from it. -import type { FeastClass, OctaveConfig } from './types'; +import type { FeastClass, LiturgicalDay, OctaveConfig } from './types'; import { getSanctoralCandidatesFor, getSaintRecord } from './feasts'; import { getTemporalFeastRecord, temporalFeastIdsStartingOn } from './temporal-feasts'; import { addDays, daysBetween } from './date-math'; -import { compareFeastClass } from './commemorations'; +import { compareFeastClass, minimumOutrightWinningRank } from './commemorations'; export interface ActiveOctave { id: string; @@ -131,6 +131,46 @@ export function resolveActiveOctave(isoDate: string): ActiveOctave | undefined { return pickWinningOctave(activeOctavesFor(isoDate)); } +/** + * Whether an active octave's own effective rank clears the bar a real + * sanctoral candidate would need to win `day`'s temporal category outright + * — i.e. whether the octave governs the day's content/label even though + * no *saint* occurring there was strong enough to (a "foreign" octave + * superimposed on a season it doesn't belong to, e.g. the Immaculate + * Conception's own octave running Dec 8-15, squarely inside Advent). + * Live-verified against Divino Afflatu 1954: Dec 9/10/12/14 (ordinary + * octave days, Semiduplex) and Dec 15 (the octave's own elevated closing + * day, Duplex majus) all win outright over Advent's own privileged-feria- + * minor ferias, exactly the same threshold `decideOccurrence` already + * uses for a real Semiduplex+ saint there (St. Nicholas, Dec 6). + * + * Deliberately excludes `ordinary-feria` (unconditional there already — + * any active octave governs regardless of rank, handled separately by + * each caller via `resolveActiveOctave` directly) and `christmastide` + * (Christmas's own stacked octaves — Christmas, St. Stephen, St. John, + * Holy Innocents — are structurally already that season's own temporal + * content, not a foreign add-on contesting it the way the Immaculate + * Conception's octave contests Advent: live-verified, Dec 30 keeps + * "Dominica Infra Octavam Nativitatis" as the real DO title regardless of + * actual weekday or which of the four octaves is active, never an octave + * name — see calendar/day-label.ts's and hours/resolve-common.ts's own + * Dec-30 doc comments for the fuller reasoning already established + * there). */ +export function octaveGoverningPrivilegedDay(day: LiturgicalDay): ActiveOctave | undefined { + if (day.temporalCategory === 'ordinary-feria' || day.season === 'christmastide') { + return undefined; + } + const minRank = minimumOutrightWinningRank(day.temporalCategory); + if (!minRank) { + return undefined; + } + const octave = resolveActiveOctave(day.date); + if (octave && compareFeastClass(octave.wins, minRank) >= 0) { + return octave; + } + return undefined; +} + /** The comparison itself, factored out from resolveActiveOctave so the * precedence rule (rank, then recency) is directly unit-testable against * synthetic ActiveOctave data — no real equal-rank overlap exists yet in diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index f57e1b4..da162d4 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calend import type { ProperText } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; -import { resolveActiveOctave, activeOctavesFor, isAtLeast } from '../calendar'; +import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { splitAntiphon } from './antiphon'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; @@ -40,25 +40,35 @@ export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ- * Which identity's own propers actually supply the office's content * (collect, Benedictus antiphon, psalmody override) — usually just * `day.winner`, but *not* on a day within an active octave where the - * temporal day itself has no real standing of its own (`ordinary-feria`) - * and the winner stayed temporal — an ordinary day within St. Lawrence's - * own octave, e.g.: live-verified (Tridentine 1910, 2026-08-12) that the + * temporal day itself has no real standing of its own (`ordinary-feria`), + * or where a *foreign* octave's own effective rank is strong enough to + * clear even a privileged day's real threshold + * (`octaveGoverningPrivilegedDay` — the Immaculate Conception's own + * octave outright winning several of its days against Advent's + * privileged-feria-minor ferias, live-verified: Dec 9/10/12/14 at the + * octave's ordinary Semiduplex, Dec 15 at its own elevated Duplex majus + * closing day) — an ordinary day within St. Lawrence's own octave, e.g.: + * live-verified (Tridentine 1910, 2026-08-12) that the * chapter/responsory/hymn/versicle, psalms' antiphons, Benedictus * antiphon, and day collect all come from the octave's own feast * ("{ex Commune aut Festo}" / "{ex Proprio Sanctorum}"), not from the * plain temporal day underneath it. * - * Deliberately gated on `ordinary-feria` specifically, not just "an - * octave is active": the Christmas Octave's own stacked octaves - * (Christmas + Stephen + John + Holy Innocents, e.g. on Dec 30) are the - * live-verified counterexample — that day's own temporal identity - * ("Dominica Infra Octavam Nativitatis", `privileged-feria-minor`) is - * itself a real, named standing, and *keeps* the office - * ("{ex Proprio de Tempore}"); the four octaves there each become their - * own separate "Commemoratio Octavæ ..." block instead (not yet modeled - * — see TODO.md), rather than any one of them taking over content the - * way Lawrence's octave does. The dividing line is real standing, not - * merely "is an octave active." + * Deliberately excludes `christmastide` even though it's otherwise a + * `privileged-feria-minor` season same as Advent: the Christmas Octave's + * own stacked octaves (Christmas + Stephen + John + Holy Innocents, e.g. + * on Dec 30) are the live-verified counterexample — that day's own + * temporal identity ("Dominica Infra Octavam Nativitatis", + * `privileged-feria-minor`) is itself a real, named standing, and + * *keeps* the office ("{ex Proprio de Tempore}") regardless of any of + * those octaves' own rank; the four octaves there each become their own + * separate "Commemoratio Octavæ ..." block instead (not yet modeled — + * see TODO.md), rather than any one of them taking over content the way + * Lawrence's or the Immaculate Conception's octave does. The dividing + * line is real standing (own temporal identity vs. a foreign octave + * merely overlapping a season's ordinary ferias), not merely "is an + * octave active" — see `octaveGoverningPrivilegedDay`'s own doc comment + * for the fuller reasoning. * * `day.winner`/`day.commemorations` themselves stay exactly as * calendar/index.ts computed them either way — this is purely a @@ -71,9 +81,6 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner { if (day.winner.kind === 'sanctoral' || ALWAYS_OVERRIDE_TEMPORAL_IDS.has(day.winner.id)) { return day.winner; } - if (day.temporalCategory !== 'ordinary-feria') { - return day.winner; - } // When more than one octave is active at once (St. Lawrence's and the // Assumption's genuinely overlap every Aug 16-17), resolveActiveOctave // picks the one that actually governs the day — see its own doc @@ -81,7 +88,8 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner { // default if that octave turns out to be a temporal-only one with no // saint record (e.g. Christmas's or Pentecost's own octave id) — no // sanctoral content to synthesize from those. - const activeOctave = resolveActiveOctave(day.date); + const activeOctave = + day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : octaveGoverningPrivilegedDay(day); if (activeOctave) { const saint = getSaintRecord(activeOctave.id); if (saint) { diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 2b8763f..e55ee30 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -173,7 +173,7 @@ describe('getDayLabel — active octave', () => { // day 3 (semiduplex, genuinely active but outranked by Lawrence's // elevated duplex) still gets named, not dropped, alongside it. expect(getDayLabel(resolveDay('2026-08-17'))).toBe( - '8th Day within the Octave of St. Lawrence, Martyr (Duplex) — The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', + 'Octave of St. Lawrence, Martyr (Duplex) — The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', ); expect(getDayLabel(resolveDay('2026-08-18'))).toBe( '4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)', @@ -187,6 +187,58 @@ describe('getDayLabel — active octave', () => { expect(day.weekday).toBe('sunday'); expect(getDayLabel(day)).not.toContain('Octave'); }); + + it("a foreign octave superimposed on a privileged season still governs the label when its own effective rank clears that season's real threshold, with the season's own losing saint commemorated alongside it", () => { + // The Immaculate Conception's own octave (Dec 8-15) always falls + // inside Advent, a `privileged-feria-minor` season -- unlike every + // other octave here (Lawrence/Assumption/All Saints/Nativity BVM), + // which always sit entirely within ordinary time. Live-verified + // (Divino Afflatu 1954): Dec 15, the octave's own closing day, is + // "In Octava Concept. Immac. Beatæ Mariæ Virginis ~ Duplex majus" -- + // outright winning against Advent -- with St. Eusebius of Vercelli + // (Semiduplex, real per calendar/data/calendar/saints/ + // st-eusebius-of-vercelli.yml -- Monastic Tridentinum 1617 places him + // here via a same-day kalendar redirect, not Dec 16 like the general + // Roman calendar) merely commemorated, not winning, since he's below + // the octave's own closing-day threshold. See + // calendar/octaves.ts's octaveGoverningPrivilegedDay for the shared + // mechanism (also used by hours/resolve-common.ts's + // resolveOfficeWinner for the actual office content, not just this + // label). + expect(getDayLabel(resolveDay('2026-12-15'))).toBe( + 'Octave of The Immaculate Conception of the Blessed Virgin Mary (Duplex) — St. Eusebius of Vercelli, Bishop and Martyr', + ); + }); + + it("the Christmas Octave's own stack never lets any of its four constituent octaves outrank Christmastide's own temporal identity, even though it's the same privileged-feria-minor category the Immaculate Conception's octave *does* outrank Advent with", () => { + // Same live-verified Dec 30 counterexample as the test above (Friday + // in Christmastide) -- restated here specifically against the new + // octaveGoverningPrivilegedDay mechanism, to pin down that its + // `christmastide` exclusion actually holds now that privileged + // categories are no longer an unconditional "never" for every octave. + const day = resolveDay('2033-12-30'); + expect(day.temporalCategory).toBe('privileged-feria-minor'); + expect(day.season).toBe('christmastide'); + expect(getDayLabel(day)).not.toContain('Octave'); + }); + + it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => { + // Live-verified against the reference engine (Divino Afflatu 1954, + // prayMatutinum): Aug 19 is "S. Joannis Eudes Confessoris ~ Duplex" + // with the commemoration read as plain "Quinta die infra Octavam S. + // Assumptionis Beatæ Mariæ Virginis" -- no rank at all -- i.e. the + // octave's real DO title even when it lost outright, not a bare "The + // Assumption of the Blessed Virgin Mary" (this app's previous + // behavior, reusing `otherActiveOctaveNames`'s plain-name formatting + // -- correct for a *secondary* octave alongside another *octave* that + // won, see the Aug 17/18 cases above, but wrong here where the octave + // itself is what's being commemorated against a winning saint) and + // also not `octaveLabel`'s own rank parenthetical (that belongs only + // to an octave day when it's the day's own primary winner). + expect(getDayLabel(resolveDay('2026-08-19'))).toBe( + 'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary', + ); + }); }); describe('getDayLabel — named temporal feast rank', () => { diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 2718197..91595f7 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -6,7 +6,12 @@ import { getPsalmsFor } from '../../src/psalter/distribution'; // and TODO.md for why this is a mechanism build with a small content slice, // not full-calendar content. Both live-verified against the reference // engine (Monastic Tridentinum 1617) before authoring. -const FERIAL_DATE = '2026-12-15'; // Tuesday of Advent III — plain ferial, no feast collision. +const FERIAL_DATE = '2026-12-01'; // Tuesday of Advent I — plain ferial, no feast collision. +// Not Dec 15 (formerly used here): that's actually the Immaculate +// Conception's own octave closing day (Duplex majus per Divino Afflatu +// 1954), which now correctly wins outright over Advent's own +// privileged-feria-minor ferias — see calendar/octaves.ts's +// octaveGoverningPrivilegedDay — so it's no longer a clean ferial date. const SUNDAY_DATE = '2026-09-06'; // 14th Sunday after Trinity — plain Sunday, no feast collision. describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => { @@ -37,21 +42,34 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => { expect(ordo.parts.some((p) => p.kind === 'canticle')).toBe(false); }); - it("resolves the user's own bible-plan readings for Advent III Tuesday, not the historical lectionary", () => { + it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => { const lessons = ordo.parts.filter((p) => p.kind === 'lesson'); - // 2 bible-plan readings, plus a 3rd: St. Eusebius of Vercelli's own - // commemoration reading, generously surfaced per the "commemorations - // get their own reading" design (see hours/matins.ts's own header) — - // vu places him Dec 15 (a Monastic 1617 same-day redirect), commemorated - // under this Advent III Tuesday ferial. - expect(lessons).toHaveLength(3); + // Plain 2 bible-plan readings on this particular clean ferial (no + // commemorated saint here) — see the next test for the 3-reading case. + expect(lessons).toHaveLength(2); const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en); - expect(citations).toEqual(['Isa 36; Isa 37', 'Sap 14', undefined]); + expect(citations).toEqual(['Isa 6; Isa 7', 'Sap 2']); // Real content isn't imported yet (Vulgate/Douay-Rheims bulk import is // a deferred follow-on) — honest `missing`, not fabricated text. expect((lessons[0] as { text: { status: { en?: string } } }).text.status.en).toBe('missing'); }); + it("adds a 3rd reading for a commemorated saint, generously surfaced per the 'commemorations get their own reading' design (see hours/matins.ts's own header)", () => { + // Not FERIAL_DATE: needs an ordinary Advent ferial with a real + // commemorated saint and no active octave. St. Bibiana (Simplex) is + // the exact case calendar/commemorations.ts's own header cites as + // live-verified: merely commemorated under an Advent feria, never + // winning outright (Simplex is below privileged-feria-minor's own + // Semiduplex threshold). 2025-12-02, a Tuesday, keeps the same 9-psalm + // ferial shape as the other tests in this block. + const commemoratedOrdo = resolveOrdo('matins', '2025-12-02'); + const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson'); + expect(lessons).toHaveLength(3); + const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en); + expect(citations).toEqual(['Isa 6; Isa 7', 'Sap 2', undefined]); + expect((lessons[0] as { text: { status: { en?: string } } }).text.status.en).toBe('missing'); + }); + it("matches the first reading (Isaiah) against the seeded per-book responsory pool", () => { const lessons = ordo.parts.filter((p) => p.kind === 'lesson'); const first = lessons[0] as { responsory?: { text: { la?: string } } };