diff --git a/TODO.md b/TODO.md index 7194d8a..18abfe5 100644 --- a/TODO.md +++ b/TODO.md @@ -365,16 +365,32 @@ saint winning on the three verified collision dates, and St. Thomas Becket (Semiduplex) still transferring off the Sunday rather than winning, unchanged by the fix. -### Bug found while checking February's results, not yet fixed +### Bug found while checking February's results — fixed (2026-08) -`hours/resolve-common.ts`'s `splitNamedAntiphon` always marks its output -`verified` regardless of the input status (`verifiedText()` hardcodes it) — +`hours/resolve-common.ts`'s `splitNamedAntiphon` always marked its output +`verified` regardless of the input status (`verifiedText()` hardcoded it) — so a `draft`-marked antiphon (Scholastica's translation, Matthias's -reworded English) renders as if fully verified everywhere it's actually -displayed, even though the source YAML correctly says `draft`. Affects -every split antiphon across every hour (Prime, Terce, Compline, Lauds), not -just these two — pre-existing, just never surfaced until content with a -real `draft` status went through it. +reworded English, and every other reworded/translated antiphon from the +sanctoral pull) rendered as if fully verified everywhere it's actually +displayed — silently dropping the "unverified draft text" UI marker +(`src/ui/styles.css`'s `.text-draft`, a dashed underline + tooltip) — even +though the source YAML correctly said `draft`. Affected every split +antiphon across every hour (Prime, Terce, Sext, None, Compline, Lauds), +not just Scholastica/Matthias — pre-existing, just never surfaced until +content with a real `draft` status went through it. + +Fixed by having `splitNamedAntiphon` take a real `ResolvedText` (not just +bare text) and carry its actual per-language status through to both the +incipit and full outputs. Callers that only ever had a plain +`Partial>` with no status of its own to begin with +(the plain weekday-default antiphons, Lauds psalmody overrides — neither +type tracks a status field, always implicitly verified by their own +live-checked file convention) now wrap with `verifiedText()` explicitly +at the call site instead of that being silently baked into +`splitNamedAntiphon` itself. `resolveMinorHourAntiphon` (Prime/Terce/ +Sext/None's own override lookup) now returns a full `ResolvedText` too, +for the same reason. Verified end-to-end with St. Scholastica's own +draft-English antiphon (`tests/hours/resolve-common.test.ts`). ## Mechanism not built at all diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 4e02a02..82f5257 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -3,7 +3,6 @@ import type { LiturgicalDay } from '../calendar/types'; import { resolveEveningDay } from '../calendar/vespers'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; -import { getCommonProper } from '../propers'; import { getHymnDoxologyId } from './hymn-doxology'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; @@ -63,7 +62,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { }, ]; case 'nunc-dimittis': { - const { incipit, full } = splitNamedAntiphon(getCommonProper('nunc-dimittis-antiphon').text); + const { incipit, full } = splitNamedAntiphon(resolveCommon('nunc-dimittis-antiphon')); const opening = isDoubleOrHigher(day.winner) ? full : incipit; return [ { kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening }, diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 221fa31..25b945f 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -16,6 +16,7 @@ import { splitNamedAntiphon, resolveOfficeWinner, ALWAYS_OVERRIDE_TEMPORAL_IDS, + verifiedText, } from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; @@ -88,7 +89,7 @@ function psalmParts(numbers: number[], antiphon: BilingualText, opening: Resolve antiphon: i === 0 ? opening : undefined, verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })), })); - parts.push({ kind: 'antiphon', text: splitNamedAntiphon(antiphon).full }); + parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full }); return parts; } @@ -136,7 +137,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; const opening = (antiphon: BilingualText) => { - const { incipit, full } = splitNamedAntiphon(antiphon); + const { incipit, full } = splitNamedAntiphon(verifiedText(antiphon)); return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; }; const parts: ResolvedPart[] = [ @@ -151,7 +152,7 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { } const canticle = getCanticle(wd.canticle.id); const canticleOpening = opening(wd.canticle.antiphon); - const canticleClosing = splitNamedAntiphon(wd.canticle.antiphon).full; + const canticleClosing = splitNamedAntiphon(verifiedText(wd.canticle.antiphon)).full; if (wd.canticle.split) { // Said in two pieces, own Gloria Patri each, one shared antiphon // framing both (opening before the first, full repeated only after @@ -232,7 +233,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'lauds-office': return resolveOffice(day); case 'benedictus': { - const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day).text); + const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day)); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return [ { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 3311e93..4d412e4 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -121,15 +121,18 @@ export function resolveMinorHourAntiphon( hourId: string, day: LiturgicalDay, weekdayDefault: Partial>, -): Partial> { +): ResolvedText { const overrideId = getMinorHourOverrideId(day); if (overrideId) { - const proper = getCommonProper(`${hourId}-antiphon-${overrideId}`); + const proper = resolveCommon(`${hourId}-antiphon-${overrideId}`); if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { - return proper.text; + return proper; } } - return weekdayDefault; + // The plain weekday default (data/hours/{hour}-antiphons.yml) never + // carries its own status field at all — always implicitly verified by + // the file's own live-checked convention, not a guess. + return verifiedText(weekdayDefault); } /** Same idea as resolveMinorHourAntiphon, for the chapter @@ -277,20 +280,34 @@ export function appendDoxology(body: ResolvedText, doxology: ResolvedText): Reso /** Splits a bilingual antiphon (one string per language, each with an * embedded "*") into its incipit and full forms, per language — each * prefixed "Ant. " inline, the same way "V."/"R." are baked directly into - * versicle text rather than rendered as a separate UI marker. */ -export function splitNamedAntiphon(text: Partial>): { + * versicle text rather than rendered as a separate UI marker. + * + * Takes the antiphon's real status and carries it through to both + * outputs unchanged — previously hardcoded `verified` on everything it + * touched regardless of the source's actual status (`verifiedText()`), + * so a `draft` antiphon (e.g. St. Scholastica's, self-translated — see + * TODO.md) silently lost its "unverified draft text" marker + * (`src/ui/styles.css`'s `.text-draft`, a dashed underline + tooltip) + * the moment it passed through here — which every antiphon does, since + * this is the shared incipit/full split used by every hour. */ +export function splitNamedAntiphon(antiphon: ResolvedText): { incipit: ResolvedText; full: ResolvedText; } { const incipitText: Partial> = {}; const fullText: Partial> = {}; - for (const [lang, t] of Object.entries(text)) { + const incipitStatus: Partial> = {}; + const fullStatus: Partial> = {}; + for (const [lang, t] of Object.entries(antiphon.text)) { if (!t) { continue; } const split = splitAntiphon(t); incipitText[lang] = `Ant. ${split.incipit}`; fullText[lang] = `Ant. ${split.full}`; + const status = antiphon.status[lang] ?? 'missing'; + incipitStatus[lang] = status; + fullStatus[lang] = status; } - return { incipit: verifiedText(incipitText), full: verifiedText(fullText) }; + return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; } diff --git a/tests/hours/resolve-common.test.ts b/tests/hours/resolve-common.test.ts index e098f2f..7d3da49 100644 --- a/tests/hours/resolve-common.test.ts +++ b/tests/hours/resolve-common.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { resolveDay } from '../../src/calendar'; -import { getDayCollect } from '../../src/hours/resolve-common'; +import { getDayCollect, splitNamedAntiphon } from '../../src/hours/resolve-common'; describe('getDayCollect', () => { it('resolves a real, verified collect for an ordinary Sunday', () => { @@ -25,3 +25,33 @@ describe('getDayCollect', () => { expect(collect.status.en).toBe('missing'); }); }); + +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'); + }); +});