diff --git a/src/data/hours/matins-psalmody-overrides/st-lawrence.yml b/src/data/hours/matins-psalmody-overrides/st-lawrence.yml new file mode 100644 index 0000000..6d16707 --- /dev/null +++ b/src/data/hours/matins-psalmody-overrides/st-lawrence.yml @@ -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." diff --git a/src/data/hours/matins-sunday-antiphons.yml b/src/data/hours/matins-sunday-antiphons.yml index 57508c9..1094747 100644 --- a/src/data/hours/matins-sunday-antiphons.yml +++ b/src/data/hours/matins-sunday-antiphons.yml @@ -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." diff --git a/src/data/scripture/jer-17.yml b/src/data/scripture/jer-17.yml new file mode 100644 index 0000000..e366615 --- /dev/null +++ b/src/data/scripture/jer-17.yml @@ -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 } diff --git a/src/data/scripture/sir-14.yml b/src/data/scripture/sir-14.yml new file mode 100644 index 0000000..a7fd5c7 --- /dev/null +++ b/src/data/scripture/sir-14.yml @@ -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 } diff --git a/src/data/scripture/sir-15.yml b/src/data/scripture/sir-15.yml new file mode 100644 index 0000000..62a423e --- /dev/null +++ b/src/data/scripture/sir-15.yml @@ -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 } diff --git a/src/data/scripture/sir-31.yml b/src/data/scripture/sir-31.yml new file mode 100644 index 0000000..ba17222 --- /dev/null +++ b/src/data/scripture/sir-31.yml @@ -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 } diff --git a/src/hours/matins-psalmody-overrides.ts b/src/hours/matins-psalmody-overrides.ts new file mode 100644 index 0000000..9552c04 --- /dev/null +++ b/src/hours/matins-psalmody-overrides.ts @@ -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>; +} +export interface ScriptureCanticleRef { + book: string; + chapter: number; + verses?: string; +} +export interface MatinsPsalmodyNocturn { + groups?: MatinsPsalmodyGroup[]; + canticles?: { refs: ScriptureCanticleRef[] }[]; + antiphon?: Partial>; + versicle: { v: Partial>; r: Partial> }; +} +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(); +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; +} diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 3106e48..47a3572 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -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>; @@ -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 ?? [])); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 0723da9..f244f0c 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -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 } }[]; + // 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 } }[]; + 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); + }); +});