Compare commits

..

2 Commits

Author SHA1 Message Date
will 8919497d24 Update tests for ferial Matins Sunday-leak fix
Deploy / deploy (push) Successful in 1m28s
The rest-of-year temporal nocturn-readings sweep was testing the Monday
after each named Sunday, relying on the now-removed leak to prove content
reachability for dates with no separately-authored ferial content -
repointed to the actual Sunday date. Updated the 2026-08-24 suppression
test's ferial case to assert the leak no longer happens, and added a
regression test pinning the original 2026-09-01 bug report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q
2026-09-01 07:06:20 -04:00
will 0f84c9e55b Stop ferial Matins from reusing the governing Sunday's own Nocturn 3 content
A plain feria within an ordinary week was pooling that week's Sunday's own
patristic homily and responsory verbatim into its single nocturn, because
day.winner.id (added unconditionally) equals the Sunday's own temporalId on
an ordinary day, bypassing the existing suppression gate. Both the
day.winner.id/commemoration shortcut and the plain temporalId/month-week
pool are now gated on threeNocturns, so a ferial day only draws on its own
day's content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q
2026-09-01 07:06:14 -04:00
2 changed files with 96 additions and 51 deletions
+52 -38
View File
@@ -18,16 +18,23 @@
// a *variable* number of readings, not the historical fixed 3 or the
// Rule's own "summer" contraction, deliberately not reproduced —
// this app reads in full year-round), the office winner's and every
// commemorated saint's own patristic/hagiographic/Gospel content, the
// plain temporal day's own content, and every active octave's own
// reading — is gathered into one ordered pool (`buildReadingPool`),
// then slotted across however many nocturns the day's psalmody has
// (`distributeIntoNocturns`), with no reading kind pinned to a
// particular nocturn number. On a 3-nocturn day the slotting is
// front-light: Nocturn 1 gets one reading, Nocturn 2 gets one, and
// Nocturn 3 absorbs the rest of the pool, however large (user, 2026-08)
// — not an even chunking of the pool. A pool of only one reading total
// goes in Nocturn 3, not Nocturn 1.
// commemorated saint's own patristic/hagiographic/Gospel content, and
// every active octave's own reading — is gathered into one ordered
// pool (`buildReadingPool`), then slotted across however many
// nocturns the day's psalmody has (`distributeIntoNocturns`), with no
// reading kind pinned to a particular nocturn number. On a 3-nocturn
// day the slotting is front-light: Nocturn 1 gets one reading,
// Nocturn 2 gets one, and Nocturn 3 absorbs the rest of the pool,
// however large (user, 2026-08) — not an even chunking of the pool. A
// pool of only one reading total goes in Nocturn 3, not Nocturn 1.
// The plain temporal day's own content (a governing Sunday's Moralia-
// in-Job-style patristic homily and its responsory) is pooled only on
// a real 3-nocturn day, not reused verbatim on the week's ferias
// (user, 2026-09-01: a plain feria showing that Sunday's own
// already-read homily and responsory read as a mechanism bug, not a
// deliberate rereading) — a 1-nocturn feria draws only on its own
// day's content (the reading plan, any saint/octave content specific
// to that date).
// - Where the historical office splits one continuous source across
// several numbered lessons, this app recombines them into one reading
// (see src/propers/octave-readings.ts's resolvePassages / src/propers/
@@ -462,38 +469,44 @@ function withSingleAntiphon(refs: PsalmRef[], antiphonText: ResolvedText, winner
* gathered for `day` — the office winner (if sanctoral), every
* commemorated saint (a transferred-in feast already appears as `day.winner`
* once `resolveDay` has applied the transfer, so it needs no separate
* lookup here), the plain temporal id itself (for an ordinary day's own
* patristic content, e.g. a plain Sunday's Moralia-in-Job-style
* commentary), and — for dates from the 1st Sunday of August through the
* eve of Advent the calendar-month/week id (`month-week-<id>`,
* lookup here), and — only on a real 3-nocturn day (Sunday, or a Duplex+
* feast) — the plain temporal id itself (that Sunday's own Moralia-in-
* Job-style commentary) plus, for dates from the 1st Sunday of August
* through the eve of Advent, the calendar-month/week id (`month-week-<id>`,
* calendar/month-week-id.ts's monthWeekId): the real historical Nocturn 2
* for the later post-Pentecost Sundays is keyed by civil calendar month,
* not Easter offset (see that function's own header for why), so it's
* pooled here as a second, independent source alongside `temporalId`,
* same dual-key precedent as propers/bible-plan.ts's Dec25-Jan13 stretch —
* deliberately inclusive, not just the winner, per the user's own "be
* generous, not winner-takes-all" instruction (2026-08). */
function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string): string[] {
* pooled here as a second, independent source alongside `temporalId`.
* Restricting both to `threeNocturns` (user, 2026-09-01 bug report: a
* plain Tuesday's Matins was reusing the governing Sunday's own Nocturn 3
* homily *and* its responsory verbatim) — a ferial 1-nocturn day now only
* draws on its own day's content (the user's scripture-plan reading, any
* saint/octave content specific to that date), not the Sunday's own
* already-read homiletic material. */
function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string, threeNocturns: boolean): string[] {
const ids = new Set<string>();
// Not gated to `kind === 'sanctoral'` -- a named temporal override (e.g.
// Immaculate Heart of Mary, calendar/movable-feasts.ts's applyMovableFeasts)
// has its own authored nocturn-readings file keyed by its own id too,
// distinct from the plain governing-Sunday `temporalId` added below.
// Harmless to include unconditionally: on an ordinary day `day.winner.id`
// already equals `temporalId`, so the Set just dedupes.
ids.add(day.winner.id);
for (const c of day.commemorations) {
// Same reasoning as day.winner.id just above: a commemorated *temporal*
// identity (an Ember day merely commemorated under a stronger-ranked
// saint, e.g. calendar/ember-days.ts's applyEmberDay) has its own real
// nocturn-readings content too, not just a commemorated sanctoral one.
if (c.kind === 'sanctoral' || c.kind === 'temporal') ids.add(c.id);
// distinct from the plain governing-Sunday `temporalId` gated below. But
// on a plain ordinary feria `day.winner.id` *is* that same governing-
// Sunday `temporalId` (the temporal cycle IS the winner), so it must go
// through the same `threeNocturns` gate rather than being added
// unconditionally.
if (day.winner.kind === 'sanctoral' || day.winner.id !== temporalId || threeNocturns) {
ids.add(day.winner.id);
}
// The plain temporalId/month-week content is only pooled when the day's
// own occurrence decision (calendar/commemorations.ts's decideOccurrence)
// actually retained the temporal identity in some form: the temporal
// cycle won outright (day.winner.kind === 'temporal' -- a plain ferial/
// Sunday, or a named temporal override like Christ the King), or it
for (const c of day.commemorations) {
// Same reasoning as day.winner.id just above.
if (c.kind === 'sanctoral' || (c.kind === 'temporal' && (c.id !== temporalId || threeNocturns))) ids.add(c.id);
}
// The plain temporalId/month-week content is the *governing Sunday's own*
// Nocturn 2/3 patristic material, real content for that Sunday itself —
// only pooled on a real 3-nocturn day, and (as before) only when the
// day's own occurrence decision (calendar/commemorations.ts's
// decideOccurrence) actually retained the temporal identity in some form:
// the temporal cycle won outright (day.winner.kind === 'temporal' -- a
// plain Sunday, or a named temporal override like Christ the King), or it
// survives as a commemoration alongside a sanctoral winner. Excluded:
// decideOccurrence's `ordinary-feria` branch, where a real feast --
// however low-ranked -- wins with zero commemorations, correctly
@@ -501,7 +514,8 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string)
// duplex-2-classis, 2026-08-24 -- his own proper reading has no Nocturn
// 3 content, and without this gate the leftover 13th-Sunday-after-
// Pentecost/month-week content wrongly filled Nocturn 3 instead).
const temporalKept = day.winner.kind === 'temporal' || day.commemorations.some((c) => c.kind === 'temporal');
const temporalKept =
threeNocturns && (day.winner.kind === 'temporal' || day.commemorations.some((c) => c.kind === 'temporal'));
if (temporalKept) {
ids.add(temporalId);
const monthWeek = monthWeekId(date);
@@ -554,7 +568,7 @@ function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined
* own scripture reading first, then each id's authored content in its own
* file order (patristic commentary typically precedes a Gospel+homily —
* see data/propers/nocturn-readings/*.yml), then active octaves. */
function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string): ResolvedPart[] {
function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string, threeNocturns: boolean): ResolvedPart[] {
const parts: ResolvedPart[] = [];
for (const r of getBiblePlanReadings(temporalId, day.weekday, date)) {
const responsory = r.responsory ? { text: r.responsory, status: { la: 'verified' as const, en: 'verified' as const } } : undefined;
@@ -578,7 +592,7 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string):
// Not hardcoded to [2, 3]: Ember days' own nocturn-readings files use
// `nocturn: 1` (their single-nocturn structure), so every tag present
// must be handled, not just the usual Sunday/feast pair.
const ids = nocturnReadingIds(day, temporalId, date);
const ids = nocturnReadingIds(day, temporalId, date, threeNocturns);
const byNocturn = new Map<number, ResolvedPart[]>();
for (const id of ids) {
const readings = getNocturnReadings(id);
@@ -648,7 +662,7 @@ export function resolveOrdo(date: string): ResolvedOrdo {
// convention every other per-feast override in this app already uses
// (see hours/resolve-common.ts's getOfficeOverrideId).
const threeNocturns = day.weekday === 'sunday' || isDoubleOrHigher(winner);
const pool = buildReadingPool(day, temporalId, date);
const pool = buildReadingPool(day, temporalId, date, threeNocturns);
const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1);
// Tenebrae's real rubric: during the Sacred Triduum the whole opening
+44 -13
View File
@@ -524,12 +524,12 @@ describe('resolveOrdo("matins", ...) rest-of-year temporal nocturn-readings swee
}
it.each([
['2028-12-04', 'advent-1'],
['2028-12-03', 'advent-1'],
['2026-12-07', 'advent-2'],
['2026-12-14', 'advent-3'],
['2026-12-21', 'advent-4'],
['2026-01-05', 'christmas-octave-sunday'],
['2026-01-12', 'post-epiphany-1'],
['2026-01-11', 'post-epiphany-1'],
['2026-01-19', 'post-epiphany-2'],
['2026-01-26', 'post-epiphany-3'],
['2027-11-01', 'post-epiphany-4 (a resumed post-Pentecost-overflow Sunday, not a January date)'],
@@ -538,14 +538,14 @@ describe('resolveOrdo("matins", ...) rest-of-year temporal nocturn-readings swee
['2026-02-02', 'septuagesima'],
['2026-02-09', 'sexagesima'],
['2026-02-16', 'quinquagesima'],
['2031-03-03', 'lent-1 (not 2026-02-23: that date is really the Vigil of St. Matthias, transferred onto the Sunday and winning outright)'],
['2026-03-02', 'lent-2'],
['2031-03-02', 'lent-1 (not 2026-02-22: that Sunday is really the Vigil of St. Matthias, transferred onto the Sunday and winning outright)'],
['2026-03-01', 'lent-2'],
['2026-03-09', 'lent-3'],
['2026-03-16', 'lent-4'],
['2026-03-23', 'passion-sunday'],
['2027-03-22', 'palm-sunday'],
['2026-03-15', 'lent-4'],
['2026-03-22', 'passion-sunday'],
['2027-03-21', 'palm-sunday'],
['2026-04-13', 'easter-octave'],
['2026-04-20', 'easter-3'],
['2026-04-19', 'easter-3'],
['2026-04-27', 'easter-4'],
['2026-05-04', 'easter-5'],
['2026-05-11', 'easter-6'],
@@ -641,11 +641,42 @@ describe('resolveOrdo("matins", ...) nocturn-reading temporal/month-week suppres
expect(lessons.some((l) => l.text.text.la?.includes('Bartholomǽus Apóstolus'))).toBe(true);
});
it('a plain ferial weekday with no sanctoral winner (2026-09-04, a gap day in sanctoral-calendar.yml) still pools the plain temporal/month-week readings, unaffected by the gate', () => {
const ordo = resolveOrdo('matins', '2026-09-04');
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record<string, string> } }[];
expect(lessons.length).toBeGreaterThan(0);
expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true);
// Superseded by the 2026-09-01 fix below: a plain ferial weekday no
// longer pools the plain temporal/month-week readings at all (that
// content is the *governing Sunday's own* Nocturn 2/3 material, reserved
// for the Sunday itself), so this gap day's lessons come only from its
// own day's sources -- none authored yet, hence still missing. That's a
// real content gap (tracked in TODO.md), not a mechanism bug.
it('a plain ferial weekday with no sanctoral winner (2026-09-04, a gap day in sanctoral-calendar.yml, within the 14th-Sunday-after-Pentecost/month-week-091 week) does not pool the plain temporal/month-week readings', () => {
const labels = lessonLabels('2026-09-04');
expect(labels).not.toContain("St. Augustine, Bishop of Hippo, Book 2 on the Lord's Sermon on the Mount, ch. 14");
expect(labels).not.toContain('St. Gregory the Great, Moralia in Job, Book 2, ch. 1');
});
});
// Real bug (2026-09-01 fix): unlike the 2026-08-24 fix above (which only
// suppressed the temporal/month-week pool when a real feast won outright),
// nocturnReadingIds still pooled that content on a plain 1-nocturn feria
// within an ordinary week, because on such a day day.winner.id (added
// unconditionally) already *is* the governing Sunday's own temporalId. A
// Tuesday's Matins (2026-09-01, within the 14th-Sunday-after-Pentecost
// week) wrongly showed that Sunday's own patristic homily and, worse, its
// Nocturn 3 responsory verbatim — content that Sunday had already read
// three days earlier. Fixed by gating both the plain temporalId/month-week
// pool *and* the day.winner.id/commemoration shortcut that duplicated it
// on threeNocturns, so a 1-nocturn feria only ever draws on its own day's
// content.
describe('resolveOrdo("matins", ...) ferial temporal-pool suppression (2026-09-01 fix)', () => {
it("a plain Tuesday feria (2026-09-01, within the 14th-Sunday-after-Pentecost week) does not reuse that Sunday's own Nocturn 3 homily or responsory", () => {
const ordo = resolveOrdo('matins', '2026-09-01');
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as {
label?: string;
responsory?: { text: { la: string } };
}[];
expect(lessons.map((l) => l.label)).not.toContain(
"St. Augustine, Bishop of Hippo, Book 2 on the Lord's Sermon on the Mount, ch. 14",
);
expect(lessons.some((l) => l.responsory?.text.la.includes('Duo Séraphim'))).toBe(false);
});
});