From 025d0e3a08d9c6352821aeb3d8ada69f2b66caad Mon Sep 17 00:00:00 2001 From: Will Estes Date: Fri, 21 Aug 2026 09:31:09 -0400 Subject: [PATCH] Fix Sunday Matins nocturn versicles dropping their R. line sundayPsalmNocturn/sundayCanticleNocturn only ever rendered a versicle's V. line, silently missing the responsory for Sunday Matins and every Duplex+ weekday feast using matins-psalmody-overrides. Both now share the versicleText helper introduced for the ferial nocturn antiphons, which combines V./R. into one block. --- TODO.md | 18 ++++++++++++------ src/hours/matins.ts | 28 +++++++++++++++------------- tests/hours/matins.test.ts | 10 ++++++++++ 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index deced7b..c5d280e 100644 --- a/TODO.md +++ b/TODO.md @@ -2229,10 +2229,16 @@ New `data/hours/matins-ferial-antiphons.yml` (6 weekdays, 5-6 antiphon groups ea group per weekday carrying a versicle) + `hours/matins.ts`'s new `ferialAntiphonedNocturn`, replacing the old bare `ferialPsalmody` call in the plain-ferial branch (`ferialPsalmody` itself stays, still used by `ferialPsalmodyThreeNocturns`'s intentionally antiphon-less fallback). -Versicle rendered as one combined V./R. block (`"V. ...\nR. ..."`), not just the V. line — -`sundayPsalmNocturn`'s own versicle handling drops the R. line entirely (only pushes -`group.versicle.v`), which looks like a pre-existing real bug there, not replicated here on -purpose; not yet fixed, since it's Sunday's own code, out of scope for this pass. +Versicle rendered as one combined V./R. block (`"V. ...\nR. ..."`) via a new shared +`versicleText` helper. + +**Follow-up, same day: fixed the pre-existing Sunday versicle bug too.** `sundayPsalmNocturn`/ +`sundayCanticleNocturn` (used by both Sunday Matins and every Duplex+ weekday feast's +`matins-psalmody-overrides` content) only ever pushed `group.versicle.v` — the V. line — never +the R. line, so every Sunday/override nocturn versicle has been silently missing its responsory +since the Sunday psalmody was first authored. Both functions now go through the same +`versicleText` helper the ferial nocturn code above uses. `tests/hours/matins.test.ts` gained a +regression test asserting all 3 Sunday nocturn versicles render both lines. `tests/hours/matins.test.ts` gained a new assertion (on the existing `FERIAL_DATE` proof, 2026-12-01, Tuesday of Advent I) confirming every nocturn psalm gets its own antiphon, @@ -2240,8 +2246,8 @@ correctly grouped, and exactly one versicle appears at the right point. Also fix Triduum tests that asserted "no versicle part anywhere in the whole ordo" — true only because ferial nocturns had no versicles at all before this change; now checks specifically that the dropped *opening* versicle (and Ps 3, which it precedes) is gone, via `ordo.parts[0]` being the -nocturn's own first psalm, rather than asserting zero versicles exist. `npm test` (420 tests) -and `tsc --noEmit` both pass. +nocturn's own first psalm, rather than asserting zero versicles exist. `npm test` (421 tests, +including the Sunday-versicle regression test above) and `tsc --noEmit` both pass. ## Known, deliberate simplifications (not bugs — working as designed) diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 3310eb2..6710c0f 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -129,6 +129,17 @@ const ferialAntiphons = matinsFerialAntiphonsData as unknown as MatinsFerialAnti const NOCTURN_NUMERAL = ['I', 'II', 'III'] as const; +/** Combines a versicle's V. and R. lines into one rendered block — every + * nocturn versicle in this file goes through this (previously + * `sundayPsalmNocturn`/`sundayCanticleNocturn` only rendered the V. line, + * silently dropping the responsory; fixed here, 2026-08-21). */ +function versicleText(versicle: { v: BilingualText; r: BilingualText }): ResolvedText { + return verifiedText({ + la: `V. ${versicle.v.la}\nR. ${versicle.r.la}`, + en: `V. ${versicle.v.en}\nR. ${versicle.r.en}`, + }); +} + type PsalmPart = Extract; function plainPsalm(number: number): PsalmPart { @@ -174,7 +185,7 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP }); parts.push({ kind: 'antiphon', text: full }); } - parts.push({ kind: 'versicle', text: verifiedText({ la: group.versicle.v.la, en: group.versicle.v.en }) }); + parts.push({ kind: 'versicle', text: versicleText(group.versicle) }); return parts; } @@ -197,7 +208,7 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv }; }); parts.push({ kind: 'antiphon', text: full }); - parts.push({ kind: 'versicle', text: verifiedText(group.versicle.v) }); + parts.push({ kind: 'versicle', text: versicleText(group.versicle) }); return parts; } @@ -241,9 +252,7 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] { * per-weekday psalm set). Every psalm-group carries its own antiphon * (incipit-or-full opening, same rank rule as Sunday's own nocturns), and * exactly one group per weekday carries a versicle+responsory, rendered - * as one combined V./R. block (not just the V. line — see the sibling - * `sundayPsalmNocturn`'s own versicle handling, which drops the R. line - * entirely; not replicated here on purpose). */ + * as one combined V./R. block via `versicleText`. */ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { const nocturn = ferialAntiphons[day.weekday as Exclude]; const isDouble = isDoubleOrHigher(resolveOfficeWinner(day)); @@ -261,14 +270,7 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { }); parts.push({ kind: 'antiphon', text: full }); if (group.versicle) { - const { v, r } = group.versicle; - parts.push({ - kind: 'versicle', - text: verifiedText({ - la: `V. ${v.la}\nR. ${r.la}`, - en: `V. ${v.en}\nR. ${r.en}`, - }), - }); + parts.push({ kind: 'versicle', text: versicleText(group.versicle) }); } } return parts; diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 9d95b4c..2829913 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -123,6 +123,16 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => { expect(ordo.parts.some((p) => p.kind === 'te-deum')).toBe(true); }); + it('renders each nocturn versicle with both its V. and R. lines (real bug: sundayPsalmNocturn/sundayCanticleNocturn used to drop the R. line entirely)', () => { + const versicles = ordo.parts.filter((p) => p.kind === 'versicle') as { text: { text: Record } }[]; + // Opening Deus-in-adjutorium versicle + one per nocturn (1, 2, 3). + expect(versicles).toHaveLength(4); + const [, nocturn1, nocturn2, nocturn3] = versicles; + expect(nocturn1?.text.text.la).toBe('V. Memor fui nocte nóminis tui Dómine.\nR. Et custodívi legem tuam.'); + expect(nocturn2?.text.text.la).toBe('V. Média nocte surgébam ad confiténdum tibi.\nR. Super justítia justificatiónis tuæ.'); + expect(nocturn3?.text.text.la).toBe('V. Exaltáre Dómine in virtúte tua.\nR. Cantábimus et psallémus virtútes tuas.'); + }); + it("resolves the user's own bible-plan reading (Tobit/Sirach) for Nocturn 1", () => { const lessons = ordo.parts.filter((p) => p.kind === 'lesson'); const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en);