Fix octave day labels: correct commemoration phrasing, closing-day title, and privileged-season precedence
Deploy / deploy (push) Successful in 1m4s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:49:51 -04:00
parent 427ba6756a
commit 3f07d6e8d3
7 changed files with 251 additions and 54 deletions
+53 -1
View File
@@ -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', () => {
+27 -9
View File
@@ -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 } } };