5f4cf33776
Deploy / deploy (push) Successful in 51s
Prime/Terce/Sext/None had no per-feast override mechanism at all before this -- their psalm antiphon always fell back to the plain weekday default and their chapter was always a single fixed per-annum/per-weekday text, regardless of what was actually being celebrated, even on a saint's own actual feast day (not just during an octave). Confirmed live (Monastic Tridentinum 1617) that St. Lawrence has his own real antiphon and chapter at every one of these hours, both on his own day and throughout his octave. New shared mechanism in hours/resolve-common.ts: getMinorHourOverrideId (duplex-majus+ threshold, kept in sync with Lauds' own so it's one shared backlog), resolveMinorHourAntiphon, resolveMinorHourChapter -- all resolveOfficeWinner-aware, so an octave day gets the same override as the feast's own actual day. Prime additionally needed isSundayOrFeastOffice (its capitulum/Preces Sunday-vs-ferial choice) made octave-aware -- live-verified Prime's capitulum stays the Sunday/feast form throughout the octave too, so no new file was needed there, just reaching the existing one correctly. Also fills in Lauds' own still-missing Lawrence office bundle (capitulum/responsory/hymn/versicle) -- flagged in TODO.md as worth fixing "even before tackling the other 32" and still not done until now. Authored real content for St. Lawrence across all five hours from live queries. The same ~31-saint backlog (TODO.md) now applies uniformly across Lauds and the four hours fixed here. Also documents a real, found-not-fixed gap: when two octaves overlap with no temporal standing to yield to (St. Lawrence's and the Assumption's genuinely overlap every Aug 16-17), this app currently picks whichever started first, which happens to match the live engine on Aug 17 (Lawrence's own elevated final "Octave Day" outranks the Assumption's ordinary octave-day strength) but only by coincidence, not a real rank comparison -- proposed design written up in TODO.md pending a decision on scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { resolveOrdo } from '../../src/hours';
|
|
|
|
// 2026-11-16 is a Monday, and (as of the pre-1955 sanctoral pull reaching
|
|
// October, with November/December still unpulled) still ordinary/ferial —
|
|
// the Monastic 1617 distribution gives Monday three whole psalms (1, 2,
|
|
// 6), the first of which (Psalm 1) has real sample content in
|
|
// data/psalms/001.yml, so these tests can check real verse data without
|
|
// needing every psalm authored. Already had to move twice (first from a
|
|
// June Monday, then from an October Monday that got real sanctoral
|
|
// content — Ss. Placid and Companions, see
|
|
// data/calendar/saints/ss-placid-and-companions.yml) — if November's own
|
|
// pull lands here too, it'll need to move again.
|
|
const MONDAY = '2026-11-16';
|
|
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/missing status per language, per verse', () => {
|
|
const ordo = resolveOrdo('prime', MONDAY);
|
|
const psalm = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 1);
|
|
expect(psalm && psalm.kind === 'psalm' ? psalm.verses.length : 0).toBe(3);
|
|
if (psalm && psalm.kind === 'psalm') {
|
|
expect(psalm.verses[0]?.status.en).toBe('draft');
|
|
expect(psalm.verses[2]?.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' && 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 Preces 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();
|
|
});
|
|
|
|
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 for not-yet-built hours', () => {
|
|
it('flags vespers as not implemented rather than throwing', () => {
|
|
const ordo = resolveOrdo('vespers', MONDAY);
|
|
expect(ordo.notImplemented).toBe(true);
|
|
expect(ordo.parts).toEqual([]);
|
|
});
|
|
});
|