hours: build Lauds
Deploy / deploy (push) Successful in 48s

Opening versicle; Ps 66 (fixed, no antiphon at all); the weekday-
variable psalm groups (Ps 50 plus 1-2 more, framed by their own
antiphon each, wholesale-substituted by Sunday's Ps 50+117-share-one-
antiphon shape); the weekday OT canticle; the Laudate psalms (148-150)
under one more shared antiphon; the weekday chapter/responsory/hymn/
versicle bundle; the Benedictus (framed by a day-resolved antiphon,
same incipit-or-full/full-repeat shape as Compline's Nunc Dimittis);
the Kyrie/Pater-noster short litany; the day's collect(s) — winner
plus one per commemoration, via the new getDayCollects; the four
Tridentine suffrages (omitted on a Double-or-higher feast); the
conclusio; the closing versicle (the silent Our Father that precedes
it isn't re-rendered, its text having already appeared once at the
short litany); and the closing Marian antiphon, reusing Compline's own
mechanism exactly.

Known, deliberate gap, not yet fixed: the psalmody doesn't model the
Commune/feast override real practice applies on a Semiduplex-or-higher
day (substituting an entirely different psalm+canticle set) — every
day currently gets the plain ferial weekday distribution regardless of
who wins. That's a separate, larger propers-authoring task (a full
Common-of-Saints psalm set per Common).

Also a simplification in getDayCollects: each collect in a multi-
collect day 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's doxology).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:45:25 -04:00
parent 70fbdd12d0
commit 5830e4cfd2
12 changed files with 547 additions and 11 deletions
+25
View File
@@ -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
+5
View File
@@ -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`);
}
}
+190 -5
View File
@@ -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<Record<string, string>>;
interface LaudsGroup {
psalms: number[];
antiphon: BilingualText;
}
interface LaudsWeekdayAntiphons {
groups: LaudsGroup[];
canticle: { id: string; antiphon: BilingualText };
laudate: { antiphon: BilingualText };
}
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, LaudsWeekdayAntiphons>;
// 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<Record<string, string>> = {};
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
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<string, string> = {
'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) };
}
+5
View File
@@ -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`);
}
}
+5
View File
@@ -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`);
}
}
+56
View File
@@ -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<Record<string, string>>): ResolvedText {
const status: Partial<Record<string, 'verified'>> = {};
for (const lang of Object.keys(text)) {
+5
View File
@@ -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`);
}
}
+5
View File
@@ -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`);
}
}
+42
View File
@@ -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.
+203
View File
@@ -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');
});
});
+2 -2
View File
@@ -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([]);
});
+4 -4
View File
@@ -41,12 +41,12 @@ describe('shell', () => {
const root = document.getElementById('app')!;
mountShell(root);
const laudsBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-hour]')).find(
(btn) => btn.dataset.hour === 'lauds',
const vespersBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[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();
});