From 4e1266d4aeed67ee4858db3ad91c973b63e50d11 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sat, 29 Aug 2026 10:37:39 -0400 Subject: [PATCH] Give each half of Saturday's split Deuteronomy canticle its own citation canticleText() always returned the whole canticle's stored citation ("Deut 32:1-65"), even when only slicing out half its verses -- both Saturday Lauds pieces showed the same full range regardless of which half was actually being said. Derive the citation from the slice's own first/last verse numbers instead, only when slicing; a full canticle's stored citation is left as-is, since it already matches what's shown and a formula can't reproduce an irregular one like canticum-trium-puerorum's "Dan 3:57-88,56" (a non-contiguous liturgical splice). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018sPkfGLiq58pmmDoBWH5UX --- src/hours/lauds.ts | 29 +++++++++++++++++++++++++++-- tests/hours/lauds.test.ts | 8 ++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 0372c0a..06e8bec 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -3,7 +3,7 @@ import type { LiturgicalDay, Weekday } from '../calendar/types'; import { resolveDay } from '../calendar'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; -import { getCanticle } from './lauds-canticles'; +import { getCanticle, type CanticleVerse } from './lauds-canticles'; import { getLaudsSaintOverride, getLaudsCommonOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; @@ -145,6 +145,30 @@ function psalmParts(numbers: number[], antiphon: BilingualText, opening: Resolve const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const; +/** The general rule is that a citation must match whatever verses are + * actually being displayed — not "the whole canticle's citation, + * always." A full canticle's stored `citation` already satisfies that + * (canticleText below only calls this helper for a `slice`), including + * cases a formula couldn't reconstruct — canticum-trium-puerorum's + * "Dan 3:57-88,56" is a non-contiguous liturgical splice, its last verse + * (`n: "3:56"`) coming after verse 88 in display order, not after it + * numerically. Only Saturday's split Deuteronomy canticle ever shows + * fewer than all of a canticle's verses, so it's the only case needing + * this: book name from the canticle's base citation (its first word), + * verse range from the slice's own first/last `n` values (e.g. + * "32:1"/"32:32" -> "Deut 32:1-32") — showing the full "1-65" range on + * both halves would mislabel whichever half isn't actually being said. */ +function sliceCitation(baseCitation: string, verses: CanticleVerse[]): string { + const book = baseCitation.split(' ')[0] ?? baseCitation; + const first = verses[0]?.n; + const last = verses[verses.length - 1]?.n; + if (!first || !last) { + return baseCitation; + } + const lastVerse = last.split(':')[1] ?? last; + return `${book} ${first}-${lastVerse}`; +} + /** Joins a canticle's verse array (or a `[start, end)` slice of it — see * Saturday's split canticle below) into one flowing text block, the same * shape nunc-dimittis/benedictus already use — Lauds' weekday canticles @@ -174,7 +198,8 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex } status[lang] = worst; } - return { text, status, citation: { la: canticle.citation, en: canticle.citation } }; + const citation = slice ? sliceCitation(canticle.citation, verses) : canticle.citation; + return { text, status, citation: { la: citation, en: citation } }; } /** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody' diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index 85deec6..f43d2ee 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -56,6 +56,10 @@ describe('resolveOrdo("lauds", ...)', () => { const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId !== 'benedictus'); expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum'); + // Its non-contiguous liturgical citation (last verse said out of + // numeric order) must come through as authored, not get mangled by + // a first/last-verse-number formula the way a naive fix would. + expect(canticle?.kind === 'canticle' ? canticle.text.citation?.la : undefined).toBe('Dan 3:57-88,56'); }); it("resolves Saturday's own psalmody: only one weekday psalm (142), and the Canticle of Moses said in two pieces (RB 13's own division), one shared antiphon framing both", () => { @@ -78,6 +82,10 @@ describe('resolveOrdo("lauds", ...)', () => { expect(part2?.kind === 'canticle' ? part2.antiphon : 'should be undefined -- shares part 1s opening antiphon').toBeUndefined(); expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Audíte, cæli'); expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Ignis succénsus est'); + // Regression test: each half must cite its own verse range, not the + // whole canticle's "Deut 32:1-65" on both pieces. + expect(part1?.kind === 'canticle' ? part1.text.citation?.la : undefined).toBe('Deut 32:1-32'); + expect(part2?.kind === 'canticle' ? part2.text.citation?.la : undefined).toBe('Deut 32:33-65'); // Each part gets its own Gloria Patri. expect(part1?.kind === 'canticle' ? part1.gloriaPatri?.text.la : undefined).toContain('Glória Patri'); expect(part2?.kind === 'canticle' ? part2.gloriaPatri?.text.la : undefined).toContain('Glória Patri');