diff --git a/src/data/hours/lauds.yml b/src/data/hours/lauds.yml new file mode 100644 index 0000000..b09dbdf --- /dev/null +++ b/src/data/hours/lauds.yml @@ -0,0 +1,25 @@ +# Ordo verified against Divinum Officium (Monastic Tridentinum 1617), Lauds +# live query, 2026-08-11 — see src/hours/lauds.ts for how each part +# resolves, and data/hours/lauds-antiphons.yml / lauds-canticles/*.yml / +# propers/common/lauds-*.yml for the underlying weekday content. +# +# The suffrages (Holy Cross, BVM, Ss. Peter & Paul, Peace) are gated by +# omitOnDouble — see hours/types.ts's 'suffrages' doc comment for the +# caveat on what else real practice also suppresses them for that isn't +# modeled yet. +id: lauds +parts: + - kind: opening-versicle + - kind: lauds-psalmody + - kind: lauds-office + - kind: benedictus + - kind: preces + textRef: { source: common, id: lauds-short-litany } + - kind: day-collects + - kind: suffrages + omitOnDouble: true + - kind: preces + textRef: { source: common, id: lauds-conclusio } + - kind: versicle + textRef: { source: common, id: lauds-closing-versicle } + - kind: marian-antiphon diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 77e686c..02dacb5 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -86,6 +86,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'by-day-kind': case 'variable': case 'day-collect': + case 'lauds-psalmody': + case 'lauds-office': + case 'benedictus': + case 'suffrages': + case 'day-collects': throw new Error(`Compline's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index b0baf79..7c79b09 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -1,7 +1,192 @@ -import type { ResolvedOrdo } from './types'; +import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; +import type { LiturgicalDay, Weekday } from '../calendar/types'; +import { resolveDay } from '../calendar'; +import { getDayLabel } from '../calendar/day-label'; +import { getPsalmVerses } from '../psalter'; +import { getCanticle } from './lauds-canticles'; +import { getOpeningVersicleId } from './opening-versicle'; +import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; +import { isDoubleOrHigher } from './antiphon'; +import { resolveCommon, getDayCollects, getBenedictusAntiphon, splitNamedAntiphon } from './resolve-common'; +import laudsDefinitionData from '../data/hours/lauds.yml'; +import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; -// Milestone 4 — first hour needing real sanctoral occurrence + rank/commemoration -// resolution (calendar/feasts.ts, calendar/easter.ts). Not built yet. -export function resolveOrdo(date: string): ResolvedOrdo { - return { hourId: 'lauds', date, parts: [], notImplemented: true }; +const laudsDefinition = laudsDefinitionData as HourDefinition; + +type BilingualText = Partial>; +interface LaudsGroup { + psalms: number[]; + antiphon: BilingualText; +} +interface LaudsWeekdayAntiphons { + groups: LaudsGroup[]; + canticle: { id: string; antiphon: BilingualText }; + laudate: { antiphon: BilingualText }; +} +const laudsAntiphons = laudsAntiphonsData as Record; + +// Mon-Fri share one capitulum verbatim (confirmed against the live engine +// — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their +// own. +function capitulumId(weekday: Weekday): string { + if (weekday === 'sunday') { + return 'lauds-capitulum-sunday'; + } + if (weekday === 'saturday') { + return 'lauds-capitulum-saturday'; + } + return 'lauds-capitulum-ferial'; +} + +function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] { + const parts: ResolvedPart[] = numbers.map((number, i) => ({ + kind: 'psalm' as const, + psalmNumber: number, + antiphon: i === 0 ? opening : undefined, + verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })), + })); + parts.push({ kind: 'antiphon', text: splitNamedAntiphon(antiphon).full }); + return parts; +} + +const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const; + +/** Joins a canticle's verse array into one flowing text block, the same + * shape nunc-dimittis/benedictus already use — Lauds' weekday canticles + * are stored verse-by-verse (see lauds-canticles.ts) purely because that's + * how they were transcribed, not because callers need per-verse access. + * Appends the fixed Gloria Patri line every canticle ends with in real + * practice, same wording as benedictus.yml's own trailing line. */ +function canticleText(canticleId: string): ResolvedText { + const canticle = getCanticle(canticleId); + const text: Partial> = {}; + const status: Partial> = {}; + const GLORIA = { + la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.', + en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.', + }; + for (const lang of ['la', 'en'] as const) { + const lines = canticle.verses.map((v) => v.text[lang]).filter((t): t is string => Boolean(t)); + if (lines.length === 0) { + continue; + } + text[lang] = `${lines.join('\n')}\n${GLORIA[lang]}`; + let worst: 'verified' | 'draft' | 'missing' = 'verified'; + for (const v of canticle.verses) { + const s = v.status[lang] ?? 'missing'; + if (STATUS_RANK[s] > STATUS_RANK[worst]) { + worst = s; + } + } + status[lang] = worst; + } + return { text, status }; +} + +/** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody' + * doc comment for the scope and the known Commune-override gap. */ +function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { + const wd = laudsAntiphons[day.weekday]; + const opening = (antiphon: BilingualText) => { + const { incipit, full } = splitNamedAntiphon(antiphon); + return isDoubleOrHigher(day.winner) ? full : incipit; + }; + const parts: ResolvedPart[] = [ + { + kind: 'psalm', + psalmNumber: 66, + verses: getPsalmVerses(66).map((v) => ({ n: v.n, text: v.text, status: v.status })), + }, + ]; + for (const group of wd.groups) { + parts.push(...psalmParts(group.psalms, group.antiphon, opening(group.antiphon))); + } + const canticle = getCanticle(wd.canticle.id); + parts.push({ + kind: 'canticle', + canticleId: canticle.id, + antiphon: opening(wd.canticle.antiphon), + text: canticleText(canticle.id), + }); + parts.push({ kind: 'antiphon', text: splitNamedAntiphon(wd.canticle.antiphon).full }); + parts.push(...psalmParts([148, 149, 150], wd.laudate.antiphon, opening(wd.laudate.antiphon))); + return parts; +} + +function resolveOffice(weekday: Weekday): ResolvedPart[] { + return [ + { kind: 'chapter', text: resolveCommon(capitulumId(weekday)) }, + { kind: 'responsory', text: resolveCommon(`lauds-responsory-${weekday}`) }, + { kind: 'hymn', text: resolveCommon(`lauds-hymn-${weekday}`) }, + { kind: 'versicle', text: resolveCommon(`lauds-versicle-${weekday}`) }, + ]; +} + +const SUFFRAGES = ['lauds-suffrage-cross', 'lauds-suffrage-bvm', 'lauds-suffrage-apostles', 'lauds-suffrage-peace']; +const SUFFRAGE_LABELS: Record = { + 'lauds-suffrage-cross': 'Of the Holy Cross', + 'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary', + 'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul', + 'lauds-suffrage-peace': 'For Peace', +}; + +function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { + switch (part.kind) { + case 'opening-versicle': + return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; + case 'lauds-psalmody': + return resolvePsalmody(day); + case 'lauds-office': + return resolveOffice(day.weekday); + case 'benedictus': { + const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day).text); + const opening = isDoubleOrHigher(day.winner) ? full : incipit; + return [ + { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, + { kind: 'antiphon', text: full }, + ]; + } + case 'preces': + return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; + case 'day-collects': + return getDayCollects(day).map((text) => ({ kind: 'prayer' as const, text })); + case 'suffrages': + if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { + return []; + } + return SUFFRAGES.map((id) => ({ + kind: 'preces' as const, + text: resolveCommon(id), + label: SUFFRAGE_LABELS[id], + })); + case 'versicle': + case 'chapter': + case 'responsory': + case 'prayer': + return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; + case 'marian-antiphon': { + const id = getMarianAntiphonId(day); + return [{ kind: 'preces', text: resolveCommon(id), label: getMarianAntiphonLabel(id) }]; + } + // Not used by Lauds. + case 'hymn': + case 'lesson': + case 'psalm': + case 'canticle': + case 'martyrology': + case 'rule-reading': + case 'creed': + case 'closing-antiphon': + case 'by-day-kind': + case 'variable': + case 'day-collect': + case 'nunc-dimittis': + throw new Error(`Lauds' ordo doesn't support a '${part.kind}' part`); + } +} + +export function resolveOrdo(date: string): ResolvedOrdo { + const day = resolveDay(date); + const parts = laudsDefinition.parts.flatMap((part) => resolvePart(part, day)); + return { hourId: 'lauds', date, parts, dayLabel: getDayLabel(day) }; } diff --git a/src/hours/none.ts b/src/hours/none.ts index 7b5d77a..502ab28 100644 --- a/src/hours/none.ts +++ b/src/hours/none.ts @@ -54,6 +54,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'closing-antiphon': case 'nunc-dimittis': case 'marian-antiphon': + case 'lauds-psalmody': + case 'lauds-office': + case 'benedictus': + case 'suffrages': + case 'day-collects': throw new Error(`None's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 5504eec..30b93be 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -112,6 +112,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved case 'nunc-dimittis': case 'marian-antiphon': case 'day-collect': + case 'lauds-psalmody': + case 'lauds-office': + case 'benedictus': + case 'suffrages': + case 'day-collects': throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 1d427d2..b7fbbb9 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -35,6 +35,62 @@ export function getDayCollect(day: LiturgicalDay): ResolvedText { return { text: {}, status: { la: 'missing', en: 'missing' } }; } +/** + * The day's own collect, plus one more per commemoration (calendar/ + * types.ts's LiturgicalDay.commemorations) — Lauds/Vespers say all of + * these in sequence, unlike the Little Hours' single getDayCollect. + * + * Simplification, not yet corrected: real practice compresses this into + * one "Orémus" and lets only the *last* collect close with the full + * doxology, with earlier ones trailing straight into the next ("And:"). + * Each collect file (data/propers/temporal/*-collect.yml, the per-saint + * *-collect.yml files) already bakes in its own "Orémus."/"Per Dóminum... + * Amen." for the single-collect case every other hour uses today, and + * stripping that back out per-collect to chain them properly would need + * text surgery this doesn't attempt — so on a commemorated day, each + * collect here renders as its own complete, separate block instead. An + * octave commemoration never contributes a collect (none authored — the + * octave content pull only ever sourced Matins readings, see propers/ + * octave-readings.ts), so those are silently skipped, same as a + * not-yet-authored saint's own collect resolves to "missing" rather than + * throwing. + */ +export function getDayCollects(day: LiturgicalDay): ResolvedText[] { + const collects = [getDayCollect(day)]; + for (const commemoration of day.commemorations) { + if (commemoration.kind === 'temporal') { + collects.push(toResolvedText(getTemporalProper(`${commemoration.id}-collect`))); + } else if (commemoration.kind === 'sanctoral') { + const saint = getSaintRecord(commemoration.id); + collects.push( + saint?.propers + ? resolveCommon(`${saint.propers}-collect`) + : { text: {}, status: { la: 'missing', en: 'missing' } }, + ); + } + } + return collects; +} + +/** + * Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a + * per-saint `${propers}-antiphon` (already authored during the sanctoral + * pull, sourced from each saint's own raw [Ant 1] — see e.g. + * st-lawrence-antiphon.yml) for a sanctoral winner, `${id}-benedictus- + * antiphon` (not authored yet for any temporal id — resolves "missing", + * same pending convention as everywhere else) for a temporal one. + */ +export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText { + if (day.winner.kind === 'temporal') { + return toResolvedText(getTemporalProper(`${day.winner.id}-benedictus-antiphon`)); + } + const saint = getSaintRecord(day.winner.id); + if (saint?.propers) { + return resolveCommon(`${saint.propers}-antiphon`); + } + return { text: {}, status: { la: 'missing', en: 'missing' } }; +} + export function verifiedText(text: Partial>): ResolvedText { const status: Partial> = {}; for (const lang of Object.keys(text)) { diff --git a/src/hours/sext.ts b/src/hours/sext.ts index 9d2bdc1..a4b5bb8 100644 --- a/src/hours/sext.ts +++ b/src/hours/sext.ts @@ -54,6 +54,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'closing-antiphon': case 'nunc-dimittis': case 'marian-antiphon': + case 'lauds-psalmody': + case 'lauds-office': + case 'benedictus': + case 'suffrages': + case 'day-collects': throw new Error(`Sext's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/terce.ts b/src/hours/terce.ts index 60563a3..361ae31 100644 --- a/src/hours/terce.ts +++ b/src/hours/terce.ts @@ -54,6 +54,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'closing-antiphon': case 'nunc-dimittis': case 'marian-antiphon': + case 'lauds-psalmody': + case 'lauds-office': + case 'benedictus': + case 'suffrages': + case 'day-collects': throw new Error(`Terce's ordo doesn't support a '${part.kind}' part`); } } diff --git a/src/hours/types.ts b/src/hours/types.ts index 2da2c19..6b71252 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -82,6 +82,48 @@ export type HourPart = // to a 'canticle' ResolvedPart (with the opening antiphon inline) plus a // standalone 'antiphon' ResolvedPart for the closing repeat. | { kind: 'nunc-dimittis' } + // Lauds only. Everything from Ps 66 through the Laudate psalms (148-150) + // inclusive, resolved by weekday — see hours/lauds.ts's resolvePsalmody + // and data/hours/lauds-antiphons.yml. Opaque here on purpose: the real + // shape (a fixed Ps 66 with no antiphon at all, then a weekday-variable + // number of antiphoned psalm groups, then a weekday-variable OT canticle, + // then the Laudate psalms under one more shared antiphon) doesn't fit any + // existing part kind, and hardcoding psalm numbers in lauds.yml can't + // work since they differ by weekday. Known, deliberate gap: does NOT + // model the Commune/feast override of this whole block (real practice + // substitutes an entirely different set of psalms+antiphons+canticle on + // a Semiduplex-or-higher day) — every day currently gets the plain + // ferial weekday distribution regardless of who wins. Fixing that is a + // separate, larger propers-authoring task (a full Common-of-Saints psalm + // set per Common), not started yet. + | { kind: 'lauds-psalmody' } + // Lauds only. The weekday-resolved chapter/short-responsory/hymn/closing + // versicle bundle that follows the psalmody — see hours/lauds.ts and + // data/propers/common/lauds-{capitulum,responsory,hymn,versicle}-*.yml. + // Expands into 4 ResolvedParts (chapter, responsory, hymn, versicle). + | { kind: 'lauds-office' } + // Lauds only. Benedictus (Luke 1:68-79), fixed text (see propers/common/ + // benedictus.yml) framed by a day-resolved antiphon (see hours/ + // resolve-common.ts's getBenedictusAntiphon) — same incipit-or-full/ + // full-after-Creed... no Creed at Lauds, just before+after framing shape + // as nunc-dimittis, gated the same way by isDoubleOrHigher. + | { kind: 'benedictus' } + // Lauds only. The four fixed Tridentine suffrages (Holy Cross, BVM, + // Ss. Peter & Paul, Peace) said after the day's collect(s) — see + // data/propers/common/lauds-suffrage-*.yml. `omitOnDouble` gates the + // whole set at once (real practice: suffrages drop on a Double-or-higher + // feast) — an honest simplification, see hours/lauds.ts for the caveat + // on what real practice also suppresses them for that isn't modeled. + | { kind: 'suffrages'; omitOnDouble?: true } + // Lauds/Vespers. The day's own collect *and* one more per commemoration + // (calendar/types.ts's LiturgicalDay.commemorations) — see hours/ + // resolve-common.ts's getDayCollects. Distinct from the singular + // 'day-collect' the Little Hours use (which only ever renders the + // winner's own collect, never a commemoration's). Simplification: each + // collect renders as its own complete "Let us pray ... Amen." block + // rather than the more compressed real form (one "Let us pray," only the + // last collect closes with the full doxology) — see hours/lauds.ts. + | { kind: 'day-collects' } // Compline's closing Marian antiphon (with its own versicle and collect // baked into the same text block) — resolved by season, defaulting to // Salve Regina. See hours/marian-antiphon.ts. diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts new file mode 100644 index 0000000..7ff4a05 --- /dev/null +++ b/tests/hours/lauds.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { resolveOrdo } from '../../src/hours'; + +// Clean ferial dates, one per weekday — see data/psalter-distribution.yml +// and src/hours/lauds.ts's own generation-script provenance comments for +// why these particular dates. +const FERIAL_SUNDAY = '2026-06-21'; +const FERIAL_MONDAY = '2026-08-31'; +const FERIAL_TUESDAY = '2026-06-16'; +const LAWRENCE_OCTAVE_DAY = '2026-08-11'; // Semiduplex -- exercises isDoubleOrHigher's false branch differently than a plain feria only in rank, not in psalms (Commune override not modeled, see below) + +describe('resolveOrdo("lauds", ...)', () => { + it('is implemented', () => { + expect(resolveOrdo('lauds', FERIAL_TUESDAY).notImplemented).toBeUndefined(); + }); + + it('opens with Ps 66 and gives it no antiphon at all, unlike every other psalm here', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const psalms = ordo.parts.filter((p) => p.kind === 'psalm'); + expect(psalms[0]?.kind === 'psalm' ? psalms[0].psalmNumber : undefined).toBe(66); + expect(psalms[0]?.kind === 'psalm' ? psalms[0].antiphon : 'should be undefined').toBeUndefined(); + }); + + it("resolves Tuesday's weekday psalmody: Ps 50, 42, 56, the Canticle of Ezechias, then 148-150", () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const psalmNumbers = ordo.parts + .filter((p) => p.kind === 'psalm') + .map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(psalmNumbers).toEqual([66, 50, 42, 56, 148, 149, 150]); + + const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus'); + expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-ezechiae'); + expect(canticle?.kind === 'canticle' ? canticle.text.text.la : undefined).toContain('In dimídio diérum meórum'); + }); + + it("gives each variable psalm group its own opening antiphon (incipit, below Double) and a full repeat right after", () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const ps50Index = ordo.parts.findIndex((p) => p.kind === 'psalm' && p.psalmNumber === 50); + const ps50 = ordo.parts[ps50Index]; + expect(ps50?.kind === 'psalm' ? ps50.antiphon?.text.la : undefined).toBe('Ant. Dele, Dómine.'); + const repeat = ordo.parts[ps50Index + 1]; + expect(repeat?.kind).toBe('antiphon'); + expect(repeat?.kind === 'antiphon' ? repeat.text.text.la : undefined).toBe('Ant. Dele, Dómine, * iniquitátem meam.'); + }); + + it("resolves Sunday's own psalmody: Ps 50+117 sharing one antiphon, then 62, then the Canticle of the Three Young Men", () => { + const ordo = resolveOrdo('lauds', FERIAL_SUNDAY); + const psalmNumbers = ordo.parts + .filter((p) => p.kind === 'psalm') + .map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(psalmNumbers).toEqual([66, 50, 117, 62, 148, 149, 150]); + + const ps117 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 117); + expect(ps117?.kind === 'psalm' ? ps117.antiphon : 'should be undefined -- shares Ps 50s opening antiphon').toBeUndefined(); + + const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus'); + expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum'); + }); + + it("resolves Saturday's own psalmody: only one weekday psalm (142), and the Canticle of Moses split across two D.O. slots joins into one continuous canticle", () => { + // Any Saturday works here: lauds-psalmody doesn't yet model the + // Commune/feast override real practice would apply on a Marian- + // Sabbato Saturday (see hours/types.ts's 'lauds-psalmody' doc comment) + // -- the plain weekday distribution is always used, so this is + // exercising the real, current behavior, not a stand-in for it. + const ordo = resolveOrdo('lauds', '2026-06-20'); + const psalmNumbers = ordo.parts + .filter((p) => p.kind === 'psalm') + .map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(psalmNumbers).toEqual([66, 50, 142, 148, 149, 150]); + + const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus'); + expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-moysis-deuteronomii'); + expect(canticle?.kind === 'canticle' ? canticle.text.text.la : undefined).toContain('Audíte, cæli'); + }); + + it('gives the Laudate psalms (148-150) one shared antiphon, opening before 148 and repeated in full after 150', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const ps148Index = ordo.parts.findIndex((p) => p.kind === 'psalm' && p.psalmNumber === 148); + const ps149 = ordo.parts[ps148Index + 1]; + const ps150 = ordo.parts[ps148Index + 2]; + expect(ps149?.kind === 'psalm' ? ps149.antiphon : 'should be undefined').toBeUndefined(); + expect(ps150?.kind === 'psalm' ? ps150.antiphon : 'should be undefined').toBeUndefined(); + const repeat = ordo.parts[ps148Index + 3]; + expect(repeat?.kind).toBe('antiphon'); + expect(repeat?.kind === 'antiphon' ? repeat.text.text.en : undefined).toContain( + 'Praise ye the Lord * from the heavens, all His angels', + ); + }); + + it("resolves the weekday chapter/responsory/hymn/versicle bundle, sharing the same capitulum Mon-Fri", () => { + const tue = resolveOrdo('lauds', FERIAL_TUESDAY); + const mon = resolveOrdo('lauds', FERIAL_MONDAY); + const tueChapter = tue.parts.find((p) => p.kind === 'chapter'); + const monChapter = mon.parts.find((p) => p.kind === 'chapter'); + expect(tueChapter?.kind === 'chapter' ? tueChapter.text.citation?.en : undefined).toBe('Rom 13:12-13'); + expect(tueChapter?.kind === 'chapter' ? tueChapter.text.text.la : undefined).toBe( + monChapter?.kind === 'chapter' ? monChapter.text.text.la : undefined, + ); + + const responsory = tue.parts.find((p) => p.kind === 'responsory'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Sana ánimam meam'); + + const hymn = tue.parts.find((p) => p.kind === 'hymn'); + expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Ales diéi núntius'); + }); + + it("marks Saturday's hymn honestly missing rather than presenting a privileged season's swapped hymn as the default", () => { + const ordo = resolveOrdo('lauds', '2026-06-20'); + const hymn = ordo.parts.find((p) => p.kind === 'hymn'); + expect(hymn?.kind === 'hymn' ? hymn.text.status.la : undefined).toBe('missing'); + }); + + it('resolves the Benedictus as a canticle with an opening antiphon and a full repeat, same shape as Compline\'s Nunc Dimittis', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const canticleIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); + const canticle = ordo.parts[canticleIndex]; + expect(canticle?.kind === 'canticle' ? canticle.text.text.en : undefined).toContain('Blessed be the Lord'); + const repeat = ordo.parts[canticleIndex + 1]; + expect(repeat?.kind).toBe('antiphon'); + }); + + it("falls back to a missing Benedictus antiphon on a plain temporal day (no per-Sunday antiphon authored yet)", () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); + expect(canticle?.kind === 'canticle' ? canticle.antiphon?.status?.la : undefined).toBeUndefined(); + }); + + it("resolves a real Benedictus antiphon on St. Lawrence's own octave day, from the saint's already-authored proper", () => { + const ordo = resolveOrdo('lauds', '2026-08-10'); // St. Lawrence's own day + const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); + expect(canticle?.kind === 'canticle' ? canticle.antiphon?.text.la : undefined).toContain('Levíta Lauréntius'); + }); + + it('includes the short litany (Kyrie + Our Father) right after the Benedictus, before the day collect', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const litanyIndex = ordo.parts.findIndex((p) => p.kind === 'preces' && p.text.text.la?.includes('Kýrie, eléison')); + const benedictusIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); + const collectIndex = ordo.parts.findIndex((p) => p.kind === 'prayer'); + expect(litanyIndex).toBeGreaterThan(benedictusIndex); + expect(collectIndex).toBeGreaterThan(litanyIndex); + }); + + it('resolves just the winners own collect on a plain day with nothing commemorated', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const collects = ordo.parts.filter((p) => p.kind === 'prayer'); + expect(collects.length).toBe(1); + }); + + it('resolves both the winners collect and a commemorated saints collect on a day with a real commemoration', () => { + // 2025-11-30: Advent I (the winner) with St. Andrew, Duplex II. classis, + // commemorated alongside it (see tests/calendar/day-label.test.ts) -- + // an octave commemoration (like LAWRENCE_OCTAVE_DAY's) never + // contributes a collect (none authored yet), so this needs a real + // sanctoral/temporal commemoration instead to exercise getDayCollects + // pushing a second entry. + const ordo = resolveOrdo('lauds', '2025-11-30'); + const collects = ordo.parts.filter((p) => p.kind === 'prayer'); + expect(collects.length).toBe(2); + expect(collects[1]?.kind === 'prayer' ? collects[1].text.text.en : undefined).toContain('Andrew'); + }); + + it("skips a straight octave commemoration for collect purposes (no octave collect authored)", () => { + const ordo = resolveOrdo('lauds', LAWRENCE_OCTAVE_DAY); + const collects = ordo.parts.filter((p) => p.kind === 'prayer'); + expect(collects.length).toBe(1); + }); + + it('includes all four Tridentine suffrages, in order, on an ordinary day', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const labels = ordo.parts.filter((p) => p.kind === 'preces' && p.label).map((p) => (p.kind === 'preces' ? p.label : undefined)); + expect(labels).toEqual([ + 'Of the Holy Cross', + 'Of the Blessed Virgin Mary', + 'Of the Holy Apostles Peter and Paul', + 'For Peace', + 'Salve Regina', + ]); + }); + + it('omits the suffrages on a Double-or-higher feast', () => { + const ordo = resolveOrdo('lauds', '2026-08-10'); // St. Lawrence, Duplex II. classis + const crossSuffrage = ordo.parts.find((p) => p.kind === 'preces' && p.label === 'Of the Holy Cross'); + expect(crossSuffrage).toBeUndefined(); + }); + + it('closes with the Conclusio, the closing versicle ("may the Lord grant us his peace... and life everlasting"), and the Marian antiphon -- without spelling out the silent Our Father', () => { + const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); + const closingVersicle = ordo.parts.find( + (p) => p.kind === 'versicle' && p.text.text.en?.includes('grant us his peace'), + ); + expect(closingVersicle?.kind === 'versicle' ? closingVersicle.text.text.en : undefined).toContain( + 'life everlasting', + ); + expect(closingVersicle?.kind === 'versicle' ? closingVersicle.text.text.en : undefined).not.toContain( + 'Our Father', + ); + + const last = ordo.parts[ordo.parts.length - 1]; + expect(last?.kind).toBe('preces'); + expect(last?.kind === 'preces' ? last.label : undefined).toBe('Salve Regina'); + }); +}); diff --git a/tests/hours/prime.test.ts b/tests/hours/prime.test.ts index 2e82ab8..338141d 100644 --- a/tests/hours/prime.test.ts +++ b/tests/hours/prime.test.ts @@ -194,8 +194,8 @@ describe('resolveOrdo("prime", ...)', () => { }); describe('resolveOrdo for not-yet-built hours', () => { - it('flags lauds as not implemented rather than throwing', () => { - const ordo = resolveOrdo('lauds', MONDAY); + it('flags vespers as not implemented rather than throwing', () => { + const ordo = resolveOrdo('vespers', MONDAY); expect(ordo.notImplemented).toBe(true); expect(ordo.parts).toEqual([]); }); diff --git a/tests/ui/shell.test.ts b/tests/ui/shell.test.ts index fa3ccbf..1c93e58 100644 --- a/tests/ui/shell.test.ts +++ b/tests/ui/shell.test.ts @@ -41,12 +41,12 @@ describe('shell', () => { const root = document.getElementById('app')!; mountShell(root); - const laudsBtn = Array.from(root.querySelectorAll('[data-hour]')).find( - (btn) => btn.dataset.hour === 'lauds', + const vespersBtn = Array.from(root.querySelectorAll('[data-hour]')).find( + (btn) => btn.dataset.hour === 'vespers', ); - laudsBtn?.click(); + vespersBtn?.click(); - expect(root.querySelector('.hour-view h2')?.textContent).toBe('Lauds'); + expect(root.querySelector('.hour-view h2')?.textContent).toBe('Vespers'); expect(root.querySelector('.hour-view-pending')).not.toBeNull(); });