diff --git a/src/hours/matins-psalmody-overrides.ts b/src/hours/matins-psalmody-overrides.ts index 8ae92a1..c22e4a5 100644 --- a/src/hours/matins-psalmody-overrides.ts +++ b/src/hours/matins-psalmody-overrides.ts @@ -93,5 +93,24 @@ for (const mod of Object.values(categoryModules)) { export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyScheme | undefined { if (!id) return undefined; - return overrides.get(id) ?? categories.get(getSaintRecord(id)?.common ?? ''); + return getMatinsSaintOverride(id) ?? getMatinsCommonOverride(id); +} + +/** Saint-specific proper only, skipping the Common-category tier — used + * where the caller needs to know specifically whether a *proper* exists, + * separately from a Common fallback (e.g. matins.ts's duplex-rank case, + * which treats "proper exists" and "only Common exists" differently). */ +export function getMatinsSaintOverride(id: string | undefined): MatinsPsalmodyOverride | undefined { + if (!id) return undefined; + return overrides.get(id); +} + +/** Common-category lookup only, skipping the saint-specific `overrides` + * tier — used where the caller has already handled (or ruled out) a + * saint-specific proper separately, e.g. matins.ts's duplex-rank fallback, + * which only wants Common's per-nocturn versicle/Nocturn-3 antiphon, not + * a full scheme substitution. */ +export function getMatinsCommonOverride(id: string | undefined): MatinsPsalmodyScheme | undefined { + if (!id) return undefined; + return categories.get(getSaintRecord(id)?.common ?? ''); } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index edc6249..3321aaa 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -61,8 +61,8 @@ // ferial day, one clean Sunday) — this is the mechanism build, not the // full-calendar content pass. See TODO.md for what's deferred. import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './types'; -import type { LiturgicalDay } from '../calendar/types'; -import { resolveDay, resolveTemporalId, monthWeekId, activeOctavesFor, resolveActiveOctave } from '../calendar'; +import type { LiturgicalDay, DayWinner } from '../calendar/types'; +import { resolveDay, resolveTemporalId, monthWeekId, activeOctavesFor, resolveActiveOctave, isAtLeast } from '../calendar'; import { isInTriduum } from '../calendar/temporal'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; @@ -84,7 +84,7 @@ import { getSaintRecord } from '../calendar/feasts'; 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 { getMatinsSaintOverride, getMatinsCommonOverride, type MatinsPsalmodyScheme } from './matins-psalmody-overrides'; import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml'; import matinsFerialAntiphonsData from '../data/hours/matins-ferial-antiphons.yml'; import type { Weekday } from '../calendar/types'; @@ -339,10 +339,6 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText { return resolveCommon('matins-hymn-ferial'); } -function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] { - return psalmRefParts(getPsalmsFor('matins', day.weekday)); -} - /** The plain ferial weekday nocturn's real antiphoned psalmody (see * data/hours/matins-ferial-antiphons.yml's own header for how each * weekday's groups/versicle were reconciled against psalter- @@ -378,17 +374,81 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { } /** 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)]; + * entry authored for its winner yet: the plain ferial weekday table, + * chunked into 3 nocturns instead of 1. Bare (no antiphons, no canticles) + * when `common` isn't given — genuinely nothing authored at all for this + * winner, still used as-is for a duplex-majus+ winner with no Common + * category either. + * + * When `common`/`commonId` *are* given (a duplex-rank winner whose Common + * category exists — user, 2026-08), each nocturn borrows that category's + * own `nocturn{N}.versicle`: the ferial table only ever authors one + * versicle for the whole (normally 1-nocturn) day, so a 3-nocturn split + * leaves 2 of the 3 nocturns with none at all — Common's own 3 real + * versicles are an exact fit for that gap. + * + * For the antiphon: Common's own Matins Nocturn 1/2 are 6 individual + * per-psalm antiphons each written as a paraphrase of *that* psalm's own + * opening words (e.g. Ps 4's "Invocántem exaudívit Dóminus" echoing Ps 4's + * own "Cum invocárem exaudívit me") — laid over a *different* ferial psalm + * they'd misquote it, so they can't be borrowed. Instead, each nocturn + * gets `SaintRecord.minorHoursCommon`'s own Terce/Sext/None antiphon + * (`{terce,sext,none}-antiphon-${minorHoursCommon}`) — the same per-saint + * field `resolveMinorHourAntiphon` itself uses, deliberately distinct + * from `common` (a saint's minor-hour Common category isn't always the + * same as their overall one — see `SaintRecord`'s own doc comment). + * Real, verified, and already proven non-psalm-specific by the minor hours themselves (3 + * ferial psalms of arbitrary content under one thematic antiphon, + * live-verified against the reference engine at any rank). Terce/Sext/ + * None each carry their own distinct text per category (confirmed, + * 2026-08-27), so this gives Nocturns 1/2/3 three genuinely different + * real antiphons instead of one borrowed Nocturn-3-only text — user, + * 2026-08-27: "some antiphons from the feria and some from the commons + * feels off... use those antiphons, one per nocturn." Falls back to bare + * (no antiphon) per nocturn when that particular hour+category combo + * isn't authored yet (most minor-hour Common files only cover a handful + * of categories so far) — never a mismatched substitute. */ +function ferialPsalmodyThreeNocturns( + day: LiturgicalDay, + common?: MatinsPsalmodyScheme, + commonId?: string, +): [ResolvedPart[], ResolvedPart[], ResolvedPart[]] { + const refs = getPsalmsFor('matins', day.weekday); + const size = Math.ceil(refs.length / 3); + const refChunks: [PsalmRef[], PsalmRef[], PsalmRef[]] = [ + refs.slice(0, size), + refs.slice(size, size * 2), + refs.slice(size * 2), + ]; + if (!common) { + return refChunks.map((chunk) => psalmRefParts(chunk)) as [ResolvedPart[], ResolvedPart[], ResolvedPart[]]; + } + const winner = resolveOfficeWinner(day); + const nocturnVersicles = [common.nocturn1.versicle, common.nocturn2.versicle, common.nocturn3.versicle]; + const minorHours = ['terce', 'sext', 'none'] as const; + return refChunks.map((chunk, i) => { + const antiphonText = commonId ? resolveCommon(`${minorHours[i]}-antiphon-${commonId}`) : undefined; + const authored = antiphonText && (antiphonText.status.la !== 'missing' || antiphonText.status.en !== 'missing'); + const psalms = authored ? withSingleAntiphon(chunk, antiphonText!, winner) : psalmRefParts(chunk); + return [...psalms, { kind: 'versicle', text: versicleText(nocturnVersicles[i]!) }]; + }) as [ResolvedPart[], ResolvedPart[], ResolvedPart[]]; +} + +/** Wraps a flat list of psalm refs in one opening/closing antiphon — the + * shape `sundayCanticleNocturn` already uses for its own single antiphon + * over several canticles, reused here for `ferialPsalmodyThreeNocturns`'s + * borrowed Terce/Sext/None antiphon over ferial psalms. */ +function withSingleAntiphon(refs: PsalmRef[], antiphonText: ResolvedText, winner: DayWinner): ResolvedPart[] { + const opening = openingAntiphon(antiphonText, winner); + const { full } = splitNamedAntiphon(antiphonText); + const parts: ResolvedPart[] = []; + refs.forEach((ref, i) => { + const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })); + const { verses, antiphon: partAntiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); + parts.push({ kind: 'psalm', psalmNumber: ref.number, verses, antiphon: partAntiphon }); + }); + parts.push({ kind: 'antiphon', text: full }); + return parts; } /** Every id whose own patristic/hagiographic/Gospel content should be @@ -578,25 +638,44 @@ export function resolveOrdo(date: string): ResolvedOrdo { parts.push({ kind: 'versicle', text: resolveCommon('te-decet-laus') }); } else if (threeNocturns) { // A Duplex+ weekday feast — genuinely different psalmody from a real - // Sunday's, not the same content reused. getMatinsPsalmodyOverride - // itself does the proper -> Common -> (nothing) tiering; only the - // final "nothing authored at all" tier — the plain ferial weekday - // table, redistributed into 3 nocturns — lives here (see - // hours/matins-psalmody-overrides.ts's own doc comment for the full - // three-tier picture). - const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined; - if (override) { + // Sunday's, not the same content reused. + // - Saint-specific proper: use its own full scheme, any rank. + // - No proper, rank duplex-majus+: Common's own full scheme (own + // psalm numbers, canticles) if authored; else the fully bare + // ferialPsalmodyThreeNocturns stand-in. Unaffected by the + // duplex-and-below carve-out below (user, 2026-08). + // - No proper, rank exactly duplex (the floor of 3-nocturn + // eligibility): ferial psalm numbers, not Common's own — but + // Common's per-nocturn versicle, and each nocturn's own Terce/ + // Sext/None antiphon for that category, if authored (see + // ferialPsalmodyThreeNocturns's own doc comment for why those + // three, not Common's own Matins Nocturn antiphons). + const properOverride = winner.kind === 'sanctoral' ? getMatinsSaintOverride(winner.id) : undefined; + const isAboveDuplex = winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus'); + const commonOverride = winner.kind === 'sanctoral' && !properOverride ? getMatinsCommonOverride(winner.id) : undefined; + if (properOverride) { parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); - parts.push(...sundayPsalmNocturn(override.nocturn1, day)); + parts.push(...sundayPsalmNocturn(properOverride.nocturn1, day)); parts.push(...(nocturn1Readings ?? [])); parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` }); - parts.push(...sundayPsalmNocturn(override.nocturn2, day)); + parts.push(...sundayPsalmNocturn(properOverride.nocturn2, day)); parts.push(...(nocturn2Readings ?? [])); parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` }); - parts.push(...sundayCanticleNocturn(override.nocturn3, day)); + parts.push(...sundayCanticleNocturn(properOverride.nocturn3, day)); + parts.push(...(nocturn3Readings ?? [])); + } else if (isAboveDuplex && commonOverride) { + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); + parts.push(...sundayPsalmNocturn(commonOverride.nocturn1, day)); + parts.push(...(nocturn1Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` }); + parts.push(...sundayPsalmNocturn(commonOverride.nocturn2, day)); + parts.push(...(nocturn2Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` }); + parts.push(...sundayCanticleNocturn(commonOverride.nocturn3, day)); parts.push(...(nocturn3Readings ?? [])); } else { - const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day); + const commonId = !isAboveDuplex && winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.minorHoursCommon : undefined; + const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day, isAboveDuplex ? undefined : commonOverride, commonId); parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); parts.push(...n1); parts.push(...(nocturn1Readings ?? [])); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index d41fff2..eb9c050 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -226,90 +226,107 @@ describe('resolveOrdo("matins", ...) Duplex+ weekday-feast (3-nocturn, non-Sunda } }); - it("falls through to its Common category's own generic psalmody for a Duplex+ weekday winner with no proper antiphon authored", () => { + it("uses ferial psalms with its own Terce/Sext/None antiphons (one per nocturn) plus Common's own versicles, for a plain-Duplex weekday winner with no proper antiphon authored", () => { // St. Ignatius of Antioch (Duplex, Common of a Martyr-Bishop, no - // proper antiphon of his own) -- Feb 1 2029, confirmed clean (no - // Sunday collision) per his own saint-file comment. - // getMatinsPsalmodyOverride's Common tier resolves - // common-of-a-martyr-bishop via common-of-a-martyr.yml's own - // `aliases` (the reference engine's own Matins psalmody for - // Martyr-Bishop redirects outright to plain Martyr) -- real, - // verified Common text, not the plain ferial table. + // proper antiphon of his own) -- Feb 1 2029, a Thursday, confirmed + // clean (no Sunday collision) per his own saint-file comment. + // Duplex is the floor of 3-nocturn eligibility (user, 2026-08): the + // psalm numbers are Thursday's own real ferial table + // (psalter-distribution.yml), chunked into 3 -- not Common's own + // psalm numbers, which only duplex-majus+ winners still get in full. + // Each nocturn's versicle is Common's own (getMatinsCommonOverride's + // Common-of-a-Martyr-Bishop resolves via common-of-a-martyr.yml's + // `aliases`); each nocturn's antiphon is his own SaintRecord. + // minorHoursCommon (common-of-a-martyr, live-verified identical + // across the Bishop/non-Bishop split) own Terce/Sext/None antiphon -- + // real, verified, non-psalm-specific text, one distinct antiphon per + // nocturn (user, 2026-08-27: "some antiphons from the feria and some + // from the commons feels off... use those antiphons, one per + // nocturn"), not Common's own Matins Nocturn 1/2 antiphons (those are + // 6 individual per-psalm paraphrases that would misquote a different + // ferial psalm). const common = resolveOrdo('matins', '2029-02-01'); const psalms = common.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); - // Same Nocturn 1 as St. Lawrence's own (1,2,4,5,8,10), but Nocturn 2 - // uses the Common default's Ps 64, not Lawrence's own proper Ps 16. - expect(psalms).toEqual([3, 94, 1, 2, 4, 5, 8, 10, 14, 20, 23, 63, 64, 91]); + expect(psalms).toEqual([3, 94, 76, 77, 77, 78, 79, 80, 82, 83, 84]); expect(common.parts.some((p) => p.kind === 'te-deum')).toBe(true); - expect(common.parts.some((p) => p.kind === 'canticle')).toBe(true); + // No canticles -- the ferial table has none, and this tier doesn't + // borrow Common's own (unlike a duplex-majus+ winner's full scheme). + expect(common.parts.some((p) => p.kind === 'canticle')).toBe(false); - const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { status: Record } }[]; + const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { text: Record; status: Record } }[]; const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber)); - // The generic Common antiphon text is real and verified, even though - // it is not proper to Ignatius himself. - for (const p of nocturnPsalms) { - expect(p.antiphon?.status.la).toBe('verified'); - expect(p.antiphon?.status.en).toBe('verified'); + // Nocturn 1 (76,77,77): Terce's own Common-of-a-Martyr antiphon. + expect(nocturnPsalms[0]?.antiphon?.text.la).toBe('Ant. Qui séquitur me, * non ámbulat in ténebris, sed habébit lumen vitæ, dicit Dóminus.'); + expect(nocturnPsalms.slice(1, 3).every((p) => p.antiphon === undefined)).toBe(true); + // Nocturn 2 (78,79,80): Sext's own, a genuinely different antiphon. + expect(nocturnPsalms[3]?.antiphon?.text.la).toBe('Ant. Qui mihi minístrat, * me sequátur: et ubi ego sum, illic sit et miníster meus.'); + expect(nocturnPsalms.slice(4, 6).every((p) => p.antiphon === undefined)).toBe(true); + // Nocturn 3 (82,83,84): None's own, different again. + expect(nocturnPsalms[6]?.antiphon?.text.la).toBe('Ant. Volo Pater, * ut ubi ego sum, illic sit et miníster meus.'); + expect(nocturnPsalms.slice(7).every((p) => p.antiphon === undefined)).toBe(true); + for (const i of [0, 3, 6]) { + expect(nocturnPsalms[i]?.antiphon?.status.la).toBe('verified'); + expect(nocturnPsalms[i]?.antiphon?.status.en).toBe('verified'); } }); - it("falls through to Common-of-a-Confessor's own generic psalmody for a Duplex+ weekday winner with no proper antiphon authored", () => { - // St. Francis of Paola (Duplex, Common of a Confessor Not a Bishop, - // no proper antiphon of his own) -- 2030-04-02, confirmed clean (no - // Sunday collision, safely outside Holy Week/Easter octave that year) - // per his own saint-file comment. + it("uses ferial psalms with Common-of-an-Abbot's own Terce/Sext/None antiphons (St. Francis of Paola's own minorHoursCommon, distinct from his Matins-classification common) for a plain-Duplex weekday winner with no proper antiphon authored", () => { + // St. Francis of Paola (Duplex, Common of a Confessor Not a Bishop + // for `common`, but `minorHoursCommon: common-of-an-abbot` -- his + // own P/T/S/N draw from the Abbot common instead, live-verified per + // his own saint-file comment) -- 2030-04-02, a Tuesday, confirmed + // clean (no Sunday collision, safely outside Holy Week/Easter octave + // that year). const common = resolveOrdo('matins', '2030-04-02'); const psalms = common.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); - expect(psalms).toEqual([3, 94, 1, 2, 4, 5, 8, 10, 14, 20, 23, 95, 96, 97]); + expect(psalms).toEqual([3, 94, 43, 44, 47, 48, 49, 54, 55, 58, 59]); expect(common.parts.some((p) => p.kind === 'te-deum')).toBe(true); - expect(common.parts.some((p) => p.kind === 'canticle')).toBe(true); + expect(common.parts.some((p) => p.kind === 'canticle')).toBe(false); - const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { status: Record } }[]; + const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { text: Record; status: Record } }[]; const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber)); - for (const p of nocturnPsalms) { - expect(p.antiphon?.status.la).toBe('verified'); - expect(p.antiphon?.status.en).toBe('verified'); + expect(nocturnPsalms[0]?.antiphon?.text.la).toBe('Ant. Euge, serve bone * in módico fidélis, intra in gáudium Dómini tui.'); + expect(nocturnPsalms[3]?.antiphon?.text.la).toBe('Ant. Fidélis servus * et prudens, quem constítuit Dóminus super famíliam suam.'); + expect(nocturnPsalms[6]?.antiphon?.text.la).toBe('Ant. Serve bone * et fidélis, intra in gáudium Dómini tui.'); + for (const i of [0, 3, 6]) { + expect(nocturnPsalms[i]?.antiphon?.status.la).toBe('verified'); + expect(nocturnPsalms[i]?.antiphon?.status.en).toBe('verified'); } }); - it("falls through to Common-of-a-Confessor-Bishop's own generic psalmody -- same psalm numbers as Common of a Confessor, different Nocturn versicles", () => { + it("uses ferial psalms for a Common-of-a-Confessor-Bishop winner -- same weekday psalm numbers as any other plain-Duplex Thursday winner, since psalm numbers no longer come from Common at all", () => { // St. Basil the Great (Duplex, live-verified as byte-identical to // common-of-a-confessor-bishop.yml despite his own [Rule] saying - // "vide C4a") -- 2035-06-14, confirmed clean per his own saint-file - // comment. + // "vide C4a") -- 2035-06-14, a Thursday, confirmed clean per his own + // saint-file comment. Same Thursday ferial psalms as St. Ignatius of + // Antioch's own test above -- the two Commons only ever differed in + // versicles/antiphon, and psalm numbers are ferial now regardless of + // which Common category applies. const common = resolveOrdo('matins', '2035-06-14'); const psalms = common.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); - // Same psalm numbers in every slot as Common of a Confessor Not a - // Bishop above -- the two Commons only differ in their versicles. - expect(psalms).toEqual([3, 94, 1, 2, 4, 5, 8, 10, 14, 20, 23, 95, 96, 97]); + expect(psalms).toEqual([3, 94, 76, 77, 77, 78, 79, 80, 82, 83, 84]); expect(common.parts.some((p) => p.kind === 'te-deum')).toBe(true); - expect(common.parts.some((p) => p.kind === 'canticle')).toBe(true); + expect(common.parts.some((p) => p.kind === 'canticle')).toBe(false); }); - it("falls through to Common-of-Several-Martyrs's own generic psalmody -- a genuinely different scheme from the single-martyr Common", () => { + it("uses ferial psalms with Common-of-Several-Martyrs's own Terce/Sext/None antiphons (one per nocturn) for a plain-Duplex weekday winner with no proper antiphon authored", () => { // Ss. Fabian and Sebastian (Duplex, Common of Several Martyrs, no // proper antiphon of their own) -- 2029-01-20, a Saturday, confirmed // clean (no Sunday collision) per their own saint-file comment. const common = resolveOrdo('matins', '2029-01-20'); const psalms = common.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); - expect(psalms).toEqual([3, 94, 1, 2, 10, 14, 15, 23, 32, 33, 45, 60, 63, 78]); + expect(psalms).toEqual([3, 94, 96, 104, 104, 105, 105, 106, 106, 107, 108]); expect(common.parts.some((p) => p.kind === 'te-deum')).toBe(true); - expect(common.parts.some((p) => p.kind === 'canticle')).toBe(true); + expect(common.parts.some((p) => p.kind === 'canticle')).toBe(false); - const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { status: Record } }[]; + const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { text: Record; status: Record } }[]; const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber)); - for (const p of nocturnPsalms) { - expect(p.antiphon?.status.la).toBe('verified'); - expect(p.antiphon?.status.en).toBe('verified'); - } - - const canticles = common.parts.filter((p) => p.kind === 'canticle') as { canticleId: string; text: { status: Record } }[]; - expect(canticles).toHaveLength(3); - expect(canticles[0]?.canticleId).toBe('sap-3-1-6'); - for (const c of canticles) { - expect(c.text.status.la).toBe('verified'); - expect(c.text.status.en).toBe('verified'); - } + expect(nocturnPsalms[0]?.antiphon?.text.la).toBe('Ant. Cum palma * ad regna pervenérunt Sancti, corónas decóris meruérunt de manu Dei.'); + expect(nocturnPsalms[3]?.antiphon?.text.la).toBe('Ant. Córpora Sanctórum * in pace sepúlta sunt: et vivent nómina eórum in ætérnum.'); + expect(nocturnPsalms[6]?.antiphon?.text.la).toBe('Ant. Mártyrum chorus * laudáte Dóminum de cælis, allelúja.'); + expect(nocturnPsalms[6]?.antiphon?.status.la).toBe('verified'); + expect(nocturnPsalms[6]?.antiphon?.status.en).toBe('verified'); + expect(nocturnPsalms.slice(7).every((p) => p.antiphon === undefined)).toBe(true); }); it('falls all the way back to the plain ferial weekday table, redistributed into 3 nocturns, when neither a proper nor a Common entry is authored', () => {