Compare commits

...

2 Commits

Author SHA1 Message Date
will 7858692fa7 Wire the flexa mark into every hour's psalm-antiphon pairing
Deploy / deploy (push) Successful in 1m24s
Every psalm's opening antiphon (Lauds, Prime, Terce, Sext, None,
Vespers, Compline's callers, Matins) now runs through applyFlexaMark,
marking the boundary in both the displayed antiphon and the psalm's
first verse — e.g. today's Sunday Matins Nocturn 1: "Ant. The king
rejoices ‡" / "The king rejoices ‡ in thy strength, O Lord...".
Live-verified against the reference engine's own rendering for Ps 20.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCvvgFLrhXNVFUThz6kanw
2026-08-23 06:30:58 -04:00
will 72b30fad80 Add flexa-mark (‡) boundary computation for antiphon/psalm text
Ports Divinum Officium's getantcross/depunct (horas.pl): finds where a
displayed antiphon's words stop matching a psalm's first verse, so the
mark can later show where the antiphon leaves off and the psalm's own
text resumes. Not yet wired into any hour builder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCvvgFLrhXNVFUThz6kanw
2026-08-23 06:30:23 -04:00
10 changed files with 324 additions and 47 deletions
+126
View File
@@ -1,5 +1,6 @@
import type { DayWinner } from '../calendar/types';
import { isAtLeast } from '../calendar/commemorations';
import type { ResolvedVerse, ResolvedText } from './types';
export interface SplitAntiphon {
incipit: string;
@@ -40,3 +41,128 @@ export function splitAntiphon(text: string): SplitAntiphon {
export function isDoubleOrHigher(winner: DayWinner): boolean {
return winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex');
}
/**
* Normalizes a word for flexa-boundary comparison only (never used for
* display text): strips chant/sentence punctuation and folds accented Latin
* vowels and digraphs to their plain form, mirroring Divinum Officium's own
* `depunct` (see `getantcross` in the reference engine's horas.pl).
*/
function depunctFold(word: string): string {
return word
.replace(/[.,:;?!"'*()]/g, '')
.toLowerCase()
.replace(/[áà]/g, 'a')
.replace(/[éè]/g, 'e')
.replace(/[íì]/g, 'i')
.replace(/[óòöõ]/g, 'o')
.replace(/[úùüû]/g, 'u')
.replace(/[æǽ]/g, 'ae')
.replace(/œ/g, 'oe')
.replace(/j/g, 'i');
}
/**
* Finds where, within a psalm's first verse, the words already said as the
* opening antiphon (in whichever form — incipit or full — is actually being
* displayed) leave off and the rest of the verse resumes. This is the flexa
* mark's boundary: ported from Divinum Officium's `getantcross`/`depunct`
* (horas.pl), which computes this at render time rather than storing it as
* content, since it depends on which antiphon form the day's rank selects.
*
* Returns the character offset into `verseText` to insert the mark at, the
* literal `'full'` when the antiphon's words consume the entire verse (the
* mark then belongs at the start of the *next* verse instead — dangling it
* at the end of this one would be meaningless), or `null` when there's no
* word-for-word match (including when the antiphon is longer than the
* verse, per the reference engine's own rule) and so no mark should appear.
*/
export function computeFlexaBoundary(verseText: string, antiphonText: string): number | 'full' | null {
const verseWords = [...verseText.matchAll(/\S+/g)];
const antWords = antiphonText.split(/\s+/).filter(Boolean);
let pind = 0;
let aind = 0;
while (aind < antWords.length && pind < verseWords.length) {
const verseWord = verseWords[pind]![0];
pind++;
const item1 = depunctFold(verseWord);
if (!item1) continue;
const antWord = antWords[aind]!;
aind++;
const item2 = depunctFold(antWord);
if (!item2) {
pind--;
continue;
}
if (item1 !== item2) return null;
}
if (aind < antWords.length) return null; // antiphon is longer than the verse
while (pind < verseWords.length && !depunctFold(verseWords[pind]![0])) pind++;
if (pind >= verseWords.length) return 'full';
return verseWords[pind]!.index!;
}
export interface FlexaMarked {
verses: ResolvedVerse[];
antiphon: ResolvedText | undefined;
}
/**
* Applies the flexa boundary (see `computeFlexaBoundary`) to a psalm's
* verses, per language, based on the antiphon actually being displayed for
* it — and, so the same boundary reads consistently at both ends of it, also
* appends the mark to the end of the displayed antiphon text itself (the
* reference engine only ever marks the psalm side — `antetpsalm` in its
* `specials/psalmi.pl` pushes the antiphon's own display line before ever
* computing the psalm-side boundary — but showing the mark at the close of
* the antiphon too, not just where it resumes in the psalm, is this
* project's own deliberate addition, by direct instruction).
*
* A no-op whenever there's no antiphon (only the first psalm in a set
* carries one — see each hour's `resolvePart`) or no verses to mark.
*/
export function applyFlexaMark(verses: ResolvedVerse[], antiphon: ResolvedText | undefined): FlexaMarked {
const first = verses[0];
if (!antiphon || !first) return { verses, antiphon };
const second = verses[1];
let firstText = first.text;
let secondText = second?.text;
let antiphonText = antiphon.text;
for (const lang of Object.keys(antiphon.text)) {
const rawAntText = antiphon.text[lang];
if (!rawAntText) continue;
// splitNamedAntiphon bakes an "Ant. " display label directly into the
// text (same convention as "V."/"R." on versicles) — strip it before
// comparing, since it's never part of the psalm's own opening words.
const antText = rawAntText.replace(/^Ant\.\s+/, '');
const verseText = first.text[lang];
if (!antText || !verseText) continue;
const boundary = computeFlexaBoundary(verseText, antText);
if (boundary === null) continue;
antiphonText = { ...antiphonText, [lang]: `${rawAntText}` };
if (boundary === 'full') {
const nextText = second?.text[lang];
if (nextText) secondText = { ...secondText, [lang]: `${nextText}` };
continue;
}
firstText = { ...firstText, [lang]: `${verseText.slice(0, boundary)}${verseText.slice(boundary)}` };
}
if (firstText === first.text && secondText === second?.text && antiphonText === antiphon.text) {
return { verses, antiphon };
}
const updated = [...verses];
updated[0] = { ...first, text: firstText };
if (second && secondText) updated[1] = { ...second, text: secondText };
const updatedAntiphon = antiphonText === antiphon.text ? antiphon : { ...antiphon, text: antiphonText };
return { verses: updated, antiphon: updatedAntiphon };
}
+11 -7
View File
@@ -8,7 +8,7 @@ import { getCanticle } from './lauds-canticles';
import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides';
import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollects,
@@ -81,12 +81,16 @@ function capitulumId(weekday: Weekday): string {
}
function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] {
const parts: ResolvedPart[] = numbers.map((number, i) => ({
kind: 'psalm' as const,
psalmNumber: number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
const parts: ResolvedPart[] = numbers.map((number, i) => {
const rawVerses = getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon: psalmAntiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
return {
kind: 'psalm' as const,
psalmNumber: number,
antiphon: psalmAntiphon,
verses,
};
});
parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full });
return parts;
}
+16 -5
View File
@@ -69,7 +69,7 @@ import { getPsalmVerses } from '../psalter';
import { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
import { getScriptureVerses } from '../scripture';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
@@ -150,6 +150,15 @@ function plainPsalm(number: number): PsalmPart {
};
}
/** `plainPsalm` plus the given antiphon, with the flexa mark (see
* hours/antiphon.ts's `applyFlexaMark`) applied to the first verse against
* that antiphon — a no-op when `antiphon` is undefined. */
function psalmPartWithAntiphon(number: number, rawAntiphon: ResolvedText | undefined): PsalmPart {
const base = plainPsalm(number);
const { verses, antiphon } = applyFlexaMark(base.verses, rawAntiphon);
return { ...base, antiphon, verses };
}
function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] {
return refs.map((ref) => ({
kind: 'psalm' as const,
@@ -167,7 +176,7 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return [
{ ...plainPsalm(94), antiphon: opening },
psalmPartWithAntiphon(94, opening),
{ kind: 'antiphon', text: full },
];
}
@@ -181,7 +190,7 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
g.psalms.forEach((n, i) => {
parts.push({ ...plainPsalm(n), antiphon: i === 0 ? opening : undefined });
parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined));
});
parts.push({ kind: 'antiphon', text: full });
}
@@ -282,11 +291,13 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
const opening = isDouble ? full : incipit;
group.psalms.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 } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
parts.push({
kind: 'psalm',
psalmNumber: ref.number,
verses: getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })),
antiphon: i === 0 ? opening : undefined,
verses,
antiphon,
});
});
parts.push({ kind: 'antiphon', text: full });
+11 -7
View File
@@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
@@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
const psalmRefs = getPsalmsFor('none', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
return {
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon,
verses,
};
});
}
// Not used by None.
case 'prayer':
+11 -7
View File
@@ -9,7 +9,7 @@ import { getRegulaReadingFor } from '../regula';
import { getChapterResponsoryId } from './chapter-responsory';
import { getHymnDoxologyId } from './hymn-doxology';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
appendDoxology,
@@ -70,12 +70,16 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
// — see hours/antiphon.ts. The full repeat comes later, after the
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
return {
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon,
verses,
};
});
}
case 'psalm':
return [
+11 -7
View File
@@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
@@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
const psalmRefs = getPsalmsFor('sext', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
return {
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon,
verses,
};
});
}
// Not used by Sext.
case 'prayer':
+11 -7
View File
@@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
@@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
const psalmRefs = getPsalmsFor('terce', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
return {
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon,
verses,
};
});
}
// Not used by Terce.
case 'prayer':
+11 -7
View File
@@ -4,7 +4,7 @@ import { resolveEveningDay } from '../calendar/vespers';
import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher } from './antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollects,
@@ -50,12 +50,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const refs = group.psalms.map(normalizePsalmRef);
const parts: ResolvedPart[] = refs.map((ref, i) => ({
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon: i === 0 ? opening : undefined,
verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })),
}));
const parts: ResolvedPart[] = refs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
return {
kind: 'psalm' as const,
psalmNumber: ref.number,
antiphon,
verses,
};
});
parts.push({ kind: 'antiphon', text: full });
return parts;
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { applyFlexaMark, computeFlexaBoundary } from '../../src/hours/antiphon';
import type { ResolvedText, ResolvedVerse } from '../../src/hours/types';
describe('computeFlexaBoundary', () => {
it('finds the boundary after an exact word-for-word prefix match', () => {
const boundary = computeFlexaBoundary(
'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.',
'The king rejoices',
);
expect(boundary).not.toBeNull();
expect(boundary).not.toBe('full');
const text = 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.';
expect(text.slice(boundary as number)).toBe('in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.');
});
it('matches accented Latin words against their unaccented/depunctuated form', () => {
const boundary = computeFlexaBoundary('Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'Dómine');
expect(boundary).not.toBeNull();
expect(boundary).not.toBe('full');
});
it('returns null when the antiphon is longer than the verse', () => {
const boundary = computeFlexaBoundary('Short verse text here.', 'Short verse text here and then some more words');
expect(boundary).toBeNull();
});
it('returns null when the words diverge', () => {
const boundary = computeFlexaBoundary('Dixit Dominus Domino meo.', 'Laudate pueri Dominum');
expect(boundary).toBeNull();
});
it("returns 'full' when the antiphon consumes the entire verse", () => {
const boundary = computeFlexaBoundary('Dixit Dominus Domino meo.', 'Dixit Dominus Domino meo.');
expect(boundary).toBe('full');
});
it('skips over trailing punctuation-only tokens before the boundary', () => {
// A stray standalone punctuation token between the matched words and
// the next real word shouldn't become the insertion point itself.
const text = 'Alpha beta : gamma delta.';
const boundary = computeFlexaBoundary(text, 'Alpha beta');
expect(boundary).not.toBeNull();
expect(boundary).not.toBe('full');
expect(text.slice(boundary as number)).toBe('gamma delta.');
});
});
describe('applyFlexaMark', () => {
const verse = (n: number, la: string, en: string): ResolvedVerse => ({
n,
text: { la, en },
status: { la: 'verified', en: 'verified' },
});
const antiphon = (la: string, en: string): ResolvedText => ({
text: { la, en },
status: { la: 'verified', en: 'verified' },
});
it('inserts the flexa mark into the first verse at the matched boundary, per language', () => {
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.')];
const result = applyFlexaMark(verses, antiphon('Ant. Dómine', 'Ant. The king rejoices'));
expect(result.verses[0]!.text.la).toBe('Dómine, ‡ in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.');
expect(result.verses[0]!.text.en).toBe('The king rejoices ‡ in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.');
});
it('also appends the mark to the end of the displayed antiphon text, in both languages', () => {
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex: * et super salutáre tuum exsultábit veheménter.', 'The king rejoices in thy strength, O Lord; * and in thy salvation he shall rejoice exceedingly.')];
const result = applyFlexaMark(verses, antiphon('Ant. Dómine', 'Ant. The king rejoices'));
expect(result.antiphon?.text.la).toBe('Ant. Dómine ‡');
expect(result.antiphon?.text.en).toBe('Ant. The king rejoices ‡');
});
it('is a no-op when no antiphon is given', () => {
const verses = [verse(2, 'Dómine, in virtúte tua lætábitur rex.', 'The king rejoices in thy strength.')];
const result = applyFlexaMark(verses, undefined);
expect(result.verses).toBe(verses);
expect(result.antiphon).toBeUndefined();
});
it('is a no-op when the antiphon does not share opening words with the verse', () => {
const verses = [verse(1, 'Beátus vir qui non ábiit in consílio impiórum.', 'Blessed is the man who hath not walked in the counsel of the ungodly.')];
const theAntiphon = antiphon('Ant. Fidélia', 'Ant. Faithful');
const result = applyFlexaMark(verses, theAntiphon);
expect(result.verses).toEqual(verses);
expect(result.antiphon).toBe(theAntiphon);
});
it('moves the mark to the start of the second verse when the antiphon matches the entire first verse', () => {
const verses = [
verse(2, 'Dixit Dóminus Dómino meo:', 'The Lord said to my Lord:'),
verse(3, 'Sede a dextris meis.', 'Sit thou at my right hand.'),
];
const result = applyFlexaMark(verses, antiphon('Ant. Dixit Dóminus Dómino meo:', 'Ant. The Lord said to my Lord:'));
expect(result.verses[0]!.text.la).toBe('Dixit Dóminus Dómino meo:');
expect(result.verses[1]!.text.la).toBe('‡ Sede a dextris meis.');
expect(result.verses[1]!.text.en).toBe('‡ Sit thou at my right hand.');
expect(result.antiphon?.text.la).toBe('Ant. Dixit Dóminus Dómino meo: ‡');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolveOrdo } from '../../src/hours';
describe('resolveOrdo("matins", ...) flexa mark on Ps 20', () => {
it("marks where today's opening antiphon leaves off, in both the antiphon itself and Ps 20's first verse (Sunday Matins, Nocturn 1, 2026-08-23)", () => {
const ordo = resolveOrdo('matins', '2026-08-23');
const ps20 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 20);
expect(ps20).toBeDefined();
if (ps20?.kind !== 'psalm') throw new Error('expected a psalm part');
const firstVerse = ps20.verses[0];
expect(firstVerse?.text.la).toContain('‡');
expect(firstVerse?.text.en).toContain('‡');
expect(ps20.antiphon?.text.la).toContain('‡');
expect(ps20.antiphon?.text.en).toContain('‡');
});
});