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('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'); }); });