37dac8b476
Deploy / deploy (push) Successful in 56s
The original 29-saint P/T/S/N pass only ever covered duplex-majus+ saints; every below-majus saint from the *original*, pre-94-batch 109-saint pass had never had Prime/Terce/Sext/None attempted at all -- same gap already found and fixed for the 94-batch's own below-majus saints, now closed for the rest of the calendar too. Reused the Kalendaria-table-validated fileref approach rather than a blind Sancti/MM-DD.txt read -- a real near-miss caught this early: St. Apollonia's Sancti/02-09.txt is currently St. Cyril of Alexandria's (a 19th-century addition to that filename slot); blind reading would have silently attributed his Doctor-of-the-Church Common to her. Also fixed a regex bug the same pass turned up: the "vide"/"ex CXX" pattern matched the bare word "Commune" (no number) and reading-specific sub-references like "ex C4 in 2 loco" -- found via St. Clement, whose Monastic-specific [Rule] override has neither a clean "vide CXX" nor a number after "Commune." 90 below-majus saints swept, ~82 wired directly into the categories already established in the previous commits -- no new content needed, every C-number variant found already falls inside an already-verified family. A real bug this pass caught and fixed in its own output: Ss. Ursula and Companions (deliberately left minorHoursCommon-unwired) got swept up and wrongly wired, since she has collectCommon set despite propers: null and being a genuine commemoration-only stub -- the Kalendaria lookup silently defaulted to St. Hilarion's own primary slot at her shared date. Caught by cross-checking every swept saint's propers field for this exact pattern; reverted. A real new category found: Doctors of the Church are not one thing. 6 of the 7 saints whose own [Rule] says "vide C4a" (Athanasius, Augustine, Basil the Great, Hilary of Poitiers, John Chrysostom, Leo I) render P/T/S/N byte-identical to the already-verified common-of-a-confessor-bishop -- wired directly. St. Gregory the Great is the one genuine exception: his own live-rendered P/T/S/N is real, unique proper text (his own election, his Liber Regulae Pastoralis, the dove-and-quill legend, the "non Angli sed Angeli" episode) -- 7 new files, English translated throughout since the source rendered none. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
232 lines
12 KiB
TypeScript
232 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { resolveDay } from '../../src/calendar';
|
|
import {
|
|
getDayCollect,
|
|
getBenedictusAntiphon,
|
|
resolveMinorHourAntiphon,
|
|
resolveMinorHourChapter,
|
|
splitNamedAntiphon,
|
|
} from '../../src/hours/resolve-common';
|
|
import type { LiturgicalDay } from '../../src/calendar/types';
|
|
|
|
function syntheticSanctoralDay(id: string): LiturgicalDay {
|
|
return {
|
|
date: '2026-01-01',
|
|
weekday: 'thursday',
|
|
season: 'trinitytide',
|
|
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("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');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|