Files
vu/tests/hours/resolve-common.test.ts
will b3a7176cb8
Deploy / deploy (push) Successful in 1m27s
Add the O Antiphons (Dec 17-23) as an unconditional Vespers Magnificat override
advent-4's own remaining Magnificat-antiphon gap turned out to be a
mechanism problem, not missing content: Dec 17-23 always carries one of
the seven "O Antiphons" at Vespers, keyed to the calendar date itself,
not to whichever Advent week (advent-3 or advent-4, varies by year)
happens to govern that date -- a single per-week file was structurally
the wrong shape.

Added vespers-o-antiphons.yml (MM-DD keyed) and a new lookup tier in
getMagnificatAntiphon, checked unconditionally ahead of both the
per-week temporal file and a real sanctoral winner's own antiphon --
direct instruction: the O Antiphon wins this one text slot on these
seven dates regardless of who otherwise wins the day (e.g. St. Thomas,
Dec 21, still keeps every other part of his own office). Two of the
seven (O Clavis David/O Oriens, Dec 20-21) couldn't be live-verified --
every reference-engine track routes those two dates through St.
Thomas's Vigil/feast instead, which this app's own vigil-of-st-thomas.yml
already documents diverging from -- so they're marked draft rather than
verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ME8QNPdEmHSbSx1r6VmDRG
2026-08-30 07:04:30 -04:00

348 lines
19 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { resolveDay } from '../../src/calendar';
import {
getDayCollect,
getBenedictusAntiphon,
getMagnificatAntiphon,
resolveMinorHourAntiphon,
resolveMinorHourChapter,
splitNamedAntiphon,
} from '../../src/hours/resolve-common';
import type { LiturgicalDay, Season } from '../../src/calendar/types';
function syntheticSanctoralDay(id: string, season: Season = 'trinitytide'): LiturgicalDay {
return {
date: '2026-01-01',
weekday: 'thursday',
season,
temporalCategory: 'ordinary-feria',
winner: { kind: 'sanctoral', id, name: id, rank: 'simplex' },
commemorations: [],
};
}
describe('getDayCollect', () => {
it('resolves a real, verified collect for an ordinary Sunday', () => {
const day = resolveDay('2026-08-09'); // post-pentecost-11
const collect = getDayCollect(day);
expect(collect.status.la).toBe('verified');
expect(collect.status.en).toBe('verified');
expect(collect.text.en).toContain('Let us pray.');
});
it('resolves a real, verified collect for a ferial day via its governing Sunday', () => {
const day = resolveDay('2026-06-14'); // post-pentecost-03's Sunday
const collect = getDayCollect(day);
expect(collect.status.la).toBe('verified');
});
it("resolves St. Lucy's own collect, read directly from her source file rather than a shared template (2026-08)", () => {
// Her own [Oratio] is @Commune/C7a, unedited -- unlike
// collect-c6a.yml's own C7a-derived text, no "Vírginis tuæ" insertion
// -- so her own [Name] section supplies the whole descriptive phrase,
// making her file's own text effectively proper even though it's
// built from a Common formula underneath. No English in the source at
// all, so status.en stays draft.
const day = resolveDay('2025-12-13');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-lucy' });
const collect = getDayCollect(day);
expect(collect.status).toEqual({ la: 'verified', en: 'draft' });
expect(collect.text.en).toContain('Lucy');
});
it('resolves as honestly missing for a saint with neither a proper collect nor a Common fallback authored', () => {
// Every real saint in data/calendar/saints/*.yml now has either a
// proper collect or a collectCommon fallback (see TODO.md's
// Kalendaria-table pull, 2026-08) -- so this exercises the "missing"
// path with a synthetic winner rather than hunting for a real
// example that no longer exists.
const collect = getDayCollect(syntheticSanctoralDay('example-confessor'));
expect(collect.status.la).toBe('missing');
expect(collect.status.en).toBe('missing');
});
});
describe('genuinely unwinnable stubs still get a Benedictus antiphon (2026-08)', () => {
// These saints are always commemorated, never the day's own rendered
// title (see e.g. st-marcus.yml's own comment) -- so unlike the rest of
// this project's content, their own [Ant 1] (or lack of one) had to be
// pulled directly from source rather than confirmed against a live
// Lauds query, since no such query is ever possible for them. A
// synthetic winner is used here for the same reason: resolveDay would
// never actually pick one of these as day.winner.
it("resolves via its Common category when the saint's own file has no [Ant 1] at all", () => {
// St. Marcus -- [Rule] says "vide C4b" with no override; C4b is a
// whole-file inherit of C4 (just the title relabeled "of the Popes"),
// the same family already live-query-verified for
// common-of-a-confessor-bishop.yml.
const ben = getBenedictusAntiphon(syntheticSanctoralDay('st-marcus'));
expect(ben.status).toEqual({ la: 'verified', en: 'verified' });
});
it('resolves via a cross-referenced Common category (C7 -> Commune/C6:Ant 2)', () => {
// St. Felicitas -- [Rule] says "vide C7", a category this project
// hadn't touched before; C7's own [Ant 1] is itself a reference to
// Commune/C6:Ant 2, which resolves to text byte-identical to
// common-of-a-virgin's own already-verified Benedictus antiphon.
const ben = getBenedictusAntiphon(syntheticSanctoralDay('st-felicitas'));
expect(ben.status).toEqual({ la: 'verified', en: 'verified' });
});
it('resolves via the same fallback for a saint from the *original*, pre-Kalendaria-table 109-saint pass', () => {
// St. Frances of Rome and St. Bibiana predate the 94-saint batch's
// collectCommon/minorHoursCommon mechanism entirely, but the same
// file-read method applies once a fileref is derived for them too --
// Frances of Rome's own file points to C7a (a whole-file inherit of
// C7, just relabeled "of a Widow"), resolving to the same
// common-of-a-virgin text as St. Felicitas above.
const ben = getBenedictusAntiphon(syntheticSanctoralDay('st-frances-of-rome'));
expect(ben.status).toEqual({ la: 'verified', en: 'verified' });
});
});
describe('minorHoursCommon for the *whole* calendar, not just the 94-saint batch (2026-08)', () => {
it("reuses another saint's own proper P/T/S/N wholesale when [Rule] points at their file directly, not a Commune category", () => {
// Finding of St. Stephen -- her own [Rule] is "vide Sancti/12-26"
// (St. Stephen Protomartyr's own Dec 26 file), not a Commune
// reference at all, so minorHoursCommon is his own id
// (st-stephen-protomartyr) -- reuses his already-authored proper
// files directly, same pattern as common-of-st-peter earlier.
const day = resolveDay('2026-08-03');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'finding-of-st-stephen' });
expect(resolveMinorHourAntiphon('prime', day, {}).status.la).toBe('verified');
});
it('a Doctor of the Church can have genuinely unique proper P/T/S/N, not shared with any Common', () => {
// St. Gregory the Great -- his own [Rule] says "vide C4a" (Doctor),
// the same reference 5 of the 6 other Doctor saints on this
// calendar have too, but his own live-rendered P/T/S/N is real,
// unique text ("Beátus Gregórius in cáthedra Petri...") -- not
// shared with common-of-a-confessor-bishop.yml the way St. Hilary
// of Poitiers's, St. Athanasius's, etc. all are (see
// st-hilary-of-poitiers.yml's own comment).
const day = resolveDay('2033-03-12');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-gregory-the-great' });
const prime = resolveMinorHourAntiphon('prime', day, {});
expect(prime.status.la).toBe('verified');
expect(prime.text.la).toContain('Gregórius');
});
it('most Doctor-of-the-Church saints share common-of-a-confessor-bishop despite their own [Rule] pointing at C4a (Doctor)', () => {
const day = resolveDay('2026-01-14'); // St. Hilary of Poitiers
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-hilary-of-poitiers' });
const prime = resolveMinorHourAntiphon('prime', day, {});
expect(prime.status.la).toBe('verified');
expect(prime.text.la).toContain('Ecce sacérdos magnus');
});
it('the last 2 duplex-majus+ saints held back from the original 29-saint P/T/S/N pass now have their own proper content too', () => {
// St. Benedict and All Saints of the Benedictine Order were excluded
// from that earlier pass for lacking a collect at the time (see
// TODO.md); now that they have one, they get the same individual
// live-query treatment as their peers -- own real text, not a shared
// Common, matching their rank.
const day = resolveDay('2033-03-21'); // St. Benedict
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-benedict' });
const terce = resolveMinorHourAntiphon('terce', day, {});
expect(terce.status).toEqual({ la: 'verified', en: 'draft' });
expect(terce.text.la).toContain('Benedíctus');
});
it("resolves its own file's [Ant 1] as draft, not verified, when it can't be checked against a live rendering", () => {
// Ss. Euphemia, Lucy, and Geminian -- her own file *does* have an
// [Ant 1], but St. Alexius (a winnable saint checked earlier in this
// same 2026-08 pass) proved a real [Ant 1] doesn't always end up the
// rendered Benedictus antiphon either (his own live Lauds query uses
// common-of-an-abbot's text instead) -- so a raw-file [Ant 1] read is
// never enough on its own to claim `verified`, and even less so for a
// saint no live query can ever confirm.
const ben = getBenedictusAntiphon(syntheticSanctoralDay('ss-euphemia-lucia-and-geminianus'));
expect(ben.status).toEqual({ la: 'draft', en: 'draft' });
expect(ben.text.la).toContain('Gaudent');
});
it("follows a saint's own propers-redirect for P/T/S/N too, not just the collect/Benedictus antiphon", () => {
// St. Michael's propers points at Apparition of St. Michael's own id
// (the two are word-for-word identical, see st-michael.yml), but
// getMinorHourOverrideId keys off the day's own winner id
// ("st-michael" itself) rather than following propers the way
// getDayCollect/getBenedictusAntiphon do -- a real gap a final
// calendar-wide sweep caught, fixed the same way as Finding of St.
// Stephen: minorHoursCommon points directly at his delegate's id.
const day = resolveDay('2028-09-29');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-michael' });
const prime = resolveMinorHourAntiphon('prime', day, {});
expect(prime.status.la).toBe('verified');
});
});
describe('minorHoursCommon/benedictusCommon fallback (2026-08, the 94 propers:null saints)', () => {
it('a below-duplex-majus saint still gets a real Benedictus antiphon and P/T/S/N content via its Common category', () => {
// St. Anacletus, Semiduplex -- below the duplex-majus+ threshold that
// gates a *proper* psalmody override (hours/lauds.ts's
// getPsalmodyOverrideFor), but getMinorHourOverrideId no longer
// requires that threshold for the shared-Common fallback (see its own
// doc comment for why: live-querying confirmed even a winning Simplex
// saint keeps proper/Common minor-hour content, not the ferial
// default).
const day = resolveDay('2028-07-13');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-anacletus' });
expect(getBenedictusAntiphon(day).status).toEqual({ la: 'verified', en: 'verified' });
expect(resolveMinorHourAntiphon('prime', day, {}).status.la).toBe('verified');
expect(resolveMinorHourChapter('terce', day, 'terce-capitulum-ferial').status.la).toBe('verified');
});
it('a Duplex-II-classis saint with no proper minor-hour content of its own still resolves via the Common, not just below-majus saints', () => {
// Ss. Placid and Companions -- confirms the fallback isn't only
// reached by low ranks; a real major feast with no *proper*
// Prime/Terce/Sext/None file authored falls through to its
// minorHoursCommon (common-of-several-martyrs) exactly the same way.
const day = resolveDay('2025-10-05');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'ss-placid-and-companions' });
expect(resolveMinorHourAntiphon('none', day, {}).status.la).toBe('verified');
});
});
describe('the 26 propers-set saints from the 94-saint batch (2026-08)', () => {
it('a saint with a genuine proper collect can still share its Benedictus antiphon with a Common category (benedictusCommon)', () => {
// Ss. Dionysius and Companions -- own proper collect (propers is
// set), but live-verified byte-identical to common-of-several-
// martyrs's Benedictus antiphon, so getBenedictusAntiphon falls
// through to benedictusCommon rather than a nonexistent
// ss-dionysius-and-companions-antiphon.yml.
const day = resolveDay('2025-10-09');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'ss-dionysius-and-companions' });
expect(getBenedictusAntiphon(day).status).toEqual({ la: 'verified', en: 'verified' });
});
it("a saint with genuinely unique Benedictus antiphon text resolves its own proper file, not a Common", () => {
// St. Benedict -- own proper text ("Sanctíssime Conféssor Dómini...",
// st-benedict-antiphon.yml), sourced from the source's Latin-only
// page (no English rendering, same gap as St. Scholastica's), so
// English is a translation and stays `draft`.
const day = resolveDay('2025-03-21');
expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-benedict' });
const ben = getBenedictusAntiphon(day);
expect(ben.status).toEqual({ la: 'verified', en: 'draft' });
expect(ben.text.la).toContain('Benedícte');
});
});
describe('splitNamedAntiphon', () => {
it('carries a verified status through to both the incipit and full forms', () => {
const { incipit, full } = splitNamedAntiphon({
text: { la: 'Foo * bar baz.', en: 'Foo * bar baz.' },
status: { la: 'verified', en: 'verified' },
});
expect(incipit.status).toEqual({ la: 'verified', en: 'verified' });
expect(full.status).toEqual({ la: 'verified', en: 'verified' });
});
it('carries a draft status through to both forms, per language -- the bug this fixes: it used to hardcode verified on everything regardless of the source status, silently dropping the "unverified draft text" UI marker (src/ui/styles.css .text-draft) from every draft antiphon', () => {
const { incipit, full } = splitNamedAntiphon({
text: { la: 'Foo * bar baz.', en: 'Foo * bar baz.' },
status: { la: 'verified', en: 'draft' },
});
expect(incipit.status).toEqual({ la: 'verified', en: 'draft' });
expect(full.status).toEqual({ la: 'verified', en: 'draft' });
});
it("reflects St. Scholastica's own real draft-English antiphon end to end, through Lauds", async () => {
const { resolveOrdo } = await import('../../src/hours/lauds');
const ordo = resolveOrdo('2029-02-10'); // St. Scholastica, a clean year
const idx = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'benedictus');
const canticle = ordo.parts[idx];
const repeat = ordo.parts[idx + 1];
expect(canticle?.kind === 'canticle' ? canticle.antiphon?.status.en : undefined).toBe('draft');
expect(repeat?.kind === 'antiphon' ? repeat.text.status.en : undefined).toBe('draft');
});
});
describe('Paschaltide alleluia suffix on Common-category antiphons (2026-08)', () => {
// Live-query-verified (St. Athanasius/Confessor-Bishop, St. Robert/Abbot,
// St. Catherine of Siena/Virgin): a Common category with no dedicated
// wholesale-different Paschaltide text still gets a mechanical single
// ", allelúja"/", alleluia" appended to its otherwise-unchanged base text
// during Eastertide/Ascensiontide/Pentecost-octave-week. St. Ambrose
// (common-of-a-confessor-bishop, propers: null) exercises the Common
// fallback branch directly rather than a per-saint proper file.
it("appends the seasonal alleluia to a Common Benedictus antiphon in Paschaltide", () => {
const eastertide = getBenedictusAntiphon(syntheticSanctoralDay('st-ambrose', 'eastertide'));
expect(eastertide.text.la).toBe(
'Euge, serve bone * et fidélis, quia in pauca fuísti fidélis, supra multa te constítuam, dicit Dóminus, allelúja.',
);
expect(eastertide.text.en).toBe(
'Well done, thou good and faithful servant * thou hast been faithful over a few things, I will make thee ruler over many things, saith the Lord, alleluia.',
);
expect(eastertide.status).toEqual({ la: 'verified', en: 'verified' });
});
it('leaves the same Common Benedictus antiphon unchanged outside Paschaltide', () => {
const ordinary = getBenedictusAntiphon(syntheticSanctoralDay('st-ambrose', 'trinitytide'));
expect(ordinary.text.la).toBe(
'Euge, serve bone * et fidélis, quia in pauca fuísti fidélis, supra multa te constítuam, dicit Dóminus.',
);
expect(ordinary.text.la).not.toContain('allelúja');
});
it('appends the suffix on the minor-hour Common fallback too (ascensiontide, not just eastertide)', () => {
const prime = resolveMinorHourAntiphon('prime', syntheticSanctoralDay('st-ambrose', 'ascensiontide'), {});
expect(prime.text.la?.endsWith('allelúja.')).toBe(true);
expect(prime.text.la).not.toMatch(/allelúja.*allelúja/); // never doubled for this category
});
it("doesn't double up on a Common category whose stored text is already the (unavoidably Paschaltide-only) native text", () => {
// common-of-pope-martyrs (Ss. Soter and Caius) -- its own stored files
// were necessarily captured from a live query already inside
// Paschaltide (see TODO.md: their fixed dates can only ever land in
// real Eastertide or the Sacred Triduum, never elsewhere), so the
// text already ends "allelúja, allelúja." -- appending again would be
// wrong.
const day = syntheticSanctoralDay('ss-soter-and-caius', 'eastertide');
const benedictus = getBenedictusAntiphon(day);
expect(benedictus.text.la).toBe(
'Fíliæ Jerúsalem, * veníte et vidéte Mártyres cum corónis, quibus coronávit eos Dóminus in die solemnitátis et lætítiæ, allelúja, allelúja.',
);
expect(benedictus.text.la?.match(/allelúja/g)?.length).toBe(2);
});
});
describe('getMagnificatAntiphon — classic weekday-default fallback', () => {
it('falls back to the classic weekday-quote antiphon for a plain temporal id with no proper Magnificat antiphon of its own', () => {
// Synthetic day, not a real resolveOrdo/resolveDay date: after the
// full temporal-cycle Magnificat-antiphon sweep (2026-08-30, see
// TODO.md), every real temporal id this app's calendar can actually
// land Vespers on has its own proper antiphon authored (or is covered
// date-by-date by the O Antiphons, Dec 17-23 -- see
// vespers-o-antiphons.yml), so there's no live date left to exercise
// this fallback tier through the real pipeline any more -- see
// tests/hours/vespers.test.ts's own note where this test used to
// live. Tests the mechanism directly instead, the same way this
// file's other synthetic-day cases do.
const day: LiturgicalDay = {
date: '2026-01-01',
weekday: 'tuesday',
season: 'trinitytide',
temporalCategory: 'ordinary-feria',
winner: { kind: 'temporal', id: 'not-a-real-temporal-id' },
commemorations: [],
};
const magnificat = getMagnificatAntiphon(day);
expect(magnificat.text.la).toContain('Exsúltet');
expect(magnificat.status.la).toBe('verified');
});
it('has no weekday default for Sunday (its own antiphon varies by Proper of the Time)', () => {
const day: LiturgicalDay = {
date: '2026-01-04',
weekday: 'sunday',
season: 'trinitytide',
temporalCategory: 'ordinary-sunday',
winner: { kind: 'temporal', id: 'not-a-real-temporal-id' },
commemorations: [],
};
const magnificat = getMagnificatAntiphon(day);
expect(magnificat.status.la).toBe('missing');
});
});