Files
vu/tests/hours/prime.test.ts
T
will 472878da47 Switch Prime's Sunday/feast Preces to the secular Tridentine form
Monastic Prime's own Dominicales Preces drop the "Et ego ad te, Domine,
clamavi..." versicle block and shorten the closing "Miserere nostri,
Domine... Fiat misericordia tua..." set. Prefer the secular Tridentine
form instead (live-verified against Divinum Officium's "Tridentine -
1906" version), keeping "beato Patri nostro Benedicto" in the Confiteor
by explicit choice even though the real secular Confiteor omits it.

The Sunday/ferial by-day-kind split stays as-is; the ferial form
(prime-preces-feriales.yml) is unchanged and continues to show on every
ferial day by deliberate choice, more generously than the historical
rubric.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HWN869GrMFHqrer9fdCBbF
2026-08-25 07:55:41 -04:00

255 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { resolveOrdo } from '../../src/hours';
import { getPsalm } from '../../src/psalter';
// 2026-09-07 is a Monday, plain ferial (post-Pentecost 15's own week,
// no sanctoral entry, no commemoration) — the Monastic 1617 distribution
// gives Monday three whole psalms (1, 2, 6), all fully verified since the
// whole Psalter got bulk-pulled from Divinum Officium's own Psalmorum
// text (2026-08). Already had to move three times now (first from a June
// Monday, then an October Monday that got real sanctoral content — Ss.
// Placid and Companions — then 2026-11-16 itself, which the November
// sanctoral pull claimed for St. Gertrude) — pick with care if it needs
// to move again.
const MONDAY = '2026-09-07';
const SUNDAY = '2026-08-09';
describe('resolveOrdo("prime", ...)', () => {
it('is implemented and resolves psalms from the weekday-variable slot', () => {
const ordo = resolveOrdo('prime', MONDAY);
expect(ordo.notImplemented).toBeUndefined();
expect(ordo.hourId).toBe('prime');
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm');
expect(psalmParts.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined))).toEqual([1, 2, 6]);
});
it('resolves fixed parts (hymn, chapter, prayer) from the common propers store', () => {
const ordo = resolveOrdo('prime', MONDAY);
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
expect(hymn).toBeDefined();
if (hymn && hymn.kind === 'hymn') {
// hymn-iam-lucis-orto-sidere is real, verified content.
expect(hymn.text.status.en).toBe('verified');
}
});
it('surfaces verified/draft status per language, per verse — via the psalm store directly', () => {
// Monday's Prime psalms (1, 2, 6) are all fully verified now (the
// whole Psalter got bulk-pulled from Divinum Officium's own Psalmorum
// text, 2026-08), so there's no draft/missing content left in this
// ordo to exercise the fallback against — test the store directly
// instead. Ps 94 (the Invitatory, used only at Matins) is the one
// psalm with a real per-language split: its Latin is verified but its
// English is the traditional Coverdale/BCP "Venite" translation
// rather than this store's usual Douay-Rheims register, so it's
// marked `draft` pending a deliberate reword (see 094.yml, TODO.md).
const psalm = getPsalm(94);
expect(psalm.verses[0]?.status.la).toBe('verified');
expect(psalm.verses[0]?.status.en).toBe('draft');
});
it('surfaces missing status for a genuinely unauthored psalm number', () => {
// 151 isn't a real Vulgate/Gallican psalm (that numbering tops out at
// 150) — exercises getPsalm's pending-verse fallback now that every
// real psalm 1-150 is authored.
const psalm = getPsalm(151);
expect(psalm.verses).toHaveLength(1);
expect(psalm.verses[0]?.status.en).toBe('missing');
});
it('slices a verse range out of a longer psalm on days that split one across the week', () => {
// Tuesday: 7, 8, 9(2-19).
const ordo = resolveOrdo('prime', '2026-08-11');
const ps9 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 9);
expect(ps9 && ps9.kind === 'psalm' ? ps9.verses.every((v) => v.n >= 2 && v.n <= 19) : false).toBe(true);
});
it('reads the Martyrology entry for the day *after* the ordo date, headed "Martyrology"', () => {
const ordo = resolveOrdo('prime', '2026-08-25');
const martyrology = ordo.parts.find((p) => p.kind === 'lesson' && p.label === 'Martyrology');
expect(martyrology).toBeDefined();
// 08-26's real content (not 08-25's), proving the date offset still works.
expect(martyrology && martyrology.kind === 'lesson' ? martyrology.text.text.la : undefined).toContain(
'Zephyríni',
);
});
it("prepends the Luna (day of the moon) heading to the Martyrology reading", () => {
const ordo = resolveOrdo('prime', '2026-08-25');
const martyrology = ordo.parts.find((p) => p.kind === 'lesson' && p.label === 'Martyrology');
// Verified against the real engine: 2026-08-26 is Luna 13.
expect(martyrology && martyrology.kind === 'lesson' ? martyrology.text.text.la : undefined).toContain(
'Luna tértia décima',
);
expect(martyrology && martyrology.kind === 'lesson' ? martyrology.text.text.en : undefined).toContain(
'13th day of the Moon',
);
});
it('resolves a Regula reading for the day, via the canonical-date table', () => {
const ordo = resolveOrdo('prime', '2026-08-25');
// 08-25 maps to canonical 04-25 (Rule ch. 67) in data/regula/table.yml.
const rule = ordo.parts.find(
(p) => p.kind === 'lesson' && typeof p.label === 'string' && p.label.includes('67'),
);
expect(rule).toBeDefined();
});
it('includes the commemoration of the dead as the final parts', () => {
const ordo = resolveOrdo('prime', MONDAY);
const last = ordo.parts[ordo.parts.length - 1];
expect(last?.kind).toBe('preces');
});
it('uses the ferial capitulum and Preces on a plain weekday', () => {
const ordo = resolveOrdo('prime', MONDAY);
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
expect(chapter && chapter.kind === 'chapter' ? chapter.text.text.en : undefined).toBe(
'Love ye truth and peace, said the Almighty Lord.',
);
const preces = ordo.parts.filter((p) => p.kind === 'preces');
const ferialPreces = preces.find((p) => p.kind === 'preces' && p.text.text.la?.startsWith('V. Éripe me'));
expect(ferialPreces).toBeDefined();
});
it('uses the Sunday capitulum and the secular Preces (with Benedict kept in the Confiteor) on a Sunday', () => {
const ordo = resolveOrdo('prime', SUNDAY);
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
expect(chapter && chapter.kind === 'chapter' ? chapter.text.text.en : undefined).toBe(
'Now to the king of ages, immortal, invisible, the only God, be honour and glory for ever and ever. Amen.',
);
const preces = ordo.parts.filter((p) => p.kind === 'preces');
const dominicalPreces = preces.find((p) => p.kind === 'preces' && p.text.text.la?.includes('Confíteor Deo'));
expect(dominicalPreces).toBeDefined();
expect(
dominicalPreces && dominicalPreces.kind === 'preces' ? dominicalPreces.text.text.la : undefined,
).toContain('beáto Patri nostro Benedícto');
expect(
dominicalPreces && dominicalPreces.kind === 'preces' ? dominicalPreces.text.text.la : undefined,
).toContain('Et ego ad te, Dómine, clamávi');
});
it('gives the capitulum its scripture citation', () => {
const ordo = resolveOrdo('prime', MONDAY);
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
expect(chapter && chapter.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Zach. 8:19');
});
it('includes the Athanasian Creed on a Sunday but not a ferial day, and closes with the antiphon after it', () => {
const sunday = resolveOrdo('prime', SUNDAY);
const creedIndex = sunday.parts.findIndex((p) => p.kind === 'lesson' && p.label === 'Athanasian Creed');
const antiphonIndex = sunday.parts.findIndex((p) => p.kind === 'antiphon');
expect(creedIndex).toBeGreaterThanOrEqual(0);
expect(antiphonIndex).toBeGreaterThan(creedIndex);
const monday = resolveOrdo('prime', MONDAY);
expect(monday.parts.some((p) => p.kind === 'lesson' && p.label === 'Athanasian Creed')).toBe(false);
});
it('leaves most Preces/connective parts unlabeled, but names Conclusion and the dead commemoration', () => {
const ordo = resolveOrdo('prime', MONDAY);
const actualPreces = ordo.parts.find((p) => p.kind === 'preces' && p.text.text.la?.includes('Kýrie, eléison'));
expect(actualPreces && actualPreces.kind === 'preces' ? actualPreces.label : 'should be undefined').toBeUndefined();
const regulaBlessing = ordo.parts.find((p) => p.kind === 'preces' && p.text.text.la?.includes('Jube, domne'));
expect(regulaBlessing && regulaBlessing.kind === 'preces' ? regulaBlessing.label : 'should be undefined').toBeUndefined();
const conclusion = ordo.parts.find((p) => p.kind === 'preces' && p.label === 'Conclusion');
expect(conclusion).toBeDefined();
const deadCommemoration = ordo.parts.find((p) => p.kind === 'preces' && p.label === 'Commemoration of the Dead');
expect(deadCommemoration).toBeDefined();
});
it('gives the generic reading after the Regula the label "Short Reading"', () => {
const ordo = resolveOrdo('prime', MONDAY);
const shortReading = ordo.parts.find((p) => p.kind === 'lesson' && p.label === 'Short Reading');
expect(shortReading).toBeDefined();
});
it('opens the weekday psalm group with just the antiphon incipit (below Double rank)', () => {
const ordo = resolveOrdo('prime', MONDAY);
const psalms = ordo.parts.filter((p) => p.kind === 'psalm');
expect(psalms[0]?.kind === 'psalm' ? psalms[0].antiphon?.text.en : undefined).toBe(
'Ant. Serve ye the Lord with fear:',
);
expect(psalms[1]?.kind === 'psalm' ? psalms[1].antiphon : 'missing-case').toBeUndefined();
});
it('closes with the full antiphon as its own part, after the psalms and the Creed', () => {
const ordo = resolveOrdo('prime', MONDAY);
const psalmIndex = ordo.parts.findIndex((p) => p.kind === 'psalm');
const lastPsalmIndex = ordo.parts.map((p) => p.kind).lastIndexOf('psalm');
const antiphonIndex = ordo.parts.findIndex((p) => p.kind === 'antiphon');
expect(antiphonIndex).toBeGreaterThan(lastPsalmIndex);
expect(psalmIndex).toBeGreaterThanOrEqual(0);
const antiphon = ordo.parts.find((p) => p.kind === 'antiphon');
// "Ant." prefix (inline, like V./R.) and the "*" chant mark are both
// kept in the full form — see hours/antiphon.ts.
expect(antiphon && antiphon.kind === 'antiphon' ? antiphon.text.text.en : undefined).toBe(
'Ant. Serve ye the Lord with fear: * and rejoice unto him with trembling.',
);
});
it('gives the responsory the full short-responsory shape (R.br, full repeat, verse, response, Gloria, full repeat)', () => {
const ordo = resolveOrdo('prime', MONDAY);
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
const lines = responsory && responsory.kind === 'responsory' ? responsory.text.text.en?.trim().split('\n') ?? [] : [];
expect(lines).toEqual([
'R.br. Christ, Son of the living God, * have mercy on us.',
'R. Christ, Son of the living God, * have mercy on us.',
'V. Thou, who sittest at the right hand of the Father.',
'R. Have mercy on us.',
'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.',
'R. Christ, Son of the living God, * have mercy on us.',
]);
});
it("appends the hymn's (currently per-annum, since season isn't real yet) doxology stanza", () => {
const ordo = resolveOrdo('prime', MONDAY);
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
expect(hymn && hymn.kind === 'hymn' ? hymn.text.text.en : undefined).toContain(
'All laud to God the Father be',
);
});
it("resolves St. Lawrence's own proper antiphon, on both his own day and his own octave -- previously always fell back to the plain weekday one with no override mechanism at all", () => {
for (const date of ['2026-08-10', '2026-08-12']) {
const ordo = resolveOrdo('prime', date);
const antiphon = ordo.parts.find((p) => p.kind === 'psalm')?.antiphon;
expect(antiphon?.text.la).toContain('Lauréntius');
const closing = ordo.parts.find((p) => p.kind === 'antiphon');
expect(closing?.kind === 'antiphon' ? closing.text.text.la : undefined).toContain('Lauréntius');
}
});
it("still resolves the Sunday/feast capitulum (1 Tim 1:17) throughout St. Lawrence's own octave, matching his actual day, via isSundayOrFeastOffice", () => {
for (const date of ['2026-08-10', '2026-08-12']) {
const ordo = resolveOrdo('prime', date);
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
expect(chapter?.kind === 'chapter' ? chapter.text.text.la : undefined).toContain('Regi sæculórum');
}
});
});
describe('resolveOrdo("prime", ...) chapter-responsory Gloria omission', () => {
// Same Passiontide-wide, Sancti-exempt rule as Compline's — see
// tests/hours/compline.test.ts's own describe block for the live-
// verification dates.
it('omits the Gloria on a ferial Passiontide day, keeps it on an ordinary date', () => {
const passiontideResponsory = resolveOrdo('prime', '2027-03-15').parts.find((p) => p.kind === 'responsory');
const ordinaryResponsory = resolveOrdo('prime', '2026-08-20').parts.find((p) => p.kind === 'responsory');
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
'Glória Patri',
);
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
});
it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => {
const ordo = resolveOrdo('prime', '2027-03-19'); // St. Joseph, Duplex I.
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
});
});