Build the Vespers ordo: mechanism, weekday content, seasonal content
Wires resolveOrdo('vespers', ...) end to end: opening versicle,
weekday psalmody (4 groups, handling the joined Ps 115+116 and the
split Ps 138/143 via a PsalmRef-like shape), the chapter/responsory/
hymn/versicle office bundle, Magnificat, day-collects — reusing
resolveEveningDay so First/Second Vespers precedence and the cross-day
commemoration (both added in the previous commit) apply automatically
— and a closing that reuses Lauds' own conclusio, since no distinct
"Vespera" version of that exact text turned up in the source.
getMagnificatAntiphon mirrors getBenedictusAntiphon's saint/temporal-id
lookup order, but also falls back to real content Benedictus has no
equivalent of: the classic ferial set that quotes the Magnificat's own
text in sequence across the week, with the correct Monastic-rubric
variant picked for Tue/Thu/Fri/Sat (two of those needed a fresh
translation where the Monastic Latin diverges from the given English
in content, not just wording).
Also includes Vespers' own seasonal content (Advent/Lent/Passiontide/
Paschaltide chapter+responsory+hymn+versicle), transcribed from the
same source blocks as the weekday content, with the Monastic-specific
hymn variants hand-applied from the source's substitution notation
against each base Roman text (one transcription slip caught and fixed
mid-pass: the Passiontide hymn's closing lines had been copied from
the Roman text instead of the Monastic one actually being
transcribed). Passiontide's own responsory/hymn correctly omit the
Gloria Patri, matching real rubric and the source data as given.
All weekday/seasonal content marked `draft`, not `verified` — read
directly from the reference source, not cross-checked against a live
rendering per day the way the psalmody was.
Six other hours' resolvePart switches needed the three new HourPart
kinds ('vespers-psalmody', 'vespers-office', 'magnificat') added to
their exhaustive not-used lists so they still typecheck; three
pre-existing tests assumed Vespers was still "coming soon" and are
updated to exercise Matins (still the genuine stub) instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -226,8 +226,8 @@ describe('resolveOrdo("prime", ...)', () => {
|
||||
});
|
||||
|
||||
describe('resolveOrdo for not-yet-built hours', () => {
|
||||
it('flags vespers as not implemented rather than throwing', () => {
|
||||
const ordo = resolveOrdo('vespers', MONDAY);
|
||||
it('flags matins as not implemented rather than throwing', () => {
|
||||
const ordo = resolveOrdo('matins', MONDAY);
|
||||
expect(ordo.notImplemented).toBe(true);
|
||||
expect(ordo.parts).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOrdo } from '../../src/hours';
|
||||
|
||||
describe('resolveOrdo("vespers", ...)', () => {
|
||||
it('is implemented and resolves Sunday psalmody in order', () => {
|
||||
// 2026-08-16 is a Sunday, in ordinary time — no First Vespers claim
|
||||
// from the following Monday (a plain feria), so this stays Sunday's
|
||||
// own Second Vespers.
|
||||
const ordo = resolveOrdo('vespers', '2026-08-16');
|
||||
expect(ordo.notImplemented).toBeUndefined();
|
||||
expect(ordo.hourId).toBe('vespers');
|
||||
|
||||
const psalmNumbers = ordo.parts
|
||||
.filter((p) => p.kind === 'psalm')
|
||||
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
expect(psalmNumbers).toEqual([109, 110, 111, 112]);
|
||||
});
|
||||
|
||||
it('joins Ps 115 and 116 under one shared antiphon on Monday', () => {
|
||||
// 2026-08-17 is a Monday, in ordinary time.
|
||||
const ordo = resolveOrdo('vespers', '2026-08-17');
|
||||
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm');
|
||||
const numbers = psalmParts.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
expect(numbers).toEqual([113, 114, 115, 116, 128]);
|
||||
|
||||
// Ps 116 (the second half of the joined pair) carries no antiphon of
|
||||
// its own — only Ps 115 (the group's first entry) does.
|
||||
const ps115 = psalmParts[2];
|
||||
const ps116 = psalmParts[3];
|
||||
expect(ps115?.kind === 'psalm' ? ps115.antiphon : undefined).toBeDefined();
|
||||
expect(ps116?.kind === 'psalm' ? ps116.antiphon : undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it('splits Ps 138 into two verse-ranges under one shared antiphon on Thursday', () => {
|
||||
// 2026-08-20 is a Thursday, in ordinary time.
|
||||
const ordo = resolveOrdo('vespers', '2026-08-20');
|
||||
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm');
|
||||
const numbers = psalmParts.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
|
||||
expect(numbers).toEqual([138, 138, 139, 140]);
|
||||
|
||||
const [firstHalf, secondHalf] = psalmParts;
|
||||
expect(firstHalf?.kind === 'psalm' ? firstHalf.verses.every((v) => v.n >= 1 && v.n <= 10) : false).toBe(true);
|
||||
expect(secondHalf?.kind === 'psalm' ? secondHalf.verses.every((v) => v.n >= 11 && v.n <= 24) : false).toBe(true);
|
||||
expect(secondHalf?.kind === 'psalm' ? secondHalf.antiphon : undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves Saturday's own distinct chapter, responsory, hymn, and versicle", () => {
|
||||
// 2026-08-15 is a Saturday and the Assumption (Duplex I. classis) —
|
||||
// well above keepsOwnSecondVespers's threshold, so it keeps its own
|
||||
// Vespers (weekday stays 'saturday') rather than ceding to tomorrow.
|
||||
// No per-feast Vespers-office override exists yet (see hours/
|
||||
// vespers.ts), so the plain Saturday weekday default still renders
|
||||
// here regardless of the Assumption's own rank — exactly what this
|
||||
// test wants to check.
|
||||
const ordo = resolveOrdo('vespers', '2026-08-15');
|
||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Rom 11:33');
|
||||
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('O lux beáta Trínitas');
|
||||
|
||||
const versicle = ordo.parts.find((p) => p.kind === 'versicle' && p.text.text.la?.includes('Vespertína'));
|
||||
expect(versicle).toBeDefined();
|
||||
});
|
||||
|
||||
it('falls back to the classic weekday-default Magnificat antiphon on a bare ferial', () => {
|
||||
// 2026-06-16 is a Tuesday, plain ordinary trinitytide, no saint, no
|
||||
// active octave (same date already used by calendar/vespers.test.ts's
|
||||
// "does not anticipate between two plain ferias").
|
||||
const ordo = resolveOrdo('vespers', '2026-06-16');
|
||||
const magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat');
|
||||
expect(magnificatIndex).toBeGreaterThanOrEqual(0);
|
||||
const magnificat = ordo.parts[magnificatIndex];
|
||||
expect(magnificat?.kind === 'canticle' ? magnificat.text.text.la : undefined).toContain('Magníficat ✠ ánima mea');
|
||||
// The closing full-antiphon repeat is the very next part.
|
||||
const closingAntiphon = ordo.parts[magnificatIndex + 1];
|
||||
expect(closingAntiphon?.kind === 'antiphon' ? closingAntiphon.text.text.la : undefined).toContain('Exsúltet');
|
||||
});
|
||||
|
||||
it('folds the cross-day Vespers commemoration into day-collects (St. James keeps Vespers, St. Anne commemorated)', () => {
|
||||
// St. Christopher (same-day collision, commemorated under James
|
||||
// already at the calendar level) and St. Anne (the cross-day Vespers
|
||||
// commemoration from calendar/vespers.ts) are both sanctoral, so both
|
||||
// render as 'preces' parts (see resolve-common.ts's
|
||||
// sanctoralCommemorationPart) — only a *temporal* commemoration
|
||||
// renders as 'prayer'.
|
||||
const ordo = resolveOrdo('vespers', '2026-07-25');
|
||||
const collectParts = ordo.parts.filter((p) => p.kind === 'prayer' || p.kind === 'preces');
|
||||
// James's own collect ('prayer') + Christopher's + Anne's commemorations ('preces').
|
||||
expect(collectParts.length).toBeGreaterThanOrEqual(3);
|
||||
const anne = ordo.parts.find((p) => p.kind === 'preces' && p.label?.includes('Anne'));
|
||||
expect(anne).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders Advent's seasonal chapter/hymn/versicle instead of the plain weekday default", () => {
|
||||
// 2025-12-02 is a Tuesday in Advent (St. Bibiana, Simplex, merely
|
||||
// commemorated — the day's own winner stays temporal either way).
|
||||
const ordo = resolveOrdo('vespers', '2025-12-02');
|
||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Gen 49:10');
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Cónditor alme síderum');
|
||||
const versicle = ordo.parts.find((p) => p.kind === 'versicle' && p.text.text.la?.includes('Roráte'));
|
||||
expect(versicle).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders Lent's seasonal office", () => {
|
||||
// 2026-03-03 is a Tuesday in Lent, before Passiontide.
|
||||
const ordo = resolveOrdo('vespers', '2026-03-03');
|
||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Joel 2:17');
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Audi, benígne Cónditor');
|
||||
});
|
||||
|
||||
it("renders Passiontide's seasonal office, with the Gloria Patri omitted from the responsory", () => {
|
||||
// 2026-03-25 is a Wednesday in Passiontide (Passion Sunday 2026-03-22
|
||||
// through Holy Saturday 2026-04-04).
|
||||
const ordo = resolveOrdo('vespers', '2026-03-25');
|
||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Jer 11:20');
|
||||
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
|
||||
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).not.toContain('Glória Patri');
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Vexílla Regis pródeunt');
|
||||
});
|
||||
|
||||
it("renders Paschaltide's seasonal office, sharing its chapter with Lauds", () => {
|
||||
// 2026-04-20 is in eastertide (Easter 2026-04-05).
|
||||
const ordo = resolveOrdo('vespers', '2026-04-20');
|
||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Rom 6:9-10');
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Ad cenam Agni próvidi');
|
||||
const versicle = ordo.parts.find((p) => p.kind === 'versicle' && p.text.text.la?.includes('Mane nobíscum'));
|
||||
expect(versicle).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -41,12 +41,12 @@ describe('shell', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
const vespersBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-hour]')).find(
|
||||
(btn) => btn.dataset.hour === 'vespers',
|
||||
const matinsBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-hour]')).find(
|
||||
(btn) => btn.dataset.hour === 'matins',
|
||||
);
|
||||
vespersBtn?.click();
|
||||
matinsBtn?.click();
|
||||
|
||||
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Vespers');
|
||||
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Matins');
|
||||
expect(root.querySelector('.hour-view-pending')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user