Add Matins psalmody overrides for a Duplex+ weekday feast

matins.ts's three-nocturn gate correctly governed nocturn count
(Sunday, or Duplex+) but wrongly reused the literal Sunday psalmody as
content for every three-nocturn day, including a weekday feast. Live-
verified (Monastic Tridentinum 1617) that a Duplex+ weekday feast has
its own genuinely different psalmody -- confirmed per-Common-category,
not per-saint, by cross-checking St. Lawrence against St. Ignatius of
Antioch (a different Martyr sub-category, nearly identical psalm
numbers, different antiphons).

New mechanism (matins-psalmody-overrides.ts, mirroring the Lauds one
but keyed per-category) with St. Lawrence authored as the live-verified
proof; falls back to the plain ferial weekday table, redistributed into
3 nocturns, for every other Duplex+ id until its own category's content
is authored. Also generalizes the canticle shape to support a citation
spanning multiple scripture chapters under one heading (Lawrence's own
Nocturn 3), and adds the new Sirach/Jeremiah chapters his canticles cite.
This commit is contained in:
2026-08-18 06:11:52 -04:00
parent 1fc4bd7160
commit 3e58f2258d
9 changed files with 399 additions and 7 deletions
@@ -0,0 +1,118 @@
# Verified against Divinum Officium (Monastic Tridentinum 1617), live
# prayMatutinum query (2026-08-17, St. Lawrence's own octave closing day
# -- "In Octava S. Laurentii Martyris ~ Duplex"). This is the live-
# verified proof for the Matins-psalmody-override mechanism
# (hours/matins-psalmody-overrides.ts) -- the general mechanism gap this
# closes: a Duplex+ weekday feast previously fell through to reusing the
# literal *Sunday* psalmody (data/hours/matins-sunday-antiphons.yml, Ps
# 20-31) unconditionally, which is wrong -- a Duplex+ weekday feast has
# its own real, different psalmody (confirmed against the live engine
# directly), not the Sunday's.
#
# Cross-checked against a second Duplex saint (St. Ignatius of Antioch,
# Common of a Martyr-Bishop, no proper content of his own, live-queried
# 2029-02-01): Nocturn 1 is *identical* (Ps 1,2,4,5,8,10); Nocturn 2 is
# nearly identical (Ps 14,20,23,63,91 match -- only one slot differs,
# Lawrence has 16 where Ignatius has 64). So the psalm *numbers* are a
# shared per-Common-category pool, not unique to Lawrence -- only the
# antiphon *text* is proper to him (each one narrates an episode of his
# own martyrdom, e.g. "Quo progréderis sine fílio, pater?"). This file is
# keyed by his own saint id (not yet a Common-category id) since only his
# own proper antiphons have been authored so far -- see this module's own
# doc comment for what extending this to a real Common-category key would
# need.
#
# Nocturn 3's first canticle spans two Sirach chapters under one heading
# in the source ("Eccli 14:22;15:3-4;15:6") -- kept as one canticle with
# two refs (matins.ts's own `canticles: { refs: [...] }[]` shape), not
# split into two, matching how the live engine presents it. New scripture
# chapters authored for this: data/scripture/{sir-14,sir-15,jer-17,sir-31}.yml
# (all sparse, only the cited verses -- same convention as sir-36.yml's
# own Sunday-canticle transcription).
id: st-lawrence
nocturn1:
groups:
- psalms: [1]
antiphon:
la: "Quo progréderis * sine fílio, pater? quo, sacérdos sancte, sine minístro próperas?"
en: "Father, whither goest thou * without thy son? Holy Priest, dost thou fare hence without a Deacon?"
- psalms: [2]
antiphon:
la: "Noli me derelínquere, * pater sancte, quia thesáuros tuos jam expéndi, quos tradidísti mihi."
en: "Leave me not, holy father, * for I have spent already thy treasures, which thou gavest unto me."
- psalms: [4]
antiphon:
la: "Non ego te désero, * fili, neque derelínquo; sed majóra tibi debéntur pro Christi fide certámina."
en: "I leave thee not, my son, neither forsake thee; * there awaiteth thee for Christ's truth a sterner wrestling than mine."
- psalms: [5]
antiphon:
la: "Beátus Lauréntius * orábat, dicens: Dómine Jesu Christe, Deus de Deo, miserére mihi servo tuo."
en: "The blessed Lawrence prayed * and said: O Lord Jesus Christ, God of God, take pity upon me thy servant."
- psalms: [8]
antiphon:
la: "Dixit Románus * ad beátum Lauréntium: Vídeo ante te júvenem pulchérrimum, festína me baptizáre."
en: "Romanus said * to Blessed Lawrence: I see in front of thee a very well-favoured young man; make haste to baptize me."
- psalms: [10]
antiphon:
la: "Beátus Lauréntius * dixit: Mea nox obscúrum non habet, sed ómnia in luce claréscunt."
en: "The blessed Lawrence said: The darkness is no darkness to me, * but the night is all as clear as morning, that shineth more and more unto the perfect day."
versicle:
v:
la: "Glória et honóre coronásti eum, Dómine."
en: "Thou hast crowned him with glory and honour, O Lord."
r:
la: "Et constituísti eum super ópera mánuum tuárum."
en: "And madest him to have dominion over the works of thy hands."
nocturn2:
groups:
- psalms: [14]
antiphon:
la: "Strinxérunt * córporis membra pósita super cratículam: subiciéntibus prunas insúltat Levíta Christi."
en: "They laid him upon the grating, and stretched out his limbs; * Christ's Levite held in derision them that brought the live coals."
- psalms: [16]
antiphon:
la: "Igne me examinásti, * et non est invénta in me iníquitas."
en: "Thou hast tried me with fire, * and found no wickedness in me."
- psalms: [20]
antiphon:
la: "Interrogátus * te Dóminum conféssus sum, assátus grátias ago."
en: "When I was asked, I acknowledged thee to be the Lord; now that I am roasted, I give thee thanks."
- psalms: [23]
antiphon:
la: "Beátus Lauréntius * clamávit et dixit: Deum meum colo, et illi solo sérvio."
en: "Blessed Lawrence * cried out and said: I care for my God, and serve him alone."
- psalms: [63]
antiphon:
la: "Dispérsit * dedit paupéribus: justítia ejus manet in séculum sǽculi."
en: "He hath dispersed and gave to the poor; * his justice shall remain in ages of ages."
- psalms: [91]
antiphon:
la: "Protéctor tuus * ego sum noli timére: si transíeris per ignem, flamma non nocébit tibi, et odor ignis non erit in te."
en: "I am thy protector, do not fear; * if you pass through fire, the flame will not harm thee, and the scent of fire will not be upon thee."
versicle:
v:
la: "Posuísti, Dómine, super caput ejus."
en: "O Lord, Thou hast set a crown of precious stones."
r:
la: "Corónam de lápide pretióso."
en: "Upon his head."
nocturn3:
antiphon:
la: "Nisi granum fruménti * cadens, in terram mórtuum fúerit, ipsum solum manet."
en: "Unless the grain of wheat * falls to the ground and dies, it only remains as such."
canticles:
- refs:
- { book: sir, chapter: 14, verses: "22" }
- { book: sir, chapter: 15, verses: "3-4" }
- { book: sir, chapter: 15, verses: "6" }
- refs:
- { book: jer, chapter: 17, verses: "7-8" }
- refs:
- { book: sir, chapter: 31, verses: "8-11" }
versicle:
v:
la: "Magna est glória ejus in salutári tuo."
en: "His glory is great in thy salvation."
r:
la: "Glóriam et magnum decórem impónes super eum."
en: "Honour and great majesty shalt Thou lay upon him."
+3 -3
View File
@@ -62,9 +62,9 @@ nocturn2:
nocturn3:
canticles:
- { book: isa, chapter: 33, verses: "2-10" }
- { book: isa, chapter: 33, verses: "13-18" }
- { book: sir, chapter: 36, verses: "14-19" }
- refs: [{ book: isa, chapter: 33, verses: "2-10" }]
- refs: [{ book: isa, chapter: 33, verses: "13-18" }]
- refs: [{ book: sir, chapter: 36, verses: "14-19" }]
antiphon:
la: "Allelúja, allelúja, allelúja."
en: "Alleluia, alleluia, alleluia."
+26
View File
@@ -0,0 +1,26 @@
# Jeremiah 17:7-8, transcribed from a live Divinum Officium query
# (Monastic Tridentinum 1617, command=prayMatutinum, 2026-08-17 — St.
# Lawrence's own octave closing day, Matins Nocturn III's second
# canticle). Verse 8 is split into two half-verses by the source itself
# (each half its own antiphon-verse line); transcribed as two separate
# entries under the same verse number, matching how the source renders
# it, not merged into one. Only 7-8 are authored; the rest of the
# chapter is unauthored.
book: jer
chapter: 17
verses:
- n: 7
text:
la: "Benedíctus vir qui confídit in Dómino, * et erit Dóminus fidúcia ejus."
en: "Blessed be the man that trusteth in the Lord, * and the Lord shall be his confidence."
status: { la: verified, en: verified }
- n: 8
text:
la: "Et erit quasi lignum quod transplantátur super aquas, quod ad humórem mittit radíces suas, * et non timébit cum vénerit æstus."
en: "And he shall be as a tree that is planted by the waters, that spreadeth out its roots towards moisture: * and it shall not fear when the heat cometh."
status: { la: verified, en: verified }
- n: 8
text:
la: "Et erit fólium ejus víride, et in témpore siccitátis non erit sollícitum, * nec aliquándo désinet fácere fructum."
en: "And the leaf thereof shall be green, and in the time of drought it shall not be solicitous, * neither shall it cease at any time to bring forth fruit."
status: { la: verified, en: verified }
+14
View File
@@ -0,0 +1,14 @@
# Sirach (Ecclesiasticus) 14:22, transcribed from a live Divinum Officium
# query (Monastic Tridentinum 1617, command=prayMatutinum, 2026-08-17 —
# St. Lawrence's own octave closing day, Matins Nocturn III's first
# canticle, "Eccli 14:22;15:3-4;15:6" — see
# data/hours/matins-psalmody-overrides/st-lawrence.yml). Only this verse
# is authored; the rest of the chapter is unauthored.
book: sir
chapter: 14
verses:
- n: 22
text:
la: "Beátus vir qui in sapiéntia morábitur, et qui in justítia sua meditábitur, * et in sensu cogitábit circumspectiónem Dei."
en: "Blessed is the man that shall continue in wisdom, and that shall meditate in his justice, * and in his mind shall think of the all-seeing eye of God."
status: { la: verified, en: verified }
+25
View File
@@ -0,0 +1,25 @@
# Sirach (Ecclesiasticus) 15:3-4,6, transcribed from a live Divinum
# Officium query (Monastic Tridentinum 1617, command=prayMatutinum,
# 2026-08-17 — St. Lawrence's own octave closing day, Matins Nocturn
# III's first canticle, "Eccli 14:22;15:3-4;15:6" — see
# data/hours/matins-psalmody-overrides/st-lawrence.yml). Verse 5 is
# skipped by the citation itself, not an authoring gap; only 3-4 and 6
# are authored, the rest of the chapter is unauthored.
book: sir
chapter: 15
verses:
- n: 3
text:
la: "Cibábit illum pane vitæ et intelléctus, * et aqua sapiéntiæ salutáris potábit illum:"
en: "With the bread of life and understanding, * she shall feed him, and give him the water of wholesome wisdom to drink."
status: { la: verified, en: verified }
- n: 4
text:
la: "Et firmábitur in illo, et non flectétur: et continébit illum, et non confundétur: * et exaltábit illum apud próximos suos."
en: "And she shall be made strong in him, and he shall not be moved: And she shall hold him fast, and he shall not be confounded: * and she shall exalt him among his neighbours."
status: { la: verified, en: verified }
- n: 6
text:
la: "Et nómine ætérno hæreditábit illum * Dóminus Deus noster."
en: "And shall cause him to inherit an everlasting name * the Lord our God."
status: { la: verified, en: verified }
+35
View File
@@ -0,0 +1,35 @@
# Sirach (Ecclesiasticus) 31:8-11, transcribed from a live Divinum
# Officium query (Monastic Tridentinum 1617, command=prayMatutinum,
# 2026-08-17 — St. Lawrence's own octave closing day, Matins Nocturn
# III's third canticle). Verse 10 is split into two half-verses by the
# source itself, transcribed as two separate entries under the same
# verse number, matching how the source renders it. Only 8-11 are
# authored; the rest of the chapter is unauthored.
book: sir
chapter: 31
verses:
- n: 8
text:
la: "Beátus vir, qui invéntus est sine mácula, et qui post aurum non ábiit, * nec sperávit in pecúnia et thesáuris."
en: "Blessed is the man that is found without blemish: and that hath not gone after gold, * nor put his trust in money nor in treasures."
status: { la: verified, en: verified }
- n: 9
text:
la: "Quis est hic, et laudábimus eum? * fecit enim mirabília in vita sua."
en: "Who is he, and we will praise him? * for he hath done wonderful things in his life."
status: { la: verified, en: verified }
- n: 10
text:
la: "Qui probátus est in illo, et perféctus est, * erit illi glória ætérna:"
en: "Who hath been tried thereby, and made perfect, * he shall have glory everlasting."
status: { la: verified, en: verified }
- n: 10
text:
la: "Qui pótuit tránsgredi, et non est transgréssus; * fácere mala, et non fecit."
en: "He that could have transgressed, and hath not transgressed: * and could do evil things, and hath not done them."
status: { la: verified, en: verified }
- n: 11
text:
la: "Ideo stabilíta sunt bona illíus in Dómino, * et eleemósynas illíus enarrábit omnis ecclésia sanctórum."
en: "Therefore are his goods established in the Lord, * and all the church of the saints shall declare his alms."
status: { la: verified, en: verified }
+61
View File
@@ -0,0 +1,61 @@
/** Matins' own analogue of hours/lauds-psalmody-overrides.ts, but keyed
* by Common category rather than per-saint: live-verified (see
* data/hours/matins-psalmody-overrides/st-lawrence.yml's own header)
* that the psalm *numbers* for Nocturns 1-2 are shared across saints of
* the same Common category (a plain-Duplex Martyr and a plain-Duplex
* Martyr-Bishop share all but one of the same twelve psalm numbers) —
* only the antiphon text varies, proper to the individual saint when
* authored, generic Common-of-* text otherwise. Deliberately the
* opposite indexing choice from Lauds' own per-feast override (see that
* file's own doc comment) — Lauds' 5-antiphon pool is shared across
* every Duplex-majus+ feast regardless of category, so per-feast is the
* natural key there; Matins' pool varies *by* category, so per-category
* is the natural key here.
*
* `id` is looked up against `hours/resolve-common.ts`'s
* `resolveOfficeWinner(day).id` when it's sanctoral, exactly like every
* other per-feast/per-category content lookup in this app — but the
* `id` values populated so far are individual saint ids (St. Lawrence
* proper), not Common-category ids, since only the live-verified proof
* has been authored; extending this to a real Common-category key
* (`common-of-a-martyr`, etc., matching `SaintRecord.common`) with
* per-saint antiphon overrides layered on top is the deferred bulk-
* content work (see TODO.md) — not built yet, so a saint without their
* own entry here falls back to the plain ferial weekday table
* (matins.ts's own fallback), not to some other saint's proper
* antiphons. */
export interface MatinsPsalmodyGroup {
psalms: number[];
antiphon: Partial<Record<string, string>>;
}
export interface ScriptureCanticleRef {
book: string;
chapter: number;
verses?: string;
}
export interface MatinsPsalmodyNocturn {
groups?: MatinsPsalmodyGroup[];
canticles?: { refs: ScriptureCanticleRef[] }[];
antiphon?: Partial<Record<string, string>>;
versicle: { v: Partial<Record<string, string>>; r: Partial<Record<string, string>> };
}
export interface MatinsPsalmodyOverride {
id: string;
nocturn1: MatinsPsalmodyNocturn;
nocturn2: MatinsPsalmodyNocturn;
nocturn3: MatinsPsalmodyNocturn;
}
const modules = import.meta.glob<{ default: MatinsPsalmodyOverride }>(
'../data/hours/matins-psalmody-overrides/*.yml',
{ eager: true },
);
const overrides = new Map<string, MatinsPsalmodyOverride>();
for (const mod of Object.values(modules)) {
overrides.set(mod.default.id, mod.default);
}
export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyOverride | undefined {
return id ? overrides.get(id) : undefined;
}
+54 -4
View File
@@ -69,6 +69,7 @@ import { resolveCommon, getDayCollect, resolveOfficeWinner, verifiedText, splitN
import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings';
import { getMatinsPsalmodyOverride } from './matins-psalmody-overrides';
import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml';
type BilingualText = Partial<Record<string, string>>;
@@ -76,9 +77,20 @@ interface SundayGroup {
psalms: number[];
antiphon: BilingualText;
}
interface ScriptureRef {
book: string;
chapter: number;
verses?: string;
}
interface SundayNocturn {
groups?: SundayGroup[];
canticles?: { book: string; chapter: number; verses?: string }[];
// Usually one ref per canticle; more than one when the source cites a
// single canticle across a chapter boundary (e.g. St. Lawrence's own
// Nocturn 3, "Eccli 14:22;15:3-4;15:6" — one canticle, two Sirach
// chapters) — concatenated in order, not rendered as separate
// canticles, matching how the source itself presents it as one entry
// under one heading.
canticles?: { refs: ScriptureRef[] }[];
antiphon?: BilingualText;
versicle: { v: BilingualText; r: BilingualText };
}
@@ -144,14 +156,14 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {}));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => {
const verses = getScriptureVerses(c.book, c.chapter, c.verses);
const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
const text: BilingualText = {
la: verses.map((v) => v.text.la).filter(Boolean).join(' '),
en: verses.map((v) => v.text.en).filter(Boolean).join(' '),
};
return {
kind: 'canticle' as const,
canticleId: `${c.book}-${c.chapter}${c.verses ? `-${c.verses}` : ''}`,
canticleId: c.refs.map((ref) => `${ref.book}-${ref.chapter}${ref.verses ? `-${ref.verses}` : ''}`).join('_'),
text: verifiedText(text),
antiphon: i === 0 ? opening : undefined,
};
@@ -165,6 +177,20 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
return psalmRefParts(getPsalmsFor('matins', day.weekday));
}
/** Fallback for a Duplex+ weekday feast with no matins-psalmody-overrides
* entry authored for its winner yet (most of them, until the bulk-content
* pass — see hours/matins-psalmody-overrides.ts's own doc comment): the
* plain ferial weekday table, chunked into 3 nocturns instead of 1 (no
* antiphons, no canticles — that content doesn't exist for the ferial
* table at all, unlike a real per-category override). Direct instruction:
* never blank, but honestly not that saint's own real proper psalmody
* until it's authored. */
function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], ResolvedPart[], ResolvedPart[]] {
const psalms = ferialPsalmody(day);
const size = Math.ceil(psalms.length / 3);
return [psalms.slice(0, size), psalms.slice(size, size * 2), psalms.slice(size * 2)];
}
/** Every id whose own patristic/hagiographic/Gospel content should be
* gathered for `day` — the office winner (if sanctoral), every
* commemorated saint (a transferred-in feast already appears as `day.winner`
@@ -279,7 +305,7 @@ export function resolveOrdo(date: string): ResolvedOrdo {
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
];
if (threeNocturns) {
if (threeNocturns && day.weekday === 'sunday') {
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
parts.push(...(nocturn1Readings ?? []));
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
@@ -287,6 +313,30 @@ export function resolveOrdo(date: string): ResolvedOrdo {
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
parts.push(...(nocturn3Readings ?? []));
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else if (threeNocturns) {
// A Duplex+ weekday feast — genuinely different psalmody from a real
// Sunday's (see hours/matins-psalmody-overrides.ts's own doc
// comment), not the same content reused. Uses that saint's own
// Common-category override when authored, falling back to the plain
// ferial weekday table (redistributed into 3 nocturns) otherwise.
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
if (override) {
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
parts.push(...(nocturn1Readings ?? []));
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
parts.push(...(nocturn2Readings ?? []));
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
parts.push(...(nocturn3Readings ?? []));
} else {
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
parts.push(...n1);
parts.push(...(nocturn1Readings ?? []));
parts.push(...n2);
parts.push(...(nocturn2Readings ?? []));
parts.push(...n3);
parts.push(...(nocturn3Readings ?? []));
}
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else {
parts.push(...ferialPsalmody(day));
parts.push(...(nocturn1Readings ?? []));
+63
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveOrdo } from '../../src/hours';
import { getPsalmsFor } from '../../src/psalter/distribution';
// Two clean proof dates, one per branch — see hours/matins.ts's own header
// and TODO.md for why this is a mechanism build with a small content slice,
@@ -120,3 +121,65 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => {
expect(prayerIndex).toBeGreaterThan(teDeumIndex);
});
});
describe('resolveOrdo("matins", ...) Duplex+ weekday-feast (3-nocturn, non-Sunday) branch', () => {
// 2026-08-17 -- St. Lawrence's own octave closing day ("In Octava S.
// Laurentii Martyris ~ Duplex"), live-verified directly against the
// reference engine (Monastic Tridentinum 1617, command=prayMatutinum).
// The mechanism bug this proves fixed: a Duplex+ weekday feast used to
// fall through to reusing the literal *Sunday* psalmody (Ps 20-31)
// unconditionally -- confirmed wrong, since a real Duplex+ weekday
// feast has its own genuinely different psalmody (see
// hours/matins-psalmody-overrides.ts and its own st-lawrence.yml proof
// data for the full sourcing detail).
const ordo = resolveOrdo('matins', '2026-08-17');
it("uses St. Lawrence's own proper psalmody, not the Sunday scheme, across 3 nocturns plus a Te Deum", () => {
const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
// Ps 3 + 94 (opening/invitatory), then his own Nocturn 1 (1,2,4,5,8,10)
// and Nocturn 2 (14,16,20,23,63,91) -- confirmed live, not the Sunday
// scheme's Ps 20-31 at all.
expect(psalms).toEqual([3, 94, 1, 2, 4, 5, 8, 10, 14, 16, 20, 23, 63, 91]);
expect(ordo.parts.some((p) => p.kind === 'te-deum')).toBe(true);
});
it("every Nocturn 1/2 psalm carries St. Lawrence's own proper antiphon, verified in both languages", () => {
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { status: Record<string, string> } }[];
// Skip Ps 3 (opening, no antiphon) and Ps 94 (the Invitatory, its own
// separate antiphon convention) -- the remaining 12 are his own.
const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber));
expect(nocturnPsalms).toHaveLength(12);
for (const p of nocturnPsalms) {
expect(p.antiphon?.status.la).toBe('verified');
expect(p.antiphon?.status.en).toBe('verified');
}
});
it('has 3 real OT canticles in Nocturn 3, verified in both languages, sourced from the newly-authored Sirach/Jeremiah chapters', () => {
const canticles = ordo.parts.filter((p) => p.kind === 'canticle') as { canticleId: string; text: { status: Record<string, string> } }[];
expect(canticles).toHaveLength(3);
// The first canticle spans two Sirach chapters under one heading in
// the source -- kept as one canticle with two refs, not split.
expect(canticles[0]?.canticleId).toBe('sir-14-22_sir-15-3-4_sir-15-6');
for (const c of canticles) {
expect(c.text.status.la).toBe('verified');
expect(c.text.status.en).toBe('verified');
}
});
it("falls back to the plain ferial weekday table, redistributed into 3 nocturns, for a Duplex+ weekday winner with no override authored", () => {
// St. Ignatius of Antioch (Duplex, Common of a Martyr-Bishop, no
// matins-psalmody-overrides entry authored) -- Feb 1 2029, confirmed
// clean (no Sunday collision) per his own saint-file comment.
const fallback = resolveOrdo('matins', '2029-02-01');
const psalms = fallback.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
// Ps 3 + 94, then Thursday's own 9-psalm ferial table (psalter-
// distribution.yml), not St. Lawrence's proper psalmody and not the
// Sunday scheme either.
expect(psalms[0]).toBe(3);
expect(psalms[1]).toBe(94);
expect(psalms.slice(2)).toEqual(getPsalmsFor('matins', 'thursday').map((r) => r.number));
expect(fallback.parts.some((p) => p.kind === 'te-deum')).toBe(true);
expect(fallback.parts.some((p) => p.kind === 'canticle')).toBe(false);
});
});