Compare commits

...

2 Commits

Author SHA1 Message Date
will 998c549e51 Replace generic "Reading"/"Gospel" headings with real incipits
Deploy / deploy (push) Successful in 1m50s
Bible-plan readings now carry a generated bilingual incipit label
(bible-book-incipits.ts) instead of falling through to the UI's generic
"Reading" heading. Gospel pericopes (whether from the user's own plan
or nocturn-readings.ts's proper content) get the same treatment via a
new label field on the 'gospel' ResolvedPart, recovering the book from
the reading's own citation when no raw book code is available.

Updates 3 matins.test.ts assertions that used "no label" as a stand-in
for "this is a bible-plan reading" — no longer true now that these
readings carry a real label, so they now key off the label's shape
(string = patristic attribution, object = generated incipit) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGjUyhUZJaSjiniEmnLdak
2026-09-03 14:02:58 -04:00
will 56321ab276 Add traditional book incipits for scripture reading labels
Maps every book abbreviation under src/data/scripture to its bilingual
liturgical incipit (e.g. "Léctio libri Sapiéntiæ" / "A reading from the
book of Wisdom"), sourced from the reference engine's own headers where
available. English uses "Saint Paul"/"Saint James"/etc. rather than
"blessed" per common modern usage; Latin keeps the traditional "beáti".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGjUyhUZJaSjiniEmnLdak
2026-09-03 14:02:49 -04:00
6 changed files with 185 additions and 18 deletions
+6 -2
View File
@@ -92,6 +92,7 @@ import { getSaintRecord } from '../calendar/feasts';
import { getBiblePlanReadings } from '../propers/bible-plan'; import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings'; import { getOctaveReading } from '../propers/octave-readings';
import { getGospelIncipitFromCitation } from '../propers/bible-book-incipits';
import { getMatinsSaintOverride, getMatinsCommonOverride, type MatinsPsalmodyScheme } from './matins-psalmody-overrides'; import { getMatinsSaintOverride, getMatinsCommonOverride, type MatinsPsalmodyScheme } from './matins-psalmody-overrides';
import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml'; import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml';
import matinsSundayNocturn3OverridesData from '../data/hours/matins-sunday-nocturn3-overrides.yml'; import matinsSundayNocturn3OverridesData from '../data/hours/matins-sunday-nocturn3-overrides.yml';
@@ -683,10 +684,12 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart {
* whereas two adjacent pool entries could be (and, before this, sometimes * whereas two adjacent pool entries could be (and, before this, sometimes
* were). */ * were). */
function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined): ResolvedPart { function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined): ResolvedPart {
const incipit = getGospelIncipitFromCitation(r.citation);
return { return {
kind: 'gospel', kind: 'gospel',
text: { text: r.text, status: r.status, citation: r.citation }, text: { text: r.text, status: r.status, citation: r.citation },
source: r.source, source: r.source,
label: incipit ? { la: incipit.la, en: incipit.en } : undefined,
responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined,
homily: homily homily: homily
? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } } ? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } }
@@ -717,10 +720,11 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string,
// this file's own header) — a bare pericope, still its own 'gospel' // this file's own header) — a bare pericope, still its own 'gospel'
// kind so it gets the same distinguishing UI treatment as a proper // kind so it gets the same distinguishing UI treatment as a proper
// Gospel+homily. // Gospel+homily.
const label = r.label ? { la: r.label.la ?? '', en: r.label.en ?? '' } : undefined;
parts.push( parts.push(
r.isGospel r.isGospel
? { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, responsory } ? { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label }
: { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory }, : { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label },
); );
} }
// 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
+6
View File
@@ -297,6 +297,12 @@ export type ResolvedPart =
text: ResolvedText; text: ResolvedText;
nocturn?: number; nocturn?: number;
source?: string; source?: string;
/** The traditional "Léctio sancti Evangélii secúndum..."/"A reading
* from the Holy Gospel according to..." incipit, shown in place of the
* generic "Gospel" heading — see hour-view.ts's renderLessonLabel and
* bible-book-incipits.ts. Absent only when the pericope's own book
* couldn't be identified. */
label?: string | Partial<Record<string, string>>;
responsory?: ResolvedText; responsory?: ResolvedText;
homily?: { source?: string; text: ResolvedText }; homily?: { source?: string; text: ResolvedText };
}; };
+145
View File
@@ -0,0 +1,145 @@
// Traditional liturgical incipits ("Léctio libri Sapiéntiæ" / "A reading
// from the book of Wisdom") for every book abbreviation used under
// src/data/scripture — keyed exactly as ScriptureCitation.book spells them
// (bible-plan.ts, octave-readings.ts). Used to replace the generic
// "Reading"/"Gospel" UI heading (see hour-view.ts's renderLessonLabel) with
// a real attribution wherever no editorial `source`/`label` was authored —
// the common case for the user's own continuous bible-plan readings.
//
// Latin wording is drawn directly from the reference engine's own Sancti/
// Commune/Tempora files (grep for "Léctio" across
// ../divinum-officium-reference) wherever a match was found there; the
// handful of books that never get their own proper-of-saints lesson in the
// reference engine (Baruch, Aggeus, Habacuc, Nahum, Abdias, Sophonias, Josue,
// Judges, Ruth, Proverbs, Ecclesiastes, Canticle of Canticles,
// Ecclesiasticus, Lamentations, and the ordinal Kings/Paralipomenon/Esdras/
// Machabees forms) follow the same standard Vulgate genitive pattern as the
// confirmed ones, not independently re-verified per book.
//
// English keeps the reference engine's own "A reading from..." phrasing
// (2026-09-03, user) but says "Saint Paul"/"Saint James"/etc., not "blessed
// Paul" — the reference engine's English text mixes both, and "Saint" is
// the ordinary modern English way to name an apostle, even though the
// Latin `beáti` this pairs with is left as-is (that's correct Latin usage,
// not the thing being fixed).
export const BOOK_INCIPITS: Record<string, { la: string; en: string }> = {
// Pentateuch
gen: { la: 'Léctio libri Génesis', en: 'A reading from the book of Genesis' },
exod: { la: 'Léctio libri Éxodi', en: 'A reading from the book of Exodus' },
lev: { la: 'Léctio libri Levítici', en: 'A reading from the book of Leviticus' },
num: { la: 'Léctio libri Numeri', en: 'A reading from the book of Numbers' },
deut: { la: 'Léctio libri Deuteronómii', en: 'A reading from the book of Deuteronomy' },
// Historical books
jos: { la: 'Léctio libri Josue', en: 'A reading from the book of Josue' },
jdc: { la: 'Léctio libri Judicum', en: 'A reading from the book of Judges' },
ruth: { la: 'Léctio libri Ruth', en: 'A reading from the book of Ruth' },
'1reg': { la: 'Léctio libri primi Regum', en: 'A reading from the first book of Kings' },
'2reg': { la: 'Léctio libri secúndi Regum', en: 'A reading from the second book of Kings' },
'3reg': { la: 'Léctio libri tértii Regum', en: 'A reading from the third book of Kings' },
'4reg': { la: 'Léctio libri quárti Regum', en: 'A reading from the fourth book of Kings' },
'1par': { la: 'Léctio libri primi Paralipómenon', en: 'A reading from the first book of Paralipomenon' },
'2par': { la: 'Léctio libri secúndi Paralipómenon', en: 'A reading from the second book of Paralipomenon' },
'1esdr': { la: 'Léctio libri primi Esdræ', en: 'A reading from the first book of Esdras' },
'2esdr': { la: 'Léctio libri secúndi Esdræ', en: 'A reading from the second book of Esdras' },
tob: { la: 'Léctio libri Tobíæ', en: 'A reading from the book of Tobias' },
jdth: { la: 'Léctio libri Judith', en: 'A reading from the book of Judith' },
esth: { la: 'Léctio libri Esther', en: 'A reading from the book of Esther' },
job: { la: 'Léctio libri Job', en: 'A reading from the book of Job' },
'1mach': { la: 'Léctio libri primi Machabæórum', en: 'A reading from the first book of Machabees' },
'2mach': { la: 'Léctio libri secúndi Machabæórum', en: 'A reading from the second book of Machabees' },
// Wisdom books
prov: { la: 'Léctio libri Próverbiórum', en: 'A reading from the book of Proverbs' },
eccl: { la: 'Léctio libri Ecclesiástæ', en: 'A reading from the book of Ecclesiastes' },
cant: { la: 'Léctio libri Cánticorum Canticórum', en: 'A reading from the Canticle of Canticles' },
sap: { la: 'Léctio libri Sapiéntiæ', en: 'A reading from the book of Wisdom' },
sir: { la: 'Léctio libri Ecclesiástici', en: 'A reading from the book of Ecclesiasticus' },
// Major prophets
isa: { la: 'Léctio Isaíæ Prophétæ', en: 'A reading from the Prophecy of Isaias' },
jer: { la: 'Léctio Jeremíæ Prophétæ', en: 'A reading from the Prophecy of Jeremias' },
lam: { la: 'Léctio Threnórum Jeremíæ Prophétæ', en: 'A reading from the Lamentations of Jeremias the Prophet' },
bar: { la: 'Léctio Baruch Prophétæ', en: 'A reading from the Prophecy of Baruch' },
ezek: { la: 'Léctio Ezechiélis Prophétæ', en: 'A reading from the Prophecy of Ezechiel' },
dan: { la: 'Léctio Daniélis Prophétæ', en: 'A reading from the Prophecy of Daniel' },
// Minor prophets
osee: { la: 'Léctio Osee Prophétæ', en: 'A reading from the Prophecy of Osee' },
joel: { la: 'Léctio Joélis Prophétæ', en: 'A reading from the Prophecy of Joel' },
amos: { la: 'Léctio Amos Prophétæ', en: 'A reading from the Prophecy of Amos' },
abd: { la: 'Léctio Abdíæ Prophétæ', en: 'A reading from the Prophecy of Abdias' },
jon: { la: 'Léctio Jonæ Prophétæ', en: 'A reading from the Prophecy of Jonas' },
mich: { la: 'Léctio Michǽæ Prophétæ', en: 'A reading from the Prophecy of Micheas' },
nah: { la: 'Léctio Nahum Prophétæ', en: 'A reading from the Prophecy of Nahum' },
hab: { la: 'Léctio Habacuc Prophétæ', en: 'A reading from the Prophecy of Habacuc' },
soph: { la: 'Léctio Sophoníæ Prophétæ', en: 'A reading from the Prophecy of Sophonias' },
agg: { la: 'Léctio Aggǽi Prophétæ', en: 'A reading from the Prophecy of Aggeus' },
zach: { la: 'Léctio Zacharíæ Prophétæ', en: 'A reading from the Prophecy of Zacharias' },
mal: { la: 'Léctio Malachíæ Prophétæ', en: 'A reading from the Prophecy of Malachias' },
// Gospels
matt: { la: 'Léctio sancti Evangélii secúndum Matthǽum', en: 'A reading from the Holy Gospel according to Matthew' },
marc: { la: 'Léctio sancti Evangélii secúndum Marcum', en: 'A reading from the Holy Gospel according to Mark' },
luc: { la: 'Léctio sancti Evangélii secúndum Lucam', en: 'A reading from the Holy Gospel according to Luke' },
joan: { la: 'Léctio sancti Evangélii secúndum Joánnem', en: 'A reading from the Holy Gospel according to John' },
// Acts
act: { la: 'Léctio Actuum Apostolórum', en: 'A reading from the Acts of the Apostles' },
// Pauline epistles
rom: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Romános', en: 'A reading from the Epistle of Saint Paul the Apostle to the Romans' },
'1cor': { la: 'Léctio Epístolæ primæ beáti Pauli Apóstoli ad Corínthios', en: 'A reading from the first Epistle of Saint Paul the Apostle to the Corinthians' },
'2cor': { la: 'Léctio Epístolæ secúndæ beáti Pauli Apóstoli ad Corínthios', en: 'A reading from the second Epistle of Saint Paul the Apostle to the Corinthians' },
gal: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Gálatas', en: 'A reading from the Epistle of Saint Paul the Apostle to the Galatians' },
eph: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Ephésios', en: 'A reading from the Epistle of Saint Paul the Apostle to the Ephesians' },
phil: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Philippénses', en: 'A reading from the Epistle of Saint Paul the Apostle to the Philippians' },
col: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Colossénses', en: 'A reading from the Epistle of Saint Paul the Apostle to the Colossians' },
'1thess': { la: 'Léctio Epístolæ primæ beáti Pauli Apóstoli ad Thessalonicénses', en: 'A reading from the first Epistle of Saint Paul the Apostle to the Thessalonians' },
'2thess': { la: 'Léctio Epístolæ secúndæ beáti Pauli Apóstoli ad Thessalonicénses', en: 'A reading from the second Epistle of Saint Paul the Apostle to the Thessalonians' },
'1tim': { la: 'Léctio Epístolæ primæ beáti Pauli Apóstoli ad Timótheum', en: 'A reading from the first Epistle of Saint Paul the Apostle to Timothy' },
'2tim': { la: 'Léctio Epístolæ secúndæ beáti Pauli Apóstoli ad Timótheum', en: 'A reading from the second Epistle of Saint Paul the Apostle to Timothy' },
tit: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Titum', en: 'A reading from the Epistle of Saint Paul the Apostle to Titus' },
phlm: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Philémonem', en: 'A reading from the Epistle of Saint Paul the Apostle to Philemon' },
heb: { la: 'Léctio Epístolæ beáti Pauli Apóstoli ad Hebrǽos', en: 'A reading from the Epistle of Saint Paul the Apostle to the Hebrews' },
// Catholic epistles
jac: { la: 'Léctio Epístolæ beáti Jacóbi Apóstoli', en: 'A reading from the Epistle of Saint James the Apostle' },
'1pet': { la: 'Léctio Epístolæ primæ beáti Petri Apóstoli', en: 'A reading from the first Epistle of Saint Peter the Apostle' },
'2pet': { la: 'Léctio Epístolæ secúndæ beáti Petri Apóstoli', en: 'A reading from the second Epistle of Saint Peter the Apostle' },
'1joan': { la: 'Léctio Epístolæ primæ beáti Joánnis Apóstoli', en: 'A reading from the first Epistle of Saint John the Apostle' },
'2joan': { la: 'Léctio Epístolæ secúndæ beáti Joánnis Apóstoli', en: 'A reading from the second Epistle of Saint John the Apostle' },
'3joan': { la: 'Léctio Epístolæ tértiæ beáti Joánnis Apóstoli', en: 'A reading from the third Epistle of Saint John the Apostle' },
jud: { la: 'Léctio Epístolæ beáti Judæ Apóstoli', en: 'A reading from the Epistle of Saint Jude the Apostle' },
// Apocalypse
apoc: { la: 'Léctio libri Apocalýpsis beáti Joánnis Apóstoli', en: 'A reading from the book of the Apocalypse of Saint John the Apostle' },
};
/** Book abbreviation -> {la, en} incipit, or undefined for an unrecognized
* abbreviation (shouldn't happen for anything actually keyed under
* src/data/scripture, but callers still get an honest absence rather than
* a made-up label). */
export function getBookIncipit(book: string): { la: string; en: string } | undefined {
return BOOK_INCIPITS[book];
}
// Nocturn-readings.ts's own hand-authored Gospel pericopes (unlike
// bible-plan.ts's) carry only an already-formatted `citation` string, not a
// raw ScriptureCitation.book — and that citation's own abbreviation isn't
// authored consistently (e.g. "Luc"/"Luke", "Joann"/"John" across
// src/data/propers/nocturn-readings/*.yml). Restricted to the four Gospels
// since those are the only isGospel:true entries this needs to cover.
const GOSPEL_CITATION_PREFIXES: [RegExp, string][] = [
[/^matt/i, 'matt'],
[/^marc|^mark/i, 'marc'],
[/^luc|^luke/i, 'luc'],
[/^joann?|^john/i, 'joan'],
];
/** Best-effort incipit lookup from an already-rendered citation string (e.g.
* "Luc 7:11-16" or "John 14:1-13") — tries the Latin form first, then
* English, since the Latin abbreviation is the more consistently authored
* one. Undefined when neither matches a known Gospel prefix. */
export function getGospelIncipitFromCitation(
citation: Partial<Record<string, string>> | undefined,
): { la: string; en: string } | undefined {
for (const candidate of [citation?.la, citation?.en]) {
if (!candidate) continue;
for (const [pattern, book] of GOSPEL_CITATION_PREFIXES) {
if (pattern.test(candidate)) return BOOK_INCIPITS[book];
}
}
return undefined;
}
+10 -2
View File
@@ -20,6 +20,7 @@
import type { LanguageCode, TranslationStatus } from '../psalter/types'; import type { LanguageCode, TranslationStatus } from '../psalter/types';
import { resolvePassages, type ScriptureCitation } from './octave-readings'; import { resolvePassages, type ScriptureCitation } from './octave-readings';
import { getResponsoryForBook } from './matins-responsories'; import { getResponsoryForBook } from './matins-responsories';
import { getBookIncipit } from './bible-book-incipits';
const GOSPEL_BOOKS = new Set(['matt', 'mark', 'luke', 'john']); const GOSPEL_BOOKS = new Set(['matt', 'mark', 'luke', 'john']);
@@ -37,6 +38,11 @@ export interface BiblePlanReading {
* data/hours/matins-responsories-by-book.yml — undefined when nothing's * data/hours/matins-responsories-by-book.yml — undefined when nothing's
* seeded for that book yet, an honest absence, not a placeholder. */ * seeded for that book yet, an honest absence, not a placeholder. */
responsory?: Partial<Record<LanguageCode, string>>; responsory?: Partial<Record<LanguageCode, string>>;
/** The traditional "Léctio libri..."/"A reading from..." incipit for the
* reading's own (first) book — see bible-book-incipits.ts. Undefined only
* for an abbreviation that map doesn't recognize, which shouldn't happen
* for anything actually under src/data/scripture. */
label?: Partial<Record<LanguageCode, string>>;
} }
interface BiblePlanReadingRecord { interface BiblePlanReadingRecord {
@@ -107,15 +113,17 @@ function resolveReading(record: BiblePlanReadingRecord, bookIndex: number): Bibl
la: text.la ? 'verified' : 'missing', la: text.la ? 'verified' : 'missing',
en: text.en ? 'verified' : 'missing', en: text.en ? 'verified' : 'missing',
}; };
const label = formatCitationLabel(record.passages); const citationLabelText = formatCitationLabel(record.passages);
const firstBook = record.passages[0]?.book; const firstBook = record.passages[0]?.book;
const responsory = firstBook ? getResponsoryForBook(firstBook, bookIndex)?.text : undefined; const responsory = firstBook ? getResponsoryForBook(firstBook, bookIndex)?.text : undefined;
const incipit = firstBook ? getBookIncipit(firstBook) : undefined;
return { return {
text, text,
status, status,
citation: { la: label, en: label }, citation: { la: citationLabelText, en: citationLabelText },
isGospel: isGospelReading(record.passages), isGospel: isGospelReading(record.passages),
responsory, responsory,
label: incipit ? { la: incipit.la, en: incipit.en } : undefined,
}; };
} }
+3 -2
View File
@@ -49,9 +49,10 @@ function renderCitation(text: ResolvedText, languages: readonly string[]): strin
function renderLessonLabel( function renderLessonLabel(
label: string | Partial<Record<string, string>> | undefined, label: string | Partial<Record<string, string>> | undefined,
languages: readonly string[], languages: readonly string[],
fallback = 'Reading',
): string { ): string {
if (!label) { if (!label) {
return '<h3 class="ordo-part-label">Reading</h3>'; return `<h3 class="ordo-part-label">${escapeHtml(fallback)}</h3>`;
} }
if (typeof label === 'string') { if (typeof label === 'string') {
return `<h3 class="ordo-part-label">${escapeHtml(label)}</h3>`; return `<h3 class="ordo-part-label">${escapeHtml(label)}</h3>`;
@@ -115,7 +116,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
case 'gospel': case 'gospel':
return ` return `
<section class="ordo-part ordo-part-lesson ordo-part-gospel"> <section class="ordo-part ordo-part-lesson ordo-part-gospel">
<h3 class="ordo-part-label">Gospel</h3> ${renderLessonLabel(part.label, languages, 'Gospel')}
${renderCitation(part.text, languages)} ${renderCitation(part.text, languages)}
${renderColumns(part.text, languages)} ${renderColumns(part.text, languages)}
${part.source ? `<p class="ordo-part-citation">${escapeHtml(part.source)}</p>` : ''} ${part.source ? `<p class="ordo-part-citation">${escapeHtml(part.source)}</p>` : ''}
+15 -12
View File
@@ -73,14 +73,17 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
}); });
it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => { it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => {
// Filtered to unlabeled lessons only: since the temporal-cycle // Filtered to the user's own bible-plan lessons only: since the
// nocturn-readings sweep authored advent-1.yml (2026-08), this same // temporal-cycle nocturn-readings sweep authored advent-1.yml
// date's Nocturn 2/3 pool also gains two labeled (patristic) lesson // (2026-08), this same date's Nocturn 2/3 pool also gains two labeled
// entries alongside the user's own bible-plan readings — a real, // (patristic) lesson entries alongside the user's own bible-plan
// separate content source this test isn't about (see // readings — a real, separate content source this test isn't about.
// hours/matins.ts's own nocturnReadingPart, which is the only lesson // Bible-plan lessons carry a generated bilingual incipit `label`
// constructor that sets `label`). // object (bible-book-incipits.ts); patristic ones carry a plain-string
const lessons = ordo.parts.filter((p) => p.kind === 'lesson' && !p.label); // `label` (nocturnReadingPart's own `r.source` attribution) — that
// shape difference, not label presence/absence, is what distinguishes
// them since 2026-09-03.
const lessons = ordo.parts.filter((p) => p.kind === 'lesson' && typeof p.label !== 'string');
// Plain 2 bible-plan readings on this particular clean ferial (no // Plain 2 bible-plan readings on this particular clean ferial (no
// commemorated saint here) — see the next test for the 3-reading case. // commemorated saint here) — see the next test for the 3-reading case.
expect(lessons).toHaveLength(2); expect(lessons).toHaveLength(2);
@@ -100,9 +103,9 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
// Semiduplex threshold). 2025-12-02, a Tuesday, keeps the same 9-psalm // Semiduplex threshold). 2025-12-02, a Tuesday, keeps the same 9-psalm
// ferial shape as the other tests in this block. // ferial shape as the other tests in this block.
const commemoratedOrdo = resolveOrdo('matins', '2025-12-02'); const commemoratedOrdo = resolveOrdo('matins', '2025-12-02');
// Same unlabeled-lesson filter as the test above — advent-1.yml's // Same bible-plan-lesson filter as the test above — advent-1.yml's
// patristic content also pools into this date. // patristic content also pools into this date.
const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson' && !p.label); const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson' && typeof p.label !== 'string');
expect(lessons).toHaveLength(3); expect(lessons).toHaveLength(3);
const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en); const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en);
expect(citations).toEqual(['Isa 6-7', 'Sap 2', undefined]); expect(citations).toEqual(['Isa 6-7', 'Sap 2', undefined]);
@@ -159,8 +162,8 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => {
}); });
it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily as one atomic gospel part', () => { 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((p) => p.kind === 'lesson') as { label?: string }[]; const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { label?: string | Record<string, string> }[];
const gregory = lessons.find((l) => l.label?.includes('Gregory')); const gregory = lessons.find((l) => typeof l.label === 'string' && l.label.includes('Gregory'));
expect(gregory).toBeDefined(); expect(gregory).toBeDefined();
const gospels = ordo.parts.filter((p) => p.kind === 'gospel') as { const gospels = ordo.parts.filter((p) => p.kind === 'gospel') as {