Implement octave-vs-octave precedence, and fix a real day-label bug it surfaced
Deploy / deploy (push) Successful in 52s
Deploy / deploy (push) Successful in 52s
When more than one octave is active on a day with no temporal standing of
its own (St. Lawrence's and the Assumption's genuinely overlap every Aug
16-17), which one governs the day's content/label is now decided by rank
comparison instead of "whichever started first" (an accident of
insertion order). Per direct instruction:
- Highest effective rank wins outright; every other active octave still
gets commemorated, same as a single octave already outranking a weak
rival saint.
- A rank tie goes to whichever octave started more recently -- day 1 of
a new octave needs to be fully present, the whole point of it
starting. No real tied-rank case exists yet to verify this
empirically, unlike everything else here -- documented in TODO.md as
a stated decision, not a live finding.
New OctaveConfig.closingDayRank (default duplex): an octave's own final
day ("in Octava") is elevated above its ordinary in-between rank --
live-verified as a real, general pattern (both St. Lawrence's Aug 17 and
the Assumption's Aug 22 show as Duplex, above their otherwise-Semiduplex
ordinary days), not a one-off. This elevation is *why* Lawrence's octave
beats the Assumption's on their one real overlap day despite the
Assumption being the far higher-ranked feast overall. Feeds both the
existing rival-saint threshold and the new octave-vs-octave comparison.
calendar/octaves.ts gains resolveActiveOctave (+ pickWinningOctave, the
comparison itself factored out for direct unit testing against synthetic
data, since no real tied-rank overlap exists to test against yet).
hours/resolve-common.ts's resolveOfficeWinner and calendar/day-label.ts
both now call it instead of each keeping their own "activeOctavesFor(...)
[0]" logic.
That consolidation surfaced a real, independent bug: getDayLabel never
checked temporalCategory at all before choosing an octave name, unlike
resolveOfficeWinner -- found while testing the real Aug 16 overlap (a
Sunday that year, where the temporal Sunday has standing and should win
outright). Live-verified counterexample: the Christmas Octave's own
stack (Dec 30) was wrongly labeled "3rd Day within the Octave of The Holy
Innocents" instead of the correct plain temporal label -- the real title
never names any of the four stacked octaves there. Fixed by sharing the
exact same ordinary-feria gate resolveOfficeWinner already had, so the
two can no longer disagree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,26 @@ describe('getDayLabel — active octave', () => {
|
||||
it("a season's own named anchor day outranks an active octave, even though Trinity Sunday is technically also day 8 of Pentecost's own octave", () => {
|
||||
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday');
|
||||
});
|
||||
|
||||
it("a temporal day with real standing of its own (not ordinary-feria) never shows an octave name at all, even with one active -- live-verified counterexample: the Christmas Octave's own stack (Dec 30) titles itself off the Sunday, never any of the four octaves layered on top of it", () => {
|
||||
const day = resolveDay('2033-12-30');
|
||||
expect(day.temporalCategory).toBe('privileged-feria-minor');
|
||||
expect(getDayLabel(day)).not.toContain('Octave');
|
||||
expect(getDayLabel(day)).toBe('Friday in Christmastide');
|
||||
});
|
||||
|
||||
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall", () => {
|
||||
expect(getDayLabel(resolveDay('2026-08-17'))).toBe('8th Day within the Octave of St. Lawrence, Martyr');
|
||||
expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary');
|
||||
});
|
||||
|
||||
it('a real Sunday with standing of its own wins over both octaves entirely, showing the plain ordinal Sunday label -- not an octave name at all', () => {
|
||||
// 2026-08-16 is a Sunday genuinely within both St. Lawrence's and the
|
||||
// Assumption's overlapping octave windows.
|
||||
const day = resolveDay('2026-08-16');
|
||||
expect(day.weekday).toBe('sunday');
|
||||
expect(getDayLabel(day)).not.toContain('Octave');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDayLabel — feast name combination', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activeOctavesFor, strictestThreshold } from '../../src/calendar/octaves';
|
||||
import { activeOctavesFor, strictestThreshold, resolveActiveOctave, pickWinningOctave } from '../../src/calendar/octaves';
|
||||
import type { ActiveOctave } from '../../src/calendar/octaves';
|
||||
|
||||
describe('activeOctavesFor', () => {
|
||||
it('finds nothing outside any octave window', () => {
|
||||
@@ -11,10 +12,12 @@ describe('activeOctavesFor', () => {
|
||||
expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]);
|
||||
|
||||
// Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) --
|
||||
// the two run concurrently this time of year.
|
||||
// the two run concurrently this time of year. Day 8 is Lawrence's own
|
||||
// closing day ("in Octava"), elevated to duplex -- live-verified real
|
||||
// pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig).
|
||||
const day8 = activeOctavesFor('2026-08-17');
|
||||
expect(day8).toEqual([
|
||||
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 8 },
|
||||
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8 },
|
||||
{ id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary', wins: 'semiduplex', dayNumber: 3 },
|
||||
]);
|
||||
|
||||
@@ -40,3 +43,39 @@ describe('activeOctavesFor', () => {
|
||||
expect(octaves.map((o) => o.dayNumber)).toEqual([5, 4, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave precedence', () => {
|
||||
it('returns undefined when no octave is active, and the sole octave when only one is', () => {
|
||||
expect(resolveActiveOctave('2026-07-15')).toBeUndefined();
|
||||
expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence');
|
||||
});
|
||||
|
||||
it("St. Lawrence's own elevated closing day (duplex) outranks the Assumption's ordinary day 3 (semiduplex) on their one real overlap, Aug 17", () => {
|
||||
const winner = resolveActiveOctave('2026-08-17');
|
||||
expect(winner?.id).toBe('st-lawrence');
|
||||
expect(winner?.wins).toBe('duplex');
|
||||
});
|
||||
|
||||
it("gives way to the Assumption once Lawrence's own octave has ended (Aug 18-22)", () => {
|
||||
expect(resolveActiveOctave('2026-08-18')?.id).toBe('assumption');
|
||||
});
|
||||
|
||||
// pickWinningOctave directly, since no real equal-rank overlap exists yet
|
||||
// in this app's own calendar to exercise the tie-break against live data.
|
||||
const higher = (dayNumber: number): ActiveOctave => ({ id: 'higher', name: 'Higher', wins: 'duplex', dayNumber });
|
||||
const lower = (dayNumber: number): ActiveOctave => ({ id: 'lower', name: 'Lower', wins: 'semiduplex', dayNumber });
|
||||
const equalA = (dayNumber: number): ActiveOctave => ({ id: 'equal-a', name: 'Equal A', wins: 'duplex', dayNumber });
|
||||
const equalB = (dayNumber: number): ActiveOctave => ({ id: 'equal-b', name: 'Equal B', wins: 'duplex', dayNumber });
|
||||
|
||||
it('picks the higher-ranked octave regardless of order or start date', () => {
|
||||
expect(pickWinningOctave([lower(2), higher(6)])?.id).toBe('higher');
|
||||
expect(pickWinningOctave([higher(6), lower(2)])?.id).toBe('higher');
|
||||
});
|
||||
|
||||
it('on a tied rank, picks the more recently started octave (smaller dayNumber) -- per direct instruction: day 1 of a newly started octave needs to be fully present, the whole point of it starting; the older, tied-rank octave loses and is commemorated instead', () => {
|
||||
// equalB started later (day 2, vs equalA's day 5 -- fewer days have
|
||||
// passed since it began) and should win the tie.
|
||||
expect(pickWinningOctave([equalA(5), equalB(2)])?.id).toBe('equal-b');
|
||||
expect(pickWinningOctave([equalB(2), equalA(5)])?.id).toBe('equal-b');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user