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 } } };