Compare commits

...

2 Commits

Author SHA1 Message Date
will 25934865b1 Model a Matins Gospel pericope and its paired homily as one atomic part
Deploy / deploy (push) Successful in 1m25s
Replaces the lesson+isGospel flag with a dedicated 'gospel' ResolvedPart
carrying an optional nested homily. Previously a Gospel pericope and its
patristic homily were two separate pool entries, which the front-light
1/1/rest nocturn slicer could split into different nocturns purely by
index. buildReadingPool now folds an isGospel:true reading and its
immediately-following same-nocturn homily into a single pool entry, so
the pairing is structural rather than a convention the slicer has to
honor. hour-view.ts renders the pair as one visually grouped section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1LqodVbQdQVoTGPz9sSw4
2026-08-28 19:46:48 -04:00
will 6a17a74b9b Fix Vespers ferial psalmody following an anticipated feast's identity
When tonight anticipates tomorrow's First Vespers, resolvePsalmody was
keying off the borrowed day's weekday instead of today's actual calendar
weekday, silently shifting the fixed 6-day psalm rotation. The psalm
cycle has no per-feast override and must stay on today's own place in
the rotation regardless of whose office governs the evening's propers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1LqodVbQdQVoTGPz9sSw4
2026-08-28 19:46:33 -04:00
8 changed files with 205 additions and 55 deletions
+35 -7
View File
@@ -20,13 +20,6 @@ across every hour and content type in this app.
1. **Mechanism gaps** — a real design/code piece not built yet, blocking any content pass on 1. **Mechanism gaps** — a real design/code piece not built yet, blocking any content pass on
it from starting: it from starting:
- Easter's own octave — tabled, "a whole different discussion." - Easter's own octave — tabled, "a whole different discussion."
- Gospel pericope + paired homily rendered as one part: decided, not yet built. When a
Matins Nocturn 3 reading pairs a Gospel pericope with a patristic homily on it, render them
as two distinct parts, one after the other, with markup that visibly distinguishes pericope
from homily (not concatenated into a single `lesson` text as now). A bible-plan Gospel
reading has no paired homily — it renders as just the pericope, which is correct as-is.
Needs a schema change (splitting `lesson`+`isGospel` into a Gospel part with an optional
homily part) and matching UI treatment in `hour-view.ts`.
- Date picker: `day-nav.ts` currently only steps a day at a time (prev/next); there's no - Date picker: `day-nav.ts` currently only steps a day at a time (prev/next); there's no
way to jump to an arbitrary date without walking there one day at a time or editing the way to jump to an arbitrary date without walking there one day at a time or editing the
URL. Needs a calendar-grid picker UI — open, not started. URL. Needs a calendar-grid picker UI — open, not started.
@@ -3887,3 +3880,38 @@ Transfiguration, All Saints of the Benedictine Order). The same session also clo
pass touched (see "Matins hymn gap closed for the same categories/saints" elsewhere in this pass touched (see "Matins hymn gap closed for the same categories/saints" elsewhere in this
file) — Matins hymns are a different mechanism from the Lauds/Vespers bundle and needed their file) — Matins hymns are a different mechanism from the Lauds/Vespers bundle and needed their
own pass. own pass.
### Gospel pericope + paired homily rendered as one part — done (2026-08-28)
Closed the last open Matins mechanism gap. `ResolvedPart` gained its own `'gospel'` kind
(`src/hours/types.ts`), replacing the old `lesson`+`isGospel` flag entirely — a Gospel pericope
now carries an optional nested `homily` (`{ source?, text }`) instead of being a same-kind
sibling `lesson` part next to it.
The reason for nesting rather than keeping two adjacent pool entries: `distributeIntoNocturns`'s
front-light 1/1/rest slicing (see "Reading distribution" elsewhere in this file) splits the
reading pool by raw index, with no notion that two adjacent entries belong together — a Gospel
pericope and its own homily, sourced as two separate array entries in
`data/propers/nocturn-readings/*.yml`, could land in different nocturns purely by where they
fell in the pool. Fixed at the pool-assembly step instead of patching the slicer:
`buildReadingPool` (`src/hours/matins.ts`) now detects an `isGospel: true` NocturnReading
immediately followed by a same-nocturn `isGospel: false` one and folds the pair into a single
`gospel` pool entry before it ever reaches `distributeIntoNocturns` — the pairing is structural
(one array element), not a convention the slicer has to know about. A bible-plan Gospel reading
(never paired with a homily, per the user's own plan) becomes a bare `gospel` part with no
`homily`, so it still gets the same "Gospel" UI treatment.
One real data wrinkle surfaced, not touched: `chair-of-st-peter-at-rome.yml`'s Nocturn 3 has the
homily prose already folded into the same `isGospel: true` entry's own `text` (an earlier, less
clean authoring convention flagged in this file's own SanctiM-pass notes) rather than as a
separate following reading — that file is left as-is per the earlier decision not to "fix" it;
the new `'gospel'` type's `source` field (distinct from `homily`) exists specifically to carry
that entry's own attribution without misrepresenting it as a separate homily reading.
`hour-view.ts` renders the pericope with a "Gospel" heading, then the nested homily (when
present) directly underneath in the same section, visually set off by a top border
(`.ordo-part-homily` in `styles.css`) — one section, pericope then homily, rather than two
separate `ordo-part` blocks that could be visually or structurally pulled apart.
`tests/hours/matins.test.ts`'s Sunday proof (2026-09-06, Luke 7:11-16 + St. Augustine's homily)
updated to assert against the new `gospel`/`homily` shape instead of two `isGospel`-flagged
lessons. `npm test`/`npm run build` both pass.
+47 -9
View File
@@ -515,11 +515,30 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart {
kind: 'lesson', kind: 'lesson',
text: { text: r.text, status: r.status, citation: r.citation }, text: { text: r.text, status: r.status, citation: r.citation },
label: r.source, label: r.source,
isGospel: r.isGospel,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
}; };
} }
/** Builds a single atomic 'gospel' pool entry from a Gospel-flagged
* NocturnReading, folding in `homily` (the immediately following reading in
* the same array, when it's a genuine separate homily on this pericope —
* see `buildReadingPool`'s own pairing check) as a nested field rather than
* a second pool entry. This is what makes the pairing structural: a single
* ResolvedPart can't be split across two nocturns by `distributeIntoNocturns`,
* whereas two adjacent pool entries could be (and, before this, sometimes
* were). */
function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined): ResolvedPart {
return {
kind: 'gospel',
text: { text: r.text, status: r.status, citation: r.citation },
source: r.source,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
homily: homily
? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } }
: undefined,
};
}
/** The full pool of readings available for `day` — every source that can /** The full pool of readings available for `day` — every source that can
* contribute (see this file's header): the user's own scripture-plan * contribute (see this file's header): the user's own scripture-plan
* readings, the office winner's and every commemorated saint's own * readings, the office winner's and every commemorated saint's own
@@ -538,12 +557,16 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart {
function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] { function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] {
const parts: ResolvedPart[] = []; const parts: ResolvedPart[] = [];
for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) { for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) {
parts.push({ const responsory = r.responsory ? { text: r.responsory, status: { la: 'verified' as const, en: 'verified' as const } } : undefined;
kind: 'lesson', // The user's own reading plan never pairs a Gospel with a homily (see
text: { text: r.text, status: r.status, citation: r.citation }, // this file's own header) — a bare pericope, still its own 'gospel'
isGospel: r.isGospel, // kind so it gets the same distinguishing UI treatment as a proper
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, // Gospel+homily.
}); parts.push(
r.isGospel
? { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, responsory }
: { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory },
);
} }
// Grouped by the reading's own `nocturn` tag (ascending), not by which id // Grouped by the reading's own `nocturn` tag (ascending), not by which id
// contributed it — a later post-Pentecost Sunday's Nocturn 2 now comes // contributed it — a later post-Pentecost Sunday's Nocturn 2 now comes
@@ -558,9 +581,25 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string):
const ids = nocturnReadingIds(day, temporalId, date); const ids = nocturnReadingIds(day, temporalId, date);
const byNocturn = new Map<number, ResolvedPart[]>(); const byNocturn = new Map<number, ResolvedPart[]>();
for (const id of ids) { for (const id of ids) {
for (const reading of getNocturnReadings(id)) { const readings = getNocturnReadings(id);
for (let i = 0; i < readings.length; i++) {
const reading = readings[i]!;
const bucket = byNocturn.get(reading.nocturn) ?? []; const bucket = byNocturn.get(reading.nocturn) ?? [];
if (reading.isGospel) {
// A genuine separate homily on this pericope is the very next
// reading in the same file, in the same nocturn, not itself flagged
// as a Gospel — the established authoring convention (see
// src/propers/nocturn-readings.ts's own header and this file's
// 'gospel' ResolvedPart doc comment). When found, fold it in and
// skip it as its own pool entry so the pair can never be split
// apart by distributeIntoNocturns.
const next = readings[i + 1];
const homily = next && !next.isGospel && next.nocturn === reading.nocturn ? next : undefined;
bucket.push(gospelReadingPart(reading, homily));
if (homily) i++;
} else {
bucket.push(nocturnReadingPart(reading)); bucket.push(nocturnReadingPart(reading));
}
byNocturn.set(reading.nocturn, bucket); byNocturn.set(reading.nocturn, bucket);
} }
} }
@@ -574,7 +613,6 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string):
kind: 'lesson', kind: 'lesson',
text: { text: reading.text, status: reading.status }, text: { text: reading.text, status: reading.status },
label: reading.source, label: reading.source,
isGospel: false,
responsory: reading.responsory responsory: reading.responsory
? { text: reading.responsory, status: { la: 'verified', en: 'verified' } } ? { text: reading.responsory, status: { la: 'verified', en: 'verified' } }
: undefined, : undefined,
+32 -11
View File
@@ -247,24 +247,45 @@ export type ResolvedPart =
// readings' own `source` is a genuine bilingual attribution (their // readings' own `source` is a genuine bilingual attribution (their
// English title is a real translation of a Latin incipit, not an // English title is a real translation of a Latin incipit, not an
// editorial description written once) so `label` accepts either shape — // editorial description written once) so `label` accepts either shape —
// see hour-view.ts's renderLessonLabel. `isGospel` flags a reading // see hour-view.ts's renderLessonLabel. `responsory` is the short
// as a Gospel pericope (from either the user's own continuous-reading // responsory said after the reading, when one was sourced (matched
// plan or the day's own proper Gospel+homily — never a Common-of-Saints // loosely by scriptural book for the user's own plan, live-queried
// fallback Gospel, which this app deliberately excludes for Matins, see // directly for patristic/hagiographic readings) — absent, not
// hours/matins.ts) rather than a distinct part kind, since a Gospel // fabricated, when none could be sourced. A Gospel pericope is never
// reading is still fundamentally a lesson — just one worth marking. // rendered with this kind — see 'gospel' below.
// `responsory` is the short responsory said after the reading, when one
// was sourced (matched loosely by scriptural book for the user's own
// plan, live-queried directly for patristic/hagiographic readings) —
// absent, not fabricated, when none could be sourced.
| { | {
kind: 'lesson'; kind: 'lesson';
text: ResolvedText; text: ResolvedText;
label?: string | Partial<Record<string, string>>; label?: string | Partial<Record<string, string>>;
nocturn?: number; nocturn?: number;
source?: string; source?: string;
isGospel?: boolean;
responsory?: ResolvedText; responsory?: ResolvedText;
}
// Matins only. A Gospel pericope, from either the user's own continuous
// reading plan (no paired homily) or the day's own genuine proper
// Gospel+homily (src/propers/nocturn-readings.ts — never a
// Common-of-Saints fallback Gospel, which this app deliberately excludes
// for Matins, see hours/matins.ts). Split out from the generic 'lesson'
// kind (2026-08-28) so a Gospel and its paired homily can be modeled as
// one atomic part: when the source pairs a pericope with a patristic
// homily on it as two adjacent readings, hours/matins.ts's
// buildReadingPool combines them into a single 'gospel' part with
// `homily` nested, rather than two separate pool entries — this is what
// guarantees distributeIntoNocturns can never split a Gospel from its
// own homily across two different nocturns; the pool's earlier
// front-light 1/1/rest slicing could otherwise do exactly that. `source`
// is set only for the rare case where the pericope's own text already
// has homily prose folded into it at the data layer (an earlier, less
// clean authoring convention — see chair-of-st-peter-at-rome.yml's own
// header) — a genuinely separate homily reading, when authored, is
// `homily` instead.
| {
kind: 'gospel';
text: ResolvedText;
nocturn?: number;
source?: string;
responsory?: ResolvedText;
homily?: { source?: string; text: ResolvedText };
}; };
export interface ResolvedVerse { export interface ResolvedVerse {
+18 -6
View File
@@ -1,6 +1,7 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types'; import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveEveningDay } from '../calendar/vespers'; import { resolveEveningDay } from '../calendar/vespers';
import { weekdayOf } from '../calendar/weekday';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
@@ -70,9 +71,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
/** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm /** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm
* the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4 * the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4
* groups in sequence. No per-feast override mechanism yet — see * groups in sequence. No per-feast override mechanism yet — see
* hours/types.ts's 'vespers-psalmody' doc comment. */ * hours/types.ts's 'vespers-psalmody' doc comment. Deliberately keyed off
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { * `weekday` (today's actual calendar weekday), not `day.weekday` — `day`
const wd = vespersAntiphons[day.weekday]; * may be tomorrow's identity when tonight anticipates First Vespers (see
* resolveEveningDay), but the psalm cycle has no per-feast override and
* must stay on today's place in the fixed 6-day rotation regardless of
* whose office governs the evening's propers. `day` is still threaded
* into psalmParts for antiphon-fullness resolution, which does care who
* governs tonight. */
function resolvePsalmody(day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
const wd = vespersAntiphons[weekday];
return wd.groups.flatMap((group) => psalmParts(group, day)); return wd.groups.flatMap((group) => psalmParts(group, day));
} }
@@ -154,12 +162,12 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
]; ];
} }
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { function resolvePart(part: HourPart, day: LiturgicalDay, weekday: Weekday): ResolvedPart[] {
switch (part.kind) { switch (part.kind) {
case 'opening-versicle': case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'vespers-psalmody': case 'vespers-psalmody':
return resolvePsalmody(day); return resolvePsalmody(day, weekday);
case 'vespers-office': case 'vespers-office':
return resolveOffice(day); return resolveOffice(day);
case 'magnificat': { case 'magnificat': {
@@ -216,6 +224,10 @@ export function resolveOrdo(date: string): ResolvedOrdo {
// vespers.ts) — getDayCollects picks that up for free, no special // vespers.ts) — getDayCollects picks that up for free, no special
// handling needed here. // handling needed here.
const day = resolveEveningDay(date); const day = resolveEveningDay(date);
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day)); // Today's own weekday, independent of the possibly-anticipated `day`
// above — see resolvePsalmody's doc comment for why psalm selection
// must not follow the evening's borrowed identity.
const weekday = weekdayOf(date);
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day, weekday));
return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) }; return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) };
} }
+30 -2
View File
@@ -91,13 +91,41 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
`; `;
case 'lesson': case 'lesson':
return ` return `
<section class="ordo-part ordo-part-lesson${part.isGospel ? ' ordo-part-lesson-gospel' : ''}"> <section class="ordo-part ordo-part-lesson">
${part.isGospel ? '<h3 class="ordo-part-label">Gospel</h3>' : renderLessonLabel(part.label, languages)} ${renderLessonLabel(part.label, languages)}
${renderCitation(part.text)} ${renderCitation(part.text)}
${renderColumns(part.text, languages)} ${renderColumns(part.text, languages)}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''} ${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
</section> </section>
`; `;
// A Gospel pericope, with its paired patristic homily (when authored)
// nested right after it in the same section rather than as a separate
// pool entry — see hours/matins.ts's buildReadingPool, which combines
// the two into one atomic part specifically so they can never end up in
// different nocturns. `part.source` (rare — see the 'gospel' type's own
// doc comment) is homily attribution already folded into the pericope's
// own text at the data layer; `part.homily`, when present, is a
// genuinely separate reading, rendered with its own label underneath.
case 'gospel':
return `
<section class="ordo-part ordo-part-lesson ordo-part-gospel">
<h3 class="ordo-part-label">Gospel</h3>
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
${part.source ? `<p class="ordo-part-citation">${escapeHtml(part.source)}</p>` : ''}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
${
part.homily
? `
<div class="ordo-part-homily">
${renderLessonLabel(part.homily.source, languages)}
${renderColumns(part.homily.text, languages)}
</div>
`
: ''
}
</section>
`;
case 'psalm': { case 'psalm': {
// Always cite the full range, not just the psalm number — a 4-verse // Always cite the full range, not just the psalm number — a 4-verse
// slice of Psalm 118 is a different citation from the whole psalm. // slice of Psalm 118 is a different citation from the whole psalm.
+11
View File
@@ -307,6 +307,17 @@ button:focus-visible {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
} }
.ordo-part-gospel {
border-left: 2px solid var(--color-brass);
padding-left: var(--space-2);
}
.ordo-part-homily {
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--color-brass);
}
.psalm-verses { .psalm-verses {
list-style: none; list-style: none;
margin: 0; margin: 0;
+17 -19
View File
@@ -146,35 +146,33 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => {
expect(citations).toContain('Sir 45'); expect(citations).toContain('Sir 45');
}); });
it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily, both flagged correctly', () => { it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily as one atomic gospel part', () => {
const lessons = ordo.parts.filter( const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { label?: string }[];
(p) => p.kind === 'lesson',
) as { label?: string; isGospel?: boolean; text: { text: { la?: string } } }[];
const gregory = lessons.find((l) => l.label?.includes('Gregory')); const gregory = lessons.find((l) => l.label?.includes('Gregory'));
expect(gregory).toBeDefined(); expect(gregory).toBeDefined();
expect(gregory?.isGospel).toBe(false);
const gospel = lessons.find((l) => l.isGospel); const gospels = ordo.parts.filter((p) => p.kind === 'gospel') as {
expect(gospel).toBeDefined(); text: { text: { la?: string } };
expect(gospel?.text.text.la).toContain('Naim'); homily?: { source?: string; text: { text: { la?: string } } };
}[];
const homily = lessons.find((l) => l.label?.includes('Augustine')); expect(gospels).toHaveLength(1);
expect(homily).toBeDefined(); const gospel = gospels[0]!;
expect(homily?.isGospel).toBe(false); expect(gospel.text.text.la).toContain('Naim');
expect(gospel.homily?.source).toContain('Augustine');
expect(gospel.homily?.text.text.la).toBeTruthy();
}); });
it('never sources a Gospel from a Common-of-Saints fallback — every Gospel-flagged reading traces to the bible-plan or a real proper', () => { it('never sources a Gospel from a Common-of-Saints fallback — the only gospel part traces to the bible-plan or a real proper', () => {
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { isGospel?: boolean; label?: string }[]; const gospels = ordo.parts.filter((p) => p.kind === 'gospel');
const gospels = lessons.filter((l) => l.isGospel);
// This Sunday's own TSV row deliberately has no Gospel (confirmed // This Sunday's own TSV row deliberately has no Gospel (confirmed
// editorial choice) — the only Gospel-flagged reading should be the // editorial choice) — the only gospel part should be the day's own
// day's own proper Nocturn 3 Gospel, not a substituted Common one. // proper Nocturn 3 Gospel, not a substituted Common one.
expect(gospels).toHaveLength(1); expect(gospels).toHaveLength(1);
}); });
it('orders Te Deum after every reading, and the day collect last', () => { it('orders Te Deum after every reading (lesson or gospel), and the day collect last', () => {
const teDeumIndex = ordo.parts.findIndex((p) => p.kind === 'te-deum'); const teDeumIndex = ordo.parts.findIndex((p) => p.kind === 'te-deum');
const lastLessonIndex = ordo.parts.map((p) => p.kind).lastIndexOf('lesson'); const lastLessonIndex = ordo.parts.map((p) => p.kind).map((k, i) => ((k === 'lesson' || k === 'gospel') ? i : -1)).reduce((a, b) => Math.max(a, b), -1);
const prayerIndex = ordo.parts.findIndex((p) => p.kind === 'prayer'); const prayerIndex = ordo.parts.findIndex((p) => p.kind === 'prayer');
expect(teDeumIndex).toBeGreaterThan(lastLessonIndex); expect(teDeumIndex).toBeGreaterThan(lastLessonIndex);
expect(prayerIndex).toBeGreaterThan(teDeumIndex); expect(prayerIndex).toBeGreaterThan(teDeumIndex);
+14
View File
@@ -235,6 +235,20 @@ describe('resolveOrdo("vespers", ...)', () => {
} }
}); });
it('keeps ferial psalmody on the actual calendar weekday even while the office anticipates tomorrow\'s First Vespers', () => {
// 2026-08-25 is a Tuesday, but its evening anticipates St. Zephyrinus's
// First Vespers (Aug 26) per the test just above -- the psalm cycle has
// no per-feast override (see hours/vespers.ts's resolvePsalmody), so it
// must stay on Tuesday's own group [129, 130, 131, 132], not slip to
// Wednesday's [134, 135, 136, 137] just because the office borrowed
// Wednesday's identity.
const ordo = resolveOrdo('vespers', '2026-08-25');
const psalmNumbers = ordo.parts
.filter((p) => p.kind === 'psalm')
.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined));
expect(psalmNumbers).toEqual([129, 130, 131, 132]);
});
it("uses Common-of-Several-Martyrs' real office bundle on Ss. Placid and Companions' own day (Duplex II. classis, keeps own Vespers)", () => { it("uses Common-of-Several-Martyrs' real office bundle on Ss. Placid and Companions' own day (Duplex II. classis, keeps own Vespers)", () => {
// Live-verified against Divinum Officium (Monastic Tridentinum 1617) // Live-verified against Divinum Officium (Monastic Tridentinum 1617)
// votive=C3 -- see vespers-hymn-common-of-several-martyrs.yml's header. // votive=C3 -- see vespers-hymn-common-of-several-martyrs.yml's header.