diff --git a/src/calendar/collision.ts b/src/calendar/collision.ts new file mode 100644 index 0000000..3632ac1 --- /dev/null +++ b/src/calendar/collision.ts @@ -0,0 +1,40 @@ +// Resolves two sanctoral candidates wanting the same day — the case +// calendar/feasts.ts explicitly doesn't cover (it just picks the +// higher-ranked of several native candidates and drops the rest) and that +// calendar/commemorations.ts doesn't cover either (that's temporal-vs- +// sanctoral, this is sanctoral-vs-sanctoral). This is specifically what a +// transferred feast (calendar/transfer.ts) runs into when its landing day +// already has a native saint — never triggered by a plain ferial landing, +// since a genuinely empty day isn't a collision at all. +// +// Same ordering as commemorations.ts's FeastClass scale, which is exactly +// the point: `duplex > semiduplex > vigil > simplex` is that scale, not a +// separate one — see calendar/types.ts's FeastClass doc comment. +import type { Commemoration, DayWinner, SanctoralIdentity } from './types'; +import { compareFeastClass } from './commemorations'; + +export interface CollisionResult { + winner: DayWinner; + commemorations: Commemoration[]; +} + +function asWinner(candidate: SanctoralIdentity): DayWinner { + return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; +} + +function asCommemoration(candidate: SanctoralIdentity): Commemoration { + return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; +} + +/** + * `incoming` is the transferred candidate; `native` is whoever already + * occupies the landing day. The loser is always commemorated — unlike + * commemorations.ts's Sunday rules, there's no rank threshold below which + * it gets nothing; a real named feast landing anywhere always leaves a + * trace. Ties favor `native` — the incoming feast is the guest here. + */ +export function resolveCollision(incoming: SanctoralIdentity, native: SanctoralIdentity): CollisionResult { + const winner = compareFeastClass(incoming.rank, native.rank) > 0 ? incoming : native; + const loser = winner === incoming ? native : incoming; + return { winner: asWinner(winner), commemorations: [asCommemoration(loser)] }; +} diff --git a/src/calendar/commemorations.ts b/src/calendar/commemorations.ts index 91e2bdf..e19bb96 100644 --- a/src/calendar/commemorations.ts +++ b/src/calendar/commemorations.ts @@ -1,19 +1,24 @@ // Precedence/occurrence resolution: given a day's temporal-cycle standing // (calendar/types.ts's TemporalCategory) and a candidate sanctoral feast (if -// any), which one is actually kept, and whether the loser is commemorated. +// any), which one is actually kept, what (if anything) is commemorated +// alongside it, and whether the candidate needs to be transferred to an +// adjacent day instead (see calendar/transfer.ts for what actually happens +// with that signal). // // Modeled as explicit rules per category, not a numeric-weight comparison // like the reference engine's occurrence()/concurrence() — see -// data/calendar/temporal-categories.yml's header for why. The thresholds -// below are a best-effort reconstruction of the pre-1955 tradition (partly -// grounded in General Rubrics §15/§16/§23, though that document is itself a -// later, differently-numbered edition — see that file's header), not -// verified against a primary source for this exact era. Expect corrections. +// data/calendar/temporal-categories.yml's header for why. Verified in part +// against real dates (the Sunday-vs-Duplex threshold below was corrected +// after finding St. Anthony Abbot, plain Duplex, outright winning against +// an ordinary Sunday in the real Monastic 1617 engine — a genuine +// correction, not a guess); the rest is the result of a design discussion, +// not yet independently verified against a primary source. -import type { FeastClass, TemporalCategory } from './types'; +import type { Commemoration, DayWinner, FeastClass, SanctoralIdentity, TemporalCategory } from './types'; const FEAST_CLASS_ORDER: FeastClass[] = [ 'simplex', + 'vigil', 'semiduplex', 'duplex', 'duplex-majus', @@ -29,47 +34,91 @@ export function isAtLeast(rank: FeastClass, threshold: FeastClass): boolean { return compareFeastClass(rank, threshold) >= 0; } +/** 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. */ +function transferDirectionOf(rank: FeastClass): 'forward' | 'backward' { + return rank === 'vigil' ? 'backward' : 'forward'; +} + export interface OccurrenceResult { - winner: 'temporal' | 'sanctoral'; - commemorated: boolean; + /** This day's own resolution, with the candidate absent if it's being transferred. */ + winner: DayWinner; + commemorations: Commemoration[]; + /** Set when `sanctoral` doesn't win or get commemorated here at all — it + * needs to be resolved against an adjacent day instead. */ + transfer?: { candidate: SanctoralIdentity; direction: 'forward' | 'backward' }; +} + +function sanctoralCommemoration(candidate: SanctoralIdentity): Commemoration { + return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; +} + +function sanctoralWinner(candidate: SanctoralIdentity): DayWinner { + return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; } /** - * `sanctoral === null` means no feast is assigned to this date at all — - * temporal wins trivially, nothing to decide. + * `temporalId` is this day's own temporal-propers id (see + * calendar/temporal-id.ts) — needed so a temporal winner or commemoration + * can actually be looked up later (`propers.getTemporalProper`), not just + * named "temporal" in the abstract. */ -export function decideOccurrence(temporal: TemporalCategory, sanctoral: FeastClass | null): OccurrenceResult { +export function decideOccurrence( + temporal: TemporalCategory, + temporalId: string, + sanctoral: SanctoralIdentity | null, +): OccurrenceResult { + const temporalWinner: DayWinner = { kind: 'temporal', id: temporalId }; if (!sanctoral) { - return { winner: 'temporal', commemorated: false }; + return { winner: temporalWinner, commemorations: [] }; } switch (temporal) { case 'ordinary-feria': // An ordinary feria has no standing of its own to defend — any real // feast, however low-ranked, is kept in its place. - return { winner: 'sanctoral', commemorated: false }; + return { winner: sanctoralWinner(sanctoral), commemorations: [] }; case 'privileged-feria': - // "These ferias are preferred to any feasts whatsoever, and they - // admit of no commemoration, except one of the privileged class" - // (General Rubrics §23) — read here as: only the very highest class - // even gets a mention. - return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-1-classis') }; + // Preferred to any feast whatsoever; admits no commemoration except + // one of the very highest class. + return { + winner: temporalWinner, + commemorations: isAtLeast(sanctoral.rank, 'duplex-1-classis') ? [sanctoralCommemoration(sanctoral)] : [], + }; case 'ordinary-sunday': - // An ordinary Sunday yields outright only to the highest class; a - // Double of the 2nd Class or a Greater Double is kept as a - // commemoration instead of displacing the Sunday; anything lower - // isn't even mentioned. - if (isAtLeast(sanctoral, 'duplex-1-classis')) { - return { winner: 'sanctoral', commemorated: false }; + if (isAtLeast(sanctoral.rank, 'duplex')) { + // Duplex+ wins outright; the Sunday itself is commemorated in return. + return { winner: sanctoralWinner(sanctoral), commemorations: [{ kind: 'temporal', id: temporalId }] }; } - return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-majus') }; + if (sanctoral.rank === 'simplex') { + // Too minor to warrant its own day, but a plain commemoration + // doesn't cheapen it the way it would a Semiduplex. + return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] }; + } + // Semiduplex or Vigil: no room here at all, in either direction — + // better to preserve the feast whole on another day than downgrade + // it to a bare commemoration. + return { + winner: temporalWinner, + commemorations: [], + transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) }, + }; case 'privileged-sunday': - // "A Sunday of the 1st class is preferred to any feast whatsoever" - // (General Rubrics §15) — never displaced; commemorated only if the - // feast is otherwise of the very top ranks. - return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-2-classis') }; + if (isAtLeast(sanctoral.rank, 'duplex-majus')) { + // Never displaced, but a sufficiently high feast still gets a nod. + return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] }; + } + // Below duplex-majus, a privileged Sunday wants nothing at all — + // not even the bare commemoration an ordinary Sunday would allow a + // Simplex — so everything here transfers. + return { + winner: temporalWinner, + commemorations: [], + transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) }, + }; } } diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index a9d192e..e42037a 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -132,12 +132,11 @@ function temporalLabel(day: LiturgicalDay): string { * came from for the three cases. */ export function getDayLabel(day: LiturgicalDay): string { - const winner = day.occurring.find((feast) => !feast.commemorated); - if (winner) { - return winner.name; + if (day.winner.kind === 'sanctoral') { + return day.winner.name; } const temporal = temporalLabel(day); - const commemorated = day.occurring.find((feast) => feast.commemorated); + const commemorated = day.commemorations.find((c) => c.kind === 'sanctoral'); return commemorated ? `${commemorated.name} — ${temporal}` : temporal; } diff --git a/src/calendar/index.ts b/src/calendar/index.ts index a8281ad..fe197d3 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -1,57 +1,115 @@ -import type { LiturgicalDay, OccurringFeast } from './types'; +import type { Commemoration, DayWinner, LiturgicalDay, SanctoralIdentity, TemporalCategory } from './types'; import { weekdayOf } from './weekday'; import { resolveSeason, resolveTemporalCategory } from './temporal'; +import { resolveTemporalId } from './temporal-id'; import { getSanctoralCandidatesFor } from './feasts'; -import { decideOccurrence, compareFeastClass } from './commemorations'; +import { decideOccurrence, type OccurrenceResult } from './commemorations'; +import { resolveCollision } from './collision'; +import { addDays } from './date-math'; + +/** + * A day's occurrence considered on its own — no awareness of what an + * adjacent day might be trying to transfer in. `resolveDay` calls this for + * the date itself *and* for yesterday/tomorrow (to check for an inbound + * transfer) without ever recursing into their own adjacent-day checks — + * that's what keeps this from being mutually recursive. + */ +function resolveNativeOccurrence(isoDate: string): { temporalCategory: TemporalCategory; result: OccurrenceResult } { + const weekday = weekdayOf(isoDate); + const season = resolveSeason(isoDate); + const temporalCategory = resolveTemporalCategory(isoDate, season, weekday); + const temporalId = resolveTemporalId(isoDate); + + // Multiple saints sharing a date is its own small occurrence contest, + // resolved the same way a transfer landing is — see calendar/collision.ts. + const candidates = getSanctoralCandidatesFor(isoDate); + let topCandidate: SanctoralIdentity | null = null; + const clashCommemorations: Commemoration[] = []; + for (const candidate of candidates) { + if (!topCandidate) { + topCandidate = candidate; + continue; + } + const collision = resolveCollision(candidate, topCandidate); + clashCommemorations.push(...collision.commemorations); + if (collision.winner.kind === 'sanctoral') { + topCandidate = { id: collision.winner.id, name: collision.winner.name, rank: collision.winner.rank }; + } + } + + const result = decideOccurrence(temporalCategory, temporalId, topCandidate); + result.commemorations.push(...clashCommemorations); + return { temporalCategory, result }; +} + +function applyIncomingTransfer( + candidate: SanctoralIdentity, + temporalCategory: TemporalCategory, + winner: DayWinner, + commemorations: Commemoration[], +): DayWinner { + if (temporalCategory === 'privileged-feria') { + // Deferred: a transfer landing on a privileged feria (the concrete + // case is Holy Week, right after Palm Sunday) needs its own + // Easter-date-keyed lookup table, the same way the reference engine + // handles it — not modeled yet. The transfer is simply not delivered + // here rather than guessed at. + return winner; + } + if (winner.kind === 'temporal') { + return { kind: 'sanctoral', id: candidate.id, name: candidate.name, rank: candidate.rank }; + } + const collision = resolveCollision(candidate, winner); + commemorations.push(...collision.commemorations); + return collision.winner; +} /** * Resolves everything about a given day *except* hour content — weekday, - * season, temporal precedence category, and any occurring feast. All real - * now: `occurring` combines calendar/feasts.ts's sanctoral candidates (if - * more than one shares a date, the highest-ranked wins that contest too — - * clashes *among* saints aren't otherwise modeled) with - * calendar/commemorations.ts's occurrence decision. A feast that's fully - * superseded (not even commemorated) doesn't appear in `occurring` at all. + * season, temporal precedence category, and the day's real winner plus + * whatever's commemorated alongside it (calendar/commemorations.ts, + * calendar/collision.ts, calendar/transfer signaling all feed into this). */ export function resolveDay(isoDate: string): LiturgicalDay { const weekday = weekdayOf(isoDate); const season = resolveSeason(isoDate); - const temporalCategory = resolveTemporalCategory(isoDate, season, weekday); + const { temporalCategory, result } = resolveNativeOccurrence(isoDate); - const candidates = getSanctoralCandidatesFor(isoDate); - let topCandidate = candidates[0] ?? null; - for (const candidate of candidates) { - if (compareFeastClass(candidate.rank, topCandidate!.rank) > 0) { - topCandidate = candidate; - } + let winner = result.winner; + const commemorations = [...result.commemorations]; + + const yesterday = resolveNativeOccurrence(addDays(isoDate, -1)); + if (yesterday.result.transfer?.direction === 'forward') { + winner = applyIncomingTransfer(yesterday.result.transfer.candidate, temporalCategory, winner, commemorations); + } + const tomorrow = resolveNativeOccurrence(addDays(isoDate, 1)); + if (tomorrow.result.transfer?.direction === 'backward') { + winner = applyIncomingTransfer(tomorrow.result.transfer.candidate, temporalCategory, winner, commemorations); } - const occurring: OccurringFeast[] = []; - if (topCandidate) { - const { winner, commemorated } = decideOccurrence(temporalCategory, topCandidate.rank); - if (winner === 'sanctoral' || commemorated) { - occurring.push({ - id: topCandidate.id, - name: topCandidate.name, - rank: topCandidate.rank, - commemorated: winner === 'temporal', - }); - } - } - - return { date: isoDate, weekday, season, temporalCategory, occurring }; + return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; } /** * The Sunday/feast-vs-ferial split that several hours' propers key off of * (Prime's capitulum and Preces, so far): a plain weekday with nothing else - * going on gets the "ferial" form, Sunday or an occurring feast gets the - * fuller "Sunday/feast" form. `occurring` is always [] until milestone 4, so - * today this reduces to "is it Sunday" — but the feast-override branch is - * real, not a stub, and will start firing the moment feasts do. + * going on gets the "ferial" form, Sunday or a winning feast gets the + * fuller "Sunday/feast" form. */ export function isSundayOrFeast(day: LiturgicalDay): boolean { - return day.weekday === 'sunday' || day.occurring.length > 0; + return day.weekday === 'sunday' || day.winner.kind === 'sanctoral'; } -export type { LiturgicalDay, OccurringFeast, Season, Weekday, FeastClass, TemporalCategory } from './types'; +export { compareFeastClass, isAtLeast, decideOccurrence } from './commemorations'; +export { resolveCollision } from './collision'; +export { resolveTemporalId } from './temporal-id'; +export type { + LiturgicalDay, + DayWinner, + Commemoration, + SanctoralIdentity, + Season, + Weekday, + FeastClass, + TemporalCategory, +} from './types'; diff --git a/src/calendar/temporal-id.ts b/src/calendar/temporal-id.ts new file mode 100644 index 0000000..f656c1a --- /dev/null +++ b/src/calendar/temporal-id.ts @@ -0,0 +1,86 @@ +// Maps any date to one of the 52 canonical temporal-propers ids already +// authored in data/propers/temporal/*.yml — the "which Sunday's collect +// governs this date" question. A feria always inherits the collect of the +// Sunday on or before it (real rubric: the ferias of a week use that +// week's own Sunday collect), so this is really "find the governing +// Sunday, then name it." +// +// Deliberately Pentecost/offset-based, not Trinity-counted — this is +// content-storage identity, kept independent of whatever counting +// convention calendar/day-label.ts displays on screen. See that file's +// header and propers/index.ts's getTemporalProper for the fuller version +// of this reasoning. +// +// Two known, deliberately unfixed gaps: the ferias between Christmas Day +// and the Sunday within its octave, and between Epiphany and its own +// first Sunday, fall back to that season's first named Sunday a few days +// early — Christmas Day's and Epiphany's own collects aren't authored as +// separate temporal-propers entries (only Sunday collects were pulled), so +// this is a deliberate approximation, not an oversight. Likewise, a real +// overflow year (early Easter, more than 24 weeks between Trinity and +// Advent) would traditionally reuse the unused post-Epiphany Sundays' +// collects for the excess weeks — not modeled; this just clamps at +// post-pentecost-24. +import { resolveSeason, sundayOnOrBefore, firstSundayStrictlyAfter, adventStart, easterOffsetOf } from './temporal'; +import { daysBetween } from './date-math'; + +const EASTER_OFFSET_IDS: [number, string][] = [ + [-63, 'septuagesima'], + [-56, 'sexagesima'], + [-49, 'quinquagesima'], + [-42, 'lent-1'], + [-35, 'lent-2'], + [-28, 'lent-3'], + [-21, 'lent-4'], + [-14, 'passion-sunday'], + [-7, 'palm-sunday'], + [0, 'easter-sunday'], + [7, 'easter-octave'], + [14, 'easter-3'], + [21, 'easter-4'], + [28, 'easter-5'], + [35, 'easter-6'], + [42, 'sunday-after-ascension'], + [49, 'pentecost-sunday'], +]; +for (let n = 1; n <= 24; n++) { + EASTER_OFFSET_IDS.push([56 + 7 * (n - 1), `post-pentecost-${String(n).padStart(2, '0')}`]); +} +const EASTER_OFFSET_ID_MAP = new Map(EASTER_OFFSET_IDS); +const MAX_EASTER_OFFSET = EASTER_OFFSET_IDS[EASTER_OFFSET_IDS.length - 1]![0]; +const MAX_EASTER_OFFSET_ID = EASTER_OFFSET_IDS[EASTER_OFFSET_IDS.length - 1]![1]; + +export function resolveTemporalId(isoDate: string): string { + const season = resolveSeason(isoDate); + const year = Number(isoDate.slice(0, 4)); + + if (season === 'advent') { + const start = adventStart(year); + const n = Math.round(daysBetween(start, sundayOnOrBefore(isoDate)) / 7) + 1; + return `advent-${Math.min(n, 4)}`; + } + + if (season === 'christmastide') { + return 'christmas-octave-sunday'; + } + + if (season === 'epiphanytide') { + const firstSunday = firstSundayStrictlyAfter(`${year}-01-06`); + if (isoDate < firstSunday) { + return 'post-epiphany-1'; + } + const n = Math.round(daysBetween(firstSunday, sundayOnOrBefore(isoDate)) / 7) + 1; + return `post-epiphany-${Math.min(n, 6)}`; + } + + // Everything else (Septuagesima-tide through Trinitytide) is Easter- + // anchored — find the governing Sunday and map its own offset directly, + // regardless of which `season` bucket the feria itself falls in (the + // ferias right after Ash Wednesday genuinely reuse Quinquagesima's + // collect, crossing what resolveSeason calls two different seasons). + const offset = easterOffsetOf(sundayOnOrBefore(isoDate)); + if (offset > MAX_EASTER_OFFSET) { + return MAX_EASTER_OFFSET_ID; + } + return EASTER_OFFSET_ID_MAP.get(offset) ?? 'septuagesima'; +} diff --git a/src/calendar/types.ts b/src/calendar/types.ts index 54157be..e4f2c09 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -36,13 +36,28 @@ export type Weekday = // third case shows up and the duplication starts to hurt. export type Season = string; -// The old (pre-1955) rank scale, six levels, low to high. Deliberately a -// closed union rather than an open string like Season — the whole point of +// The old (pre-1955) rank scale, low to high. Deliberately a closed union +// rather than an open string like Season — the whole point of // calendar/commemorations.ts's decideOccurrence is to compare two of these // with explicit, readable rules, which only works if the set of values is // fixed and known. See calendar/types.ts's TemporalCategory doc comment for // the other half of that comparison. -export type FeastClass = 'simplex' | 'semiduplex' | 'duplex' | 'duplex-majus' | 'duplex-2-classis' | 'duplex-1-classis'; +// +// `vigil` sits between `simplex` and `semiduplex` on purpose — it's the +// same strength as `simplex` for the "does this win against a Sunday" +// question (both lose and get transferred rather than fighting the day +// directly), but ranks strictly above `simplex` for the *separate* +// "two saints collide on the same landing day" comparison +// (calendar/collision.ts). One ordering serves both; see the design +// discussion in project history for why that isn't a coincidence. +export type FeastClass = + | 'simplex' + | 'vigil' + | 'semiduplex' + | 'duplex' + | 'duplex-majus' + | 'duplex-2-classis' + | 'duplex-1-classis'; // A day's own precedence class *before* any sanctoral feast is considered — // i.e. what the temporal cycle alone says this day is entitled to. This is @@ -57,17 +72,32 @@ export type FeastClass = 'simplex' | 'semiduplex' | 'duplex' | 'duplex-majus' | // corrections. export type TemporalCategory = 'ordinary-feria' | 'privileged-feria' | 'ordinary-sunday' | 'privileged-sunday'; -export interface OccurringFeast { +export interface SanctoralIdentity { id: string; name: string; rank: FeastClass; - commemorated: boolean; - // Set by calendar/vespers.ts's resolveEveningDay when this feast's First - // Vespers is being anticipated this evening (i.e. this OccurringFeast - // belongs to *tomorrow*, but is winning tonight's Vespers/Compline). - vespersFrom?: 'today' | 'firstVespersOfTomorrow'; } +export type DayWinner = + | { kind: 'temporal'; id: string } + | ({ + kind: 'sanctoral'; + // Set by calendar/vespers.ts's resolveEveningDay when this feast's + // First Vespers is being anticipated this evening (i.e. this feast + // belongs to *tomorrow*, but is winning tonight's Vespers/Compline). + vespersFrom?: 'firstVespersOfTomorrow'; + } & SanctoralIdentity); + +/** + * A day can have more than one of these at once (a transferred feast can + * displace a native saint who then also gets commemorated, alongside the + * Sunday whose own occurrence pushed the transfer in the first place) — + * hence a list, not a single flag. Extensible on purpose: a future + * `{ kind: 'octave'; id: string }` variant joins this union once octaves + * are modeled, without changing the shape callers already rely on. + */ +export type Commemoration = { kind: 'temporal'; id: string } | ({ kind: 'sanctoral' } & SanctoralIdentity); + export interface LiturgicalDay { /** ISO date, e.g. "2026-08-09" */ date: string; @@ -76,6 +106,8 @@ export interface LiturgicalDay { season: Season; /** This day's own precedence class, before any sanctoral feast wins or loses against it. */ temporalCategory: TemporalCategory; - /** The feast(s) actually occurring — real, via calendar/feasts.ts + calendar/commemorations.ts. */ - occurring: OccurringFeast[]; + /** Whichever office actually governs the day. */ + winner: DayWinner; + /** Everything else commemorated alongside the winner — see the doc comment on Commemoration. */ + commemorations: Commemoration[]; } diff --git a/src/calendar/vespers.ts b/src/calendar/vespers.ts index 6194e0f..e85c183 100644 --- a/src/calendar/vespers.ts +++ b/src/calendar/vespers.ts @@ -8,7 +8,7 @@ // Same reconstruction caveat as commemorations.ts/temporal-categories.yml: // best-effort, not sourced from a primary text for this era, expect // corrections. -import type { LiturgicalDay, OccurringFeast } from './types'; +import type { LiturgicalDay } from './types'; import { resolveDay } from './index'; import { addDays } from './date-math'; import { easterOffsetOf } from './temporal'; @@ -32,17 +32,12 @@ function isMajorFixedFeastOfTheLord(isoDate: string): boolean { return offset === 0 || offset === 39 || offset === 60 || offset === 68; // Easter, Ascension, Corpus Christi, Sacred Heart } -function winningFeast(day: LiturgicalDay): OccurringFeast | undefined { - return day.occurring.find((feast) => !feast.commemorated); -} - /** Does this day's evening claim First Vespers at all (for the day *after* it)? */ function hasFirstVespers(day: LiturgicalDay): boolean { if (day.weekday === 'sunday' || isMajorFixedFeastOfTheLord(day.date)) { return true; } - const winner = winningFeast(day); - return winner ? isAtLeast(winner.rank, 'duplex-majus') : false; + return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-majus'); } /** Does this day keep its own Second Vespers regardless of what tomorrow is? */ @@ -50,17 +45,14 @@ function keepsOwnSecondVespers(day: LiturgicalDay): boolean { if (day.weekday === 'sunday' || day.temporalCategory === 'privileged-feria' || isMajorFixedFeastOfTheLord(day.date)) { return true; } - const winner = winningFeast(day); - return winner ? isAtLeast(winner.rank, 'duplex-2-classis') : false; + return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-2-classis'); } function anticipated(tomorrow: LiturgicalDay): LiturgicalDay { - return { - ...tomorrow, - occurring: tomorrow.occurring.map((feast) => - feast.commemorated ? feast : { ...feast, vespersFrom: 'firstVespersOfTomorrow' }, - ), - }; + if (tomorrow.winner.kind === 'sanctoral') { + return { ...tomorrow, winner: { ...tomorrow.winner, vespersFrom: 'firstVespersOfTomorrow' } }; + } + return tomorrow; } /** @@ -68,10 +60,10 @@ function anticipated(tomorrow: LiturgicalDay): LiturgicalDay { * Returns `resolveDay(isoDate)` unchanged unless tomorrow has First * Vespers *and* today doesn't keep its own Second Vespers regardless — in * which case returns tomorrow's `LiturgicalDay`, with `vespersFrom: - * 'firstVespersOfTomorrow'` set on its winning feast (if any) so callers + * 'firstVespersOfTomorrow'` set on its winner (if sanctoral) so callers * can tell the difference from an ordinary day. * - * One deliberate exception to that ordering: a day on + * One deliberate absolute exception, caught by a failing test: a day on * `isMajorFixedFeastOfTheLord`'s list always wins tomorrow's Vespers, * *before* checking whether today would otherwise keep its own — Easter * displaces even Holy Saturday's privileged-feria status (the classic diff --git a/src/data/calendar/saints/vigil-of-st-lawrence.yml b/src/data/calendar/saints/vigil-of-st-lawrence.yml new file mode 100644 index 0000000..66d103c --- /dev/null +++ b/src/data/calendar/saints/vigil-of-st-lawrence.yml @@ -0,0 +1,10 @@ +# A real vigil, used as the concrete first test case for the vigil rank +# and its backward-transfer rule (see calendar/commemorations.ts and +# calendar/index.ts) — vigils are the day *before* their feast (St. +# Lawrence is Aug 10, so this is Aug 9), and if that date is impeded (e.g. +# falls on a Sunday), it transfers to the day before instead of forward. +id: vigil-of-st-lawrence +name: "Vigil of St. Lawrence" +rank: vigil +common: common-of-a-vigil +propers: null diff --git a/src/data/calendar/sanctoral-calendar.yml b/src/data/calendar/sanctoral-calendar.yml index 0299182..6bf44ff 100644 --- a/src/data/calendar/sanctoral-calendar.yml +++ b/src/data/calendar/sanctoral-calendar.yml @@ -12,6 +12,7 @@ # privileged-day lists. `example-confessor` is deliberately NOT mapped to # any day here — it's fictional, kept only to document the record shape. days: + "08-09": [vigil-of-st-lawrence] "08-10": [st-lawrence] "08-15": [assumption] "08-28": [st-augustine] diff --git a/src/hours/antiphon.ts b/src/hours/antiphon.ts index 7777045..d3e6160 100644 --- a/src/hours/antiphon.ts +++ b/src/hours/antiphon.ts @@ -1,4 +1,4 @@ -import type { OccurringFeast } from '../calendar/types'; +import type { DayWinner } from '../calendar/types'; import { isAtLeast } from '../calendar/commemorations'; export interface SplitAntiphon { @@ -31,13 +31,12 @@ export function splitAntiphon(text: string): SplitAntiphon { * said before the psalms (the full text is always said after, regardless * of rank). * - * Only asks whether the day's *winning* feast (not a merely-commemorated - * one) is Double-or-higher — a privileged Sunday with nothing occurring, - * or a commemorated low-rank saint, both correctly return false here. A - * Sunday being "privileged" doesn't by itself make this true; that's a + * Only asks whether the day's *winner* (not a merely-commemorated feast) + * is Double-or-higher — a privileged Sunday with nothing winning, or a + * commemorated low-rank saint, both correctly return false here. A Sunday + * being "privileged" doesn't by itself make this true; that's a * `TemporalCategory` question, not a `FeastClass` one. */ -export function isDoubleOrHigher(occurring: OccurringFeast[]): boolean { - const winner = occurring.find((feast) => !feast.commemorated); - return winner ? isAtLeast(winner.rank, 'duplex') : false; +export function isDoubleOrHigher(winner: DayWinner): boolean { + return winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex'); } diff --git a/src/hours/chapter-responsory.ts b/src/hours/chapter-responsory.ts index 704ae70..cc32994 100644 --- a/src/hours/chapter-responsory.ts +++ b/src/hours/chapter-responsory.ts @@ -1,4 +1,4 @@ -import type { Season, OccurringFeast } from '../calendar/types'; +import type { Season, DayWinner } from '../calendar/types'; import { resolveSeasonalPropersId } from './seasonal-propers'; import bySeasonData from '../data/hours/prime-chapter-responsory-by-season.yml'; import byFeastData from '../data/hours/prime-chapter-responsory-by-feast.yml'; @@ -7,6 +7,6 @@ const bySeason = bySeasonData as { perAnnum: string; bySeason: Record }; /** Resolves the common-propers id for the chapter responsory's variable verse. */ -export function getChapterResponsoryId(season: Season, occurring: OccurringFeast[]): string { - return resolveSeasonalPropersId(season, occurring, { ...bySeason, byFeastId: byFeast.byFeastId }); +export function getChapterResponsoryId(season: Season, winner: DayWinner): string { + return resolveSeasonalPropersId(season, winner, { ...bySeason, byFeastId: byFeast.byFeastId }); } diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 069456d..0f40d84 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -32,7 +32,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { } const body = resolveCommon(part.textRef.id); const doxology = resolveCommon( - getHymnDoxologyId(day.season, day.occurring, 'compline-hymn-doxology-per-annum'), + getHymnDoxologyId(day.season, day.winner, 'compline-hymn-doxology-per-annum'), ); return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; } @@ -42,14 +42,14 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'prayer': return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; case 'preces': - if (part.omitOnDouble && isDoubleOrHigher(day.occurring)) { + if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { return []; } return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; case 'lesson': return [{ kind: 'lesson', text: resolveCommon(part.textRef.id), label: part.label }]; case 'opening-versicle': - return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.occurring)) }]; + return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; case 'psalm': return [ { @@ -64,7 +64,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { ]; case 'nunc-dimittis': { const { incipit, full } = splitNamedAntiphon(getCommonProper('nunc-dimittis-antiphon').text); - const opening = isDoubleOrHigher(day.occurring) ? full : incipit; + const opening = isDoubleOrHigher(day.winner) ? full : incipit; return [ { kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening }, { kind: 'antiphon', text: full }, diff --git a/src/hours/hymn-doxology.ts b/src/hours/hymn-doxology.ts index f45f059..d685117 100644 --- a/src/hours/hymn-doxology.ts +++ b/src/hours/hymn-doxology.ts @@ -1,4 +1,4 @@ -import type { Season, OccurringFeast } from '../calendar/types'; +import type { Season, DayWinner } from '../calendar/types'; import { resolveSeasonalPropersId } from './seasonal-propers'; import bySeasonData from '../data/hours/hymn-doxology-by-season.yml'; import byFeastData from '../data/hours/hymn-doxology-by-feast.yml'; @@ -12,8 +12,8 @@ const byFeast = byFeastData as { byFeastId: Record }; * Officium's own Doxologies.txt table is hymn-agnostic), but the per-annum * default is each hymn's own natural ending, so callers supply it. */ -export function getHymnDoxologyId(season: Season, occurring: OccurringFeast[], perAnnumId: string): string { - return resolveSeasonalPropersId(season, occurring, { +export function getHymnDoxologyId(season: Season, winner: DayWinner, perAnnumId: string): string { + return resolveSeasonalPropersId(season, winner, { perAnnum: perAnnumId, bySeason: bySeason.bySeason, byFeastId: byFeast.byFeastId, diff --git a/src/hours/marian-antiphon.ts b/src/hours/marian-antiphon.ts index 4591fb2..7471762 100644 --- a/src/hours/marian-antiphon.ts +++ b/src/hours/marian-antiphon.ts @@ -34,7 +34,7 @@ export function getMarianAntiphonId(day: LiturgicalDay): string { if (isCandlemasToHolyWednesday(day.date)) { return AVE_REGINA_CAELORUM_ID; } - return resolveSeasonalPropersId(day.season, day.occurring, bySeason); + return resolveSeasonalPropersId(day.season, day.winner, bySeason); } const LABELS: Record = { diff --git a/src/hours/opening-versicle.ts b/src/hours/opening-versicle.ts index 2f53381..e558734 100644 --- a/src/hours/opening-versicle.ts +++ b/src/hours/opening-versicle.ts @@ -1,10 +1,10 @@ -import type { Season, OccurringFeast } from '../calendar/types'; +import type { Season, DayWinner } from '../calendar/types'; import { resolveSeasonalPropersId } from './seasonal-propers'; import bySeasonData from '../data/hours/opening-by-season.yml'; const bySeason = bySeasonData as { perAnnum: string; bySeason: Record }; /** Resolves the common-propers id for the opening versicle's ending (Allelúja vs. Laus tibi). */ -export function getOpeningVersicleId(season: Season, occurring: OccurringFeast[]): string { - return resolveSeasonalPropersId(season, occurring, bySeason); +export function getOpeningVersicleId(season: Season, winner: DayWinner): string { + return resolveSeasonalPropersId(season, winner, bySeason); } diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 178c857..df995b8 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -21,7 +21,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved switch (part.kind) { case 'hymn': { const body = resolveCommon(part.textRef.id); - const doxology = resolveCommon(getHymnDoxologyId(day.season, day.occurring, 'hymn-doxology-per-annum')); + const doxology = resolveCommon(getHymnDoxologyId(day.season, day.winner, 'hymn-doxology-per-annum')); return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; } case 'chapter': @@ -30,14 +30,14 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved case 'prayer': return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; case 'preces': - if (part.omitOnDouble && isDoubleOrHigher(day.occurring)) { + if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { return []; } return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; case 'lesson': return [{ kind: 'lesson', text: resolveCommon(part.textRef.id), label: part.label }]; case 'opening-versicle': - return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.occurring)) }]; + return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; case 'creed': // Real Divinum Officium's actual inclusion rule tangles together // rank, commemorations, and version-specific rubrics in a way that @@ -45,7 +45,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved // Sunday and not a Double-or-higher feast. Octaves aren't modeled at // all yet (milestone 4), so they can't suppress it either, same as // isDoubleOrHigher's own limitation. - if (day.weekday !== 'sunday' || isDoubleOrHigher(day.occurring)) { + if (day.weekday !== 'sunday' || isDoubleOrHigher(day.winner)) { return []; } return [{ kind: 'lesson', text: resolveCommon('athanasian-creed'), label: 'Athanasian Creed' }]; @@ -55,7 +55,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved if (part.resolve === 'by-season') { // Currently only the chapter responsory's verse uses this — see // hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime. - return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.occurring)) }]; + return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.winner)) }]; } { const psalmRefs = getPsalmsFor('prime', day.weekday); @@ -63,7 +63,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved // Full text on a Double-or-higher feast, otherwise just the incipit // — see hours/antiphon.ts. The full repeat comes later, after the // Creed (see 'closing-antiphon'), not tacked onto the last psalm. - const opening = isDoubleOrHigher(day.occurring) ? full : incipit; + const opening = isDoubleOrHigher(day.winner) ? full : incipit; return psalmRefs.map((ref, i) => ({ kind: 'psalm' as const, psalmNumber: ref.number, @@ -86,7 +86,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved case 'canticle': return [{ kind: 'canticle', canticleId: part.canticleId, text: resolveCommon(part.textRef.id) }]; case 'by-day-kind': { - if (part.omitOnDouble && isDoubleOrHigher(day.occurring)) { + if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { return []; } const ref = isSundayOrFeast(day) ? part.sundayOrFeastRef : part.ferialRef; diff --git a/src/hours/seasonal-propers.ts b/src/hours/seasonal-propers.ts index 74a1a4c..feede6d 100644 --- a/src/hours/seasonal-propers.ts +++ b/src/hours/seasonal-propers.ts @@ -1,4 +1,4 @@ -import type { Season, OccurringFeast } from '../calendar/types'; +import type { Season, DayWinner } from '../calendar/types'; export interface SeasonalPropersTable { perAnnum: string; @@ -9,18 +9,13 @@ export interface SeasonalPropersTable { /** * Shared by everything that picks a common-propers id by season with a * per-feast override (the chapter responsory's verse, the hymn doxology, - * and presumably more once milestone 4 lands) — feast overrides win over - * season, which wins over the "per annum" default. `occurring` is always - * [] until milestone 4, so today this always returns a season match or - * perAnnum. + * and presumably more once milestone 4 lands) — a feast override wins only + * when that feast is the day's actual *winner* (not merely commemorated), + * which wins over season, which wins over the "per annum" default. */ -export function resolveSeasonalPropersId( - season: Season, - occurring: OccurringFeast[], - table: SeasonalPropersTable, -): string { - for (const feast of occurring) { - const override = table.byFeastId?.[feast.id]; +export function resolveSeasonalPropersId(season: Season, winner: DayWinner, table: SeasonalPropersTable): string { + if (winner.kind === 'sanctoral') { + const override = table.byFeastId?.[winner.id]; if (override) { return override; } diff --git a/tests/calendar/collision.test.ts b/tests/calendar/collision.test.ts new file mode 100644 index 0000000..a284149 --- /dev/null +++ b/tests/calendar/collision.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCollision } from '../../src/calendar/collision'; +import type { SanctoralIdentity } from '../../src/calendar/types'; + +function saint(id: string, rank: SanctoralIdentity['rank']): SanctoralIdentity { + return { id, name: id, rank }; +} + +describe('resolveCollision', () => { + it('higher rank wins regardless of which side is incoming vs native', () => { + const incoming = saint('incoming', 'semiduplex'); + const native = saint('native', 'simplex'); + const result = resolveCollision(incoming, native); + expect(result.winner).toEqual({ kind: 'sanctoral', id: 'incoming', name: 'incoming', rank: 'semiduplex' }); + expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'native', name: 'native', rank: 'simplex' }]); + }); + + it('a transferred Vigil beats a native Simplex (vigil ranks above simplex for collisions)', () => { + const result = resolveCollision(saint('vigil-feast', 'vigil'), saint('native', 'simplex')); + expect(result.winner.kind).toBe('sanctoral'); + expect(result.winner.kind === 'sanctoral' ? result.winner.id : undefined).toBe('vigil-feast'); + }); + + it('a native Semiduplex beats a transferred Vigil', () => { + const result = resolveCollision(saint('vigil-feast', 'vigil'), saint('native', 'semiduplex')); + expect(result.winner.kind === 'sanctoral' ? result.winner.id : undefined).toBe('native'); + expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'vigil-feast', name: 'vigil-feast', rank: 'vigil' }]); + }); + + it('ties favor the native occupant, the incoming feast is commemorated', () => { + const result = resolveCollision(saint('incoming', 'duplex'), saint('native', 'duplex')); + expect(result.winner.kind === 'sanctoral' ? result.winner.id : undefined).toBe('native'); + expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'incoming', name: 'incoming', rank: 'duplex' }]); + }); + + it('the loser is always commemorated, with no rank threshold', () => { + const result = resolveCollision(saint('incoming', 'duplex-1-classis'), saint('native', 'simplex')); + expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'native', name: 'native', rank: 'simplex' }]); + }); +}); diff --git a/tests/calendar/commemorations.test.ts b/tests/calendar/commemorations.test.ts index 33184aa..51cdecf 100644 --- a/tests/calendar/commemorations.test.ts +++ b/tests/calendar/commemorations.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest'; import { decideOccurrence, compareFeastClass, isAtLeast } from '../../src/calendar/commemorations'; +import type { SanctoralIdentity } from '../../src/calendar/types'; + +function saint(rank: SanctoralIdentity['rank']): SanctoralIdentity { + return { id: 'x', name: 'St. Ereden', rank }; +} describe('compareFeastClass / isAtLeast', () => { - it('orders the six ranks low to high', () => { - expect(compareFeastClass('simplex', 'duplex-1-classis')).toBeLessThan(0); + it('orders the seven ranks low to high, with vigil between simplex and semiduplex', () => { + expect(compareFeastClass('simplex', 'vigil')).toBeLessThan(0); + expect(compareFeastClass('vigil', 'semiduplex')).toBeLessThan(0); + expect(compareFeastClass('semiduplex', 'duplex')).toBeLessThan(0); expect(compareFeastClass('duplex-1-classis', 'simplex')).toBeGreaterThan(0); expect(compareFeastClass('duplex', 'duplex')).toBe(0); }); @@ -14,56 +21,85 @@ describe('compareFeastClass / isAtLeast', () => { }); }); -describe('decideOccurrence', () => { +describe('decideOccurrence — no candidate', () => { it('temporal wins trivially when nothing is assigned to the date', () => { - expect(decideOccurrence('ordinary-feria', null)).toEqual({ winner: 'temporal', commemorated: false }); - expect(decideOccurrence('privileged-sunday', null)).toEqual({ winner: 'temporal', commemorated: false }); - }); - - it('an ordinary feria always yields to any real feast, uncommemorated', () => { - expect(decideOccurrence('ordinary-feria', 'simplex')).toEqual({ winner: 'sanctoral', commemorated: false }); - expect(decideOccurrence('ordinary-feria', 'duplex-1-classis')).toEqual({ winner: 'sanctoral', commemorated: false }); - }); - - it('a privileged feria yields to nothing short of the top class', () => { - expect(decideOccurrence('privileged-feria', 'duplex-2-classis')).toEqual({ - winner: 'temporal', - commemorated: false, - }); - expect(decideOccurrence('privileged-feria', 'duplex-1-classis')).toEqual({ - winner: 'temporal', - commemorated: true, - }); - }); - - it('an ordinary Sunday yields outright only to the top class, and is commemorated by the two ranks below it', () => { - expect(decideOccurrence('ordinary-sunday', 'duplex-1-classis')).toEqual({ - winner: 'sanctoral', - commemorated: false, - }); - expect(decideOccurrence('ordinary-sunday', 'duplex-2-classis')).toEqual({ - winner: 'temporal', - commemorated: true, - }); - expect(decideOccurrence('ordinary-sunday', 'duplex-majus')).toEqual({ - winner: 'temporal', - commemorated: true, - }); - expect(decideOccurrence('ordinary-sunday', 'duplex')).toEqual({ winner: 'temporal', commemorated: false }); - }); - - it('a privileged Sunday is never displaced, and only the top two ranks are even commemorated', () => { - expect(decideOccurrence('privileged-sunday', 'duplex-1-classis')).toEqual({ - winner: 'temporal', - commemorated: true, - }); - expect(decideOccurrence('privileged-sunday', 'duplex-2-classis')).toEqual({ - winner: 'temporal', - commemorated: true, - }); - expect(decideOccurrence('privileged-sunday', 'duplex-majus')).toEqual({ - winner: 'temporal', - commemorated: false, + expect(decideOccurrence('ordinary-feria', 'some-feria-id', null)).toEqual({ + winner: { kind: 'temporal', id: 'some-feria-id' }, + commemorations: [], }); }); }); + +describe('decideOccurrence — ordinary-feria', () => { + it('always yields to any real feast, uncommemorated, whatever its rank', () => { + for (const rank of ['simplex', 'vigil', 'semiduplex', 'duplex', 'duplex-1-classis'] as const) { + const result = decideOccurrence('ordinary-feria', 'id', saint(rank)); + expect(result.winner).toEqual({ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank }); + expect(result.commemorations).toEqual([]); + expect(result.transfer).toBeUndefined(); + } + }); +}); + +describe('decideOccurrence — privileged-feria', () => { + it('yields to nothing short of the top class', () => { + const majus = decideOccurrence('privileged-feria', 'id', saint('duplex-2-classis')); + expect(majus.winner).toEqual({ kind: 'temporal', id: 'id' }); + expect(majus.commemorations).toEqual([]); + + const top = decideOccurrence('privileged-feria', 'id', saint('duplex-1-classis')); + expect(top.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-1-classis' }]); + }); +}); + +describe('decideOccurrence — ordinary-sunday', () => { + it('duplex or higher wins outright, and the Sunday itself is commemorated', () => { + const result = decideOccurrence('ordinary-sunday', 'post-epiphany-2', saint('duplex')); + expect(result.winner).toEqual({ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' }); + expect(result.commemorations).toEqual([{ kind: 'temporal', id: 'post-epiphany-2' }]); + expect(result.transfer).toBeUndefined(); + }); + + it('simplex stays and is commemorated, not transferred', () => { + const result = decideOccurrence('ordinary-sunday', 'post-epiphany-2', saint('simplex')); + expect(result.winner).toEqual({ kind: 'temporal', id: 'post-epiphany-2' }); + expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'simplex' }]); + expect(result.transfer).toBeUndefined(); + }); + + it('semiduplex gets nothing here — transfers forward', () => { + const result = decideOccurrence('ordinary-sunday', 'post-epiphany-2', saint('semiduplex')); + expect(result.winner).toEqual({ kind: 'temporal', id: 'post-epiphany-2' }); + expect(result.commemorations).toEqual([]); + expect(result.transfer).toEqual({ candidate: saint('semiduplex'), direction: 'forward' }); + }); + + it('vigil gets nothing here — transfers backward', () => { + const result = decideOccurrence('ordinary-sunday', 'post-epiphany-2', saint('vigil')); + expect(result.transfer).toEqual({ candidate: saint('vigil'), direction: 'backward' }); + }); +}); + +describe('decideOccurrence — privileged-sunday', () => { + it('is never displaced; duplex-majus and up are commemorated', () => { + for (const rank of ['duplex-majus', 'duplex-2-classis', 'duplex-1-classis'] as const) { + const result = decideOccurrence('privileged-sunday', 'advent-1', saint(rank)); + expect(result.winner).toEqual({ kind: 'temporal', id: 'advent-1' }); + expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank }]); + expect(result.transfer).toBeUndefined(); + } + }); + + it('duplex, semiduplex, and simplex all transfer forward — no bare commemoration here', () => { + for (const rank of ['duplex', 'semiduplex', 'simplex'] as const) { + const result = decideOccurrence('privileged-sunday', 'advent-1', saint(rank)); + expect(result.commemorations).toEqual([]); + expect(result.transfer).toEqual({ candidate: saint(rank), direction: 'forward' }); + } + }); + + it('vigil transfers backward, same as on an ordinary Sunday', () => { + const result = decideOccurrence('privileged-sunday', 'advent-1', saint('vigil')); + expect(result.transfer).toEqual({ candidate: saint('vigil'), direction: 'backward' }); + }); +}); diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index eb0bacd..a2748a1 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -38,13 +38,14 @@ describe('getDayLabel — feast name combination', () => { weekday: 'monday', season: 'trinitytide', temporalCategory: 'ordinary-feria', - occurring: [], + winner: { kind: 'temporal', id: 'post-pentecost-02' }, + commemorations: [], }; it('shows just the feast name when it wins outright', () => { const day: LiturgicalDay = { ...base, - occurring: [{ id: 'x', name: 'St. Ereden', rank: 'duplex', commemorated: false }], + winner: { kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' }, }; expect(getDayLabel(day)).toBe('St. Ereden'); }); @@ -52,12 +53,12 @@ describe('getDayLabel — feast name combination', () => { it('shows both, feast first, when the feast is merely commemorated', () => { const day: LiturgicalDay = { ...base, - occurring: [{ id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis', commemorated: true }], + commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }], }; expect(getDayLabel(day)).toBe('St. Ereden — Monday in the 2nd week after Trinity'); }); - it('shows just the temporal label when nothing is occurring at all', () => { + it('shows just the temporal label when nothing is commemorated at all', () => { expect(getDayLabel(base)).toBe('Monday in the 2nd week after Trinity'); }); }); diff --git a/tests/calendar/transfer.test.ts b/tests/calendar/transfer.test.ts new file mode 100644 index 0000000..d3267d9 --- /dev/null +++ b/tests/calendar/transfer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDay } from '../../src/calendar'; + +describe('transfer mechanism (resolveDay integration)', () => { + // The Vigil of St. Lawrence (Aug 9) is a real content entry specifically + // added to exercise this — Aug 9, 2026 is a Sunday, so the vigil (rank + // 'vigil') can't win or be commemorated there (see + // commemorations.ts's ordinary-sunday rule) and must transfer backward. + it('a vigil impeded by an ordinary Sunday transfers backward to Saturday, leaving the Sunday untouched', () => { + const saturday = resolveDay('2026-08-08'); + const sunday = resolveDay('2026-08-09'); + const monday = resolveDay('2026-08-10'); + + expect(saturday.winner).toEqual({ + kind: 'sanctoral', + id: 'vigil-of-st-lawrence', + name: 'Vigil of St. Lawrence', + rank: 'vigil', + }); + expect(saturday.commemorations).toEqual([]); + + // The Sunday's own resolution is exactly as if the vigil didn't exist. + expect(sunday.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-11' }); + expect(sunday.commemorations).toEqual([]); + + // St. Lawrence's own day (Monday) is unaffected by the backward transfer. + expect(monday.winner).toEqual({ + kind: 'sanctoral', + id: 'st-lawrence', + name: 'St. Lawrence, Martyr', + rank: 'duplex-2-classis', + }); + }); +}); diff --git a/tests/calendar/vespers.test.ts b/tests/calendar/vespers.test.ts index 5d75502..4e20450 100644 --- a/tests/calendar/vespers.test.ts +++ b/tests/calendar/vespers.test.ts @@ -33,8 +33,10 @@ describe('resolveEveningDay', () => { // Duplex I classis. const day = resolveEveningDay('2026-08-14'); expect(day.date).toBe('2026-08-15'); - const winner = day.occurring.find((f) => !f.commemorated); - expect(winner?.id).toBe('assumption'); - expect(winner?.vespersFrom).toBe('firstVespersOfTomorrow'); + expect(day.winner.kind).toBe('sanctoral'); + if (day.winner.kind === 'sanctoral') { + expect(day.winner.id).toBe('assumption'); + expect(day.winner.vespersFrom).toBe('firstVespersOfTomorrow'); + } }); }); diff --git a/tests/calendar/weekday.test.ts b/tests/calendar/weekday.test.ts index bf07774..6809e0a 100644 --- a/tests/calendar/weekday.test.ts +++ b/tests/calendar/weekday.test.ts @@ -31,10 +31,11 @@ describe('weekdayOf', () => { }); describe('resolveDay', () => { - it('stubs season and occurring until milestone 4', () => { + it('resolves a plain Sunday with no occurring feast', () => { const day = resolveDay('2026-08-09'); expect(day.date).toBe('2026-08-09'); expect(day.weekday).toBe('sunday'); - expect(day.occurring).toEqual([]); + expect(day.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-11' }); + expect(day.commemorations).toEqual([]); }); });