Compare commits
2 Commits
9fb9cb5e24
...
7858692fa7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7858692fa7 | |||
| 72b30fad80 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: ‡');
|
||||
});
|
||||
});
|
||||
@@ -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('‡');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user