From e442f21cceb143f1e1590f3ee34faae3bed25620 Mon Sep 17 00:00:00 2001 From: keyxmakerx Date: Sat, 11 Jul 2026 13:37:39 +0000 Subject: [PATCH] fix(calendar-sync): unwrap event envelope, fix date-push indexing, reentrant guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review of #76 (FM-CAL-BACKCATALOG-FIX) found the back-catalog sync was a silent no-op and the Foundry->Chronicle date push mis-dated. - BLOCKER: the back-catalog now unwraps Chronicle's {data,total} envelope. api-client.get() returns the parsed body verbatim and GET /calendar/events wraps events in an envelope, so the old `if (!Array.isArray(events)) continue` skipped every month window -> 0 events. The unwrap is kept local to the call site, so get()'s contract for the other 25 callers is unchanged. - HIGH: _onCalendariaDateTimeChange now reads the RAW 0-indexed components nested under `.current` (Calendaria 1.1.3 dateTimeChange payload, verified in source) and corrects them through the shared helper, ignoring the day-of-YEAR `day` sibling that rides in game.time.components. The old top-level read sent undefined fields (nesting) with no 0->1 correction. - MEDIUM: the echo-suppression guard is now a reentrant _syncDepth counter, read through a _syncing getter, so a WebSocket event resolving mid-back-catalog no longer clears the guard the loop depends on -> no duplicate re-POST. - Ride-alongs: skip the weekday structure check when either side is unreadable (count 0); document the leap-year comparison exclusion; append "then reload the world" to the mismatch banner. New regression tests (tools/test-calendar-backcatalog-fix.mjs) fail on each pre-fix behavior, verified by temporary revert. Full suite 609/609 green; descriptor check green. Cites: 2026-05-21-core-tenets §T-O2 (consumer-verified wire contract), §T-B4 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012vaBdb1cgA7pvf2mRR8Mu9 --- CLAUDE.md | 6 +- scripts/calendar-sync.mjs | 126 ++++++++++--- tools/test-calendar-backcatalog-fix.mjs | 234 ++++++++++++++++++++++++ tools/test-calendar-sync-hotfix.mjs | 16 +- 4 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 tools/test-calendar-backcatalog-fix.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 35704ff..e061a91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,11 @@ Integration — Install & Updates". ## Code Conventions - **ES modules** (`.mjs`) with `export default class` pattern. -- Sync modules use a `_syncing` boolean guard to prevent infinite loops. +- Sync modules use a `_syncing` guard to prevent infinite loops. Most back it + with a boolean; `calendar-sync.mjs` backs it with a reentrant `_syncDepth` + counter (read through a `_syncing` getter) because its back-catalog loop and + WebSocket handlers can overlap, and a boolean's `finally` would unmask the + loop mid-flight (FM-CAL-BACKCATALOG-FIX item 3). - System adapters implement `toChronicleFields()` / `fromChronicleFields()`. - All REST calls use Bearer token auth via `api-client.mjs`. - WebSocket messages are routed by type through `SyncManager`. diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 2d5f1e9..e657cea 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -175,6 +175,12 @@ export function isCalendarNoteJournal(journal) { * correct with zero double-correction risk. The YEAR is absolute in both and is * never adjusted (an earlier yearZero hypothesis was wrong — audit §2). * + * CAUTION: do NOT pass a raw Foundry `game.time.components` spread (e.g. the + * `.current` of a Calendaria `dateTimeChange` payload) straight in — it carries + * BOTH a `dayOfMonth` (day-of-month, 0-indexed) AND a day-of-YEAR `day`, and the + * `day` would trip the passthrough branch. Extract `{year, month, dayOfMonth}` + * first (see `_onCalendariaDateTimeChange`). FM-CAL-BACKCATALOG-FIX item 2. + * * @param {object} startDate - Calendaria startDate ({year, month, day?|dayOfMonth?}). * @returns {{year:number, month:number, day:number}|null} 1-indexed, or null. */ @@ -227,6 +233,21 @@ export function calendarEventFetchCoordinates(calendar, yearSpan = 1) { * Compares month count, per-month day counts, and weekday count ONLY — moons, * seasons, and eras are cosmetic to date coordinates and excluded (B-R2). * + * Two ride-along refinements (FM-CAL-BACKCATALOG-FIX, RC-9): + * - The weekday comparison is SKIPPED when EITHER side reports 0 weekdays. A + * real calendar always has ≥1 weekday, so 0 means the list was unreadable + * (e.g. the Foundry reader didn't recognize Calendaria's weekday storage). + * Pausing sync on an unreadable weekday list while the month structure + * matches is a false positive — degrade to comparing what is readable. The + * month structure stays fail-closed (a genuine month divergence still pauses). + * - Leap-year month-length variants are a DOCUMENTED EXCLUSION. Chronicle's + * wire exposes leap rules (leap_year_every/offset + per-month leap_year_days, + * GetCalendar), but the active-Foundry structure reader surfaces only base + * per-month `days`, and pinning Calendaria's leap representation for a + * symmetric comparison is out of scope (RC-9: do NOT build leap inference). + * The base month/day + weekday comparison catches genuine structural + * divergence; leap variance is intentionally not compared. + * * @param {object} chronicle - Chronicle calendar ({months:[{days}], weekdays:[]}). * @param {object} foundry - normalized active Foundry structure * ({name, monthDays:number[], weekdayCount:number}). @@ -252,7 +273,10 @@ export function compareCalendarStructures(chronicle, foundry) { } } } - if (chronicleWeekdays !== fWeekdays) { + // Only compare weekday counts when BOTH sides expose a real count (> 0). A 0 + // on either side signals an unreadable list, not a genuine mismatch — see the + // doc comment's ride-along note. + if (chronicleWeekdays > 0 && fWeekdays > 0 && chronicleWeekdays !== fWeekdays) { reasons.push(`weekday count (Chronicle ${chronicleWeekdays} vs Foundry ${fWeekdays})`); } @@ -282,7 +306,13 @@ export class CalendarSync { constructor() { /** @type {import('./api-client.mjs').ChronicleAPI|null} */ this._api = null; - this._syncing = false; + /** + * Reentrant echo-suppression guard, backed by a DEPTH COUNTER (not a + * boolean). Read it through the `_syncing` getter. FM-CAL-BACKCATALOG-FIX + * item 3 — see the getter for why a counter is required. + * @type {number} + */ + this._syncDepth = 0; /** @type {'calendaria'|'simple-calendar'|null} */ this._calendarModule = null; @@ -308,6 +338,23 @@ export class CalendarSync { this._boundHandlers = {}; } + /** + * Reentrant echo-suppression guard. Reads `true` whenever ANY sync operation + * is in flight. Backed by the `_syncDepth` COUNTER rather than a boolean so + * that a Chronicle WebSocket handler firing MID back-catalog does not clear + * the guard the still-running back-catalog loop depends on. With a boolean, + * the WS handler's `finally { _syncing = false }` unmasked the loop, and the + * loop's subsequent `_createLocalEvent` calls fired `calendaria.noteCreated` + * unsuppressed → `_onCalendariaNoteCreated` re-POSTed just-pulled events as + * duplicates. A counter (`_syncDepth++` on enter, `--` in `finally`; active + * while `> 0`) nests correctly. FM-CAL-BACKCATALOG-FIX item 3. + * @returns {boolean} + * @private + */ + get _syncing() { + return this._syncDepth > 0; + } + /** * Initialize calendar sync. Detects which Foundry calendar module is * active and registers appropriate hooks. @@ -477,7 +524,8 @@ export class CalendarSync { this._calendarMismatchDetail = `Chronicle: ${chronicleName} ${chronicleShape} · Foundry: ${foundryName} ${foundryShape} — ${detail}`; const msg = `Chronicle Sync: calendar structures differ (${this._calendarMismatchDetail}). ` - + 'Calendar sync is paused for this session — import or author the matching calendar in Chronicle. ' + + 'Calendar sync is paused for this session — import or author the matching calendar in Chronicle, ' + + 'then reload the world. ' + '(Journals, characters, and maps still sync.)'; console.warn(msg); try { globalThis.ui?.notifications?.warn(msg, { permanent: true }); } catch { /* headless */ } @@ -583,11 +631,11 @@ export class CalendarSync { */ async _onChronicleEventCreated(data) { if (!data) return; - this._syncing = true; + this._syncDepth++; try { await this._createLocalEvent(data); } finally { - this._syncing = false; + this._syncDepth--; } } @@ -598,11 +646,11 @@ export class CalendarSync { */ async _onChronicleEventUpdated(data) { if (!data) return; - this._syncing = true; + this._syncDepth++; try { await this._updateLocalEvent(data); } finally { - this._syncing = false; + this._syncDepth--; } } @@ -613,11 +661,11 @@ export class CalendarSync { */ async _onChronicleEventDeleted(data) { if (!data) return; - this._syncing = true; + this._syncDepth++; try { await this._deleteLocalEvent(data); } finally { - this._syncing = false; + this._syncDepth--; } } @@ -654,13 +702,38 @@ export class CalendarSync { if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs if (this._isActiveCalendarExcluded()) return; + // Calendaria 1.1.3's dateTimeChange payload NESTS the raw date components + // under `.current` (verified in Calendaria source: time-tracker.mjs + // #fireDateTimeChangeHook spreads game.time.components into hookData.current; + // release-1.1.3 @ ee04e44). The old code read data.month / data.dayOfMonth + // off the TOP level → every field was undefined → the PUT pushed a garbage + // date. The nested components are RAW 0-indexed (month, dayOfMonth), the same + // shape the note* hooks deliver; `year` is already yearZero-adjusted. + // + // We route the correction through chronicleDateFromCalendariaStartDate so the + // 0→1 math lives in ONE place (exactly like the note paths after #76). BUT + // because `.current` is a spread of game.time.components it ALSO carries a + // day-of-YEAR `day` sibling; handing `.current` straight to the shape-keyed + // helper would trip its "has `day` → already public" branch and use + // day-of-year as day-of-month. So we feed it a CLEAN raw stub + // ({year, month, dayOfMonth}) whenever the raw dayOfMonth is present, and + // otherwise pass the source through (a genuinely public/flat payload still + // takes the helper's passthrough branch). FM-CAL-BACKCATALOG-FIX item 2. + const src = (data && data.current) ? data.current : data; + if (!src) return; + const startDate = (src.dayOfMonth !== undefined) + ? { year: src.year, month: src.month, dayOfMonth: src.dayOfMonth } + : src; + const date = chronicleDateFromCalendariaStartDate(startDate); + if (!date) return; + try { await this._api.put('/calendar/date', { - year: data.year, - month: data.month, - day: data.dayOfMonth ?? data.day, - hour: data.hour ?? 0, - minute: data.minute ?? 0, + year: date.year, + month: date.month, + day: date.day, + hour: src.hour ?? 0, + minute: src.minute ?? 0, }); } catch (err) { console.error('Chronicle: Failed to push Calendaria date/time to Chronicle', err); @@ -1068,7 +1141,7 @@ export class CalendarSync { * @private */ async _setLocalDate(data) { - this._syncing = true; + this._syncDepth++; try { if (this._calendarModule === 'calendaria') { if (this._hasModernCalendariaApi) { @@ -1105,7 +1178,7 @@ export class CalendarSync { } catch (err) { console.error('Chronicle: Failed to set local calendar date', err); } finally { - this._syncing = false; + this._syncDepth--; } } @@ -1299,20 +1372,31 @@ export class CalendarSync { // just-pulled event back to Chronicle as a DUPLICATE. Hold the _syncing guard // across the create loop, exactly as the WS pull path _onChronicleEventCreated // does around the same call. - this._syncing = true; + this._syncDepth++; try { for (const coord of fetches) { const path = coord ? `/calendar/events?year=${coord.year}&month=${coord.month}` : '/calendar/events'; - let events; + let payload; try { - events = await this._api.get(path); + payload = await this._api.get(path); } catch (err) { console.debug('Chronicle: back-catalog fetch failed for', path, err.message); continue; } - if (!Array.isArray(events)) continue; + // Chronicle's GET /calendar/events returns an ENVELOPE + // { "data": [...events], "total": N } (syncapi/calendar_api_handler.go), + // NOT a bare array — the same shape getNotes() unwraps (api-client.mjs). + // The #76 test masked this by stubbing a bare array, so every window hit + // the old `if (!Array.isArray(events)) continue` and the back-catalog + // created ZERO events. Accept the envelope (real API) AND a bare array + // (tolerance). Kept local to this call site so get()'s contract for the + // other 25 callers is unchanged. FM-CAL-BACKCATALOG-FIX item 1 (BLOCKER). + const events = Array.isArray(payload) + ? payload + : (payload && Array.isArray(payload.data) ? payload.data : null); + if (!events) continue; for (const event of events) { // Dedupe across months: a recurring event can surface in several // month windows, but maps to ONE Chronicle event id. @@ -1331,7 +1415,7 @@ export class CalendarSync { // Calendar events endpoint may not exist yet; not critical. console.debug('Chronicle: Could not sync calendar events back-catalog', err.message); } finally { - this._syncing = false; + this._syncDepth--; } } diff --git a/tools/test-calendar-backcatalog-fix.mjs b/tools/test-calendar-backcatalog-fix.mjs new file mode 100644 index 0000000..2bb87ec --- /dev/null +++ b/tools/test-calendar-backcatalog-fix.mjs @@ -0,0 +1,234 @@ +// test-calendar-backcatalog-fix.mjs — FM-CAL-BACKCATALOG-FIX regression tests. +// +// Run: node --test tools/test-calendar-backcatalog-fix.mjs +// +// Pins the three post-merge-review fixes to PR #76: +// 1. BLOCKER — back-catalog unwraps Chronicle's { data, total } envelope +// (the #76 stub used a bare array and masked a total no-op). +// 2. HIGH — the Calendaria dateTimeChange push reads the RAW 0-indexed +// components nested under `.current` and corrects them +1, ignoring the +// day-of-YEAR `day` sibling that rides in game.time.components. +// 3. MEDIUM — the echo-suppression guard is a reentrant depth counter, so a +// WS event resolving mid-back-catalog does not unmask the loop. +// Plus the two RC-9 ride-alongs on compareCalendarStructures. +// +// Payload shape verified against Calendaria release-1.1.3 source (commit +// ee04e441…): time-tracker.mjs #fireDateTimeChangeHook nests raw components +// under hookData.current; api.mjs toPublic is the (un-applied here) 0→1 +1. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Stub the Foundry globals calendar-sync.mjs touches at module-load time. +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (base) => base } }, +}; +globalThis.game = globalThis.game || { + settings: { get: () => null, register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + user: { isGM: true }, + modules: { get: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; + +const { compareCalendarStructures, CalendarSync } = await import('../scripts/calendar-sync.mjs'); + +// Build a CalendarSync WITHOUT running the constructor; seed _syncDepth: 0 to +// mirror the reentrant-guard init. +function makeCalendarSync(overrides) { + return Object.assign( + Object.create(CalendarSync.prototype), + { _hasModernCalendariaApi: true, _syncDepth: 0 }, + overrides, + ); +} + +// ── Item 1: BLOCKER — envelope unwrap ──────────────────────────────────────── + +function makeBackcatalog(getImpl, overrides) { + const created = []; + const cs = makeCalendarSync(Object.assign({ + // 1 month × (current year ±1) = 3 fetch windows. + _chronicleCalendar: { current_year: 1492, months: [{ days: 30 }] }, + _api: { get: getImpl }, + _getLocalEventId: () => null, + _createLocalEvent(e) { created.push(e); return Promise.resolve(); }, + }, overrides)); + return { cs, created }; +} + +test('BLOCKER: { data, total } envelope with 2 distinct events across 2 windows → 2 notes', async () => { + let call = 0; + const { cs, created } = makeBackcatalog(async () => { + call += 1; + if (call === 1) return { data: [{ id: 'a' }], total: 1 }; + if (call === 2) return { data: [{ id: 'b' }], total: 1 }; + return { data: [], total: 0 }; + }); + await cs._syncChronicleEventsToCalendariaNotes(); + assert.deepEqual(created.map((e) => e.id).sort(), ['a', 'b']); +}); + +test('BLOCKER: a recurring event surfacing in every window is created ONCE (dedupe by id)', async () => { + const { cs, created } = makeBackcatalog(async () => ({ data: [{ id: 'recur' }], total: 1 })); + await cs._syncChronicleEventsToCalendariaNotes(); + assert.equal(created.length, 1); +}); + +test('BLOCKER: a bare-array response is still tolerated (back-compat)', async () => { + const { cs, created } = makeBackcatalog(async () => [{ id: 'x' }]); + await cs._syncChronicleEventsToCalendariaNotes(); + assert.equal(created.length, 1); +}); + +test('BLOCKER: a non-array, non-envelope body skips the window without throwing', async () => { + for (const body of [null, {}, { unexpected: true }, { data: 'nope' }, 42]) { + const { cs, created } = makeBackcatalog(async () => body); + await cs._syncChronicleEventsToCalendariaNotes(); + assert.equal(created.length, 0, `body ${JSON.stringify(body)} must create nothing`); + } +}); + +// ── Item 2: HIGH — dateTimeChange push (nested .current, raw 0-indexed) ─────── + +function makeDatePush(overrides) { + const puts = []; + const cs = makeCalendarSync(Object.assign({ + _calendarSyncDisabled: false, + _isActiveCalendarExcluded: () => false, + _api: { put: async (path, body) => { puts.push({ path, body }); return { id: 'ok' }; } }, + }, overrides)); + return { cs, puts }; +} + +test('HIGH: nested .current raw payload → 1-indexed wire date; day-of-YEAR `day` sibling ignored', async () => { + const { cs, puts } = makeDatePush(); + // March 15 1492: raw month 2 / dayOfMonth 14, with a day-of-year `day` (=73) + // that MUST NOT be used as the day-of-month. + await cs._onCalendariaDateTimeChange({ + current: { year: 1492, month: 2, dayOfMonth: 14, day: 73, hour: 9, minute: 30, second: 5 }, + previous: {}, diff: 1, + }); + assert.equal(puts.length, 1); + assert.equal(puts[0].path, '/calendar/date'); + assert.deepEqual(puts[0].body, { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }); +}); + +test('HIGH: the January-1 zero case → 1/1, never an invalid 0/0', async () => { + const { cs, puts } = makeDatePush(); + await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 0, dayOfMonth: 0, day: 0, hour: 0, minute: 0 } }); + assert.deepEqual(puts[0].body, { year: 1492, month: 1, day: 1, hour: 0, minute: 0 }); +}); + +test('HIGH: flat payload (no .current) still works via the data.current ?? data fallback', async () => { + const { cs, puts } = makeDatePush(); + await cs._onCalendariaDateTimeChange({ year: 1492, month: 5, dayOfMonth: 9, hour: 13, minute: 45 }); + assert.deepEqual(puts[0].body, { year: 1492, month: 6, day: 10, hour: 13, minute: 45 }); +}); + +test('HIGH: a genuinely public payload (has `day`, no `dayOfMonth`) passes through un-incremented', async () => { + const { cs, puts } = makeDatePush(); + await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 3, day: 15, hour: 1, minute: 2 } }); + assert.deepEqual(puts[0].body, { year: 1492, month: 3, day: 15, hour: 1, minute: 2 }); +}); + +test('HIGH: missing hour/minute default to 0', async () => { + const { cs, puts } = makeDatePush(); + await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 2, dayOfMonth: 14 } }); + assert.deepEqual(puts[0].body, { year: 1492, month: 3, day: 15, hour: 0, minute: 0 }); +}); + +test('HIGH: a payload with no usable date (year undefined) does NOT push', async () => { + const { cs, puts } = makeDatePush(); + await cs._onCalendariaDateTimeChange({ current: { month: 2, dayOfMonth: 14 } }); + assert.equal(puts.length, 0); +}); + +test('HIGH: guarded off — _syncing (depth>0), non-GM, and paused all suppress the push', async () => { + const payload = { current: { year: 1492, month: 2, dayOfMonth: 14 } }; + + const a = makeDatePush({ _syncDepth: 1 }); + await a.cs._onCalendariaDateTimeChange(payload); + assert.equal(a.puts.length, 0, 'in-flight sync suppresses the push'); + + game.user.isGM = false; + const b = makeDatePush(); + await b.cs._onCalendariaDateTimeChange(payload); + assert.equal(b.puts.length, 0, 'non-GM does not push'); + game.user.isGM = true; + + const c = makeDatePush({ _calendarSyncDisabled: true }); + await c.cs._onCalendariaDateTimeChange(payload); + assert.equal(c.puts.length, 0, 'structure-mismatch pause suppresses the push'); +}); + +// ── Item 3: MEDIUM — reentrant depth-counter guard ─────────────────────────── + +test('MEDIUM: _syncDepth nests — the guard stays active until the OUTERMOST scope exits', () => { + const cs = makeCalendarSync({ _syncDepth: 0 }); + assert.equal(cs._syncing, false); + cs._syncDepth += 1; // outer scope (e.g. back-catalog loop) + assert.equal(cs._syncing, true); + cs._syncDepth += 1; // inner scope (e.g. WS handler) + cs._syncDepth -= 1; // inner exits — must NOT unmask + assert.equal(cs._syncing, true, 'still held after the inner scope exits'); + cs._syncDepth -= 1; // outer exits + assert.equal(cs._syncing, false); +}); + +test('MEDIUM: a WS _onChronicleEventCreated resolving mid-back-catalog does not unmask the loop', async () => { + const syncingSeen = []; + let injected = false; + const cs = makeCalendarSync({ + _chronicleCalendar: { current_year: 1492, months: [{ days: 30 }] }, + _api: { get: async () => ({ data: [{ id: 'e1' }, { id: 'e2' }], total: 2 }) }, + _getLocalEventId: () => null, + async _createLocalEvent() { + syncingSeen.push(this._syncing); + if (!injected) { + injected = true; + // A Chronicle WS event lands mid-loop: run the REAL handler, whose + // finally does _syncDepth-- . With the old boolean this cleared the + // guard for the remaining creates → duplicate re-POST. + await CalendarSync.prototype._onChronicleEventCreated.call(this, { id: 'ws-1' }); + } + }, + }); + await cs._syncChronicleEventsToCalendariaNotes(); + assert.ok(syncingSeen.length >= 2, 'multiple creates happened'); + assert.ok(syncingSeen.every((v) => v === true), 'guard stays held across the interleaved WS event'); + assert.equal(cs._syncing, false, 'guard fully released after the loop'); +}); + +// ── Ride-alongs: compareCalendarStructures (RC-9) ──────────────────────────── + +const twelveMonths = new Array(12).fill({ days: 30 }); +const twelveMonthDays = new Array(12).fill(30); + +test('ride-along: an unreadable Foundry weekday list (count 0) does not pause when months match', () => { + const chronicle = { months: twelveMonths, weekdays: new Array(7).fill({}) }; + const foundry = { name: 'X', monthDays: twelveMonthDays, weekdayCount: 0 }; + assert.equal(compareCalendarStructures(chronicle, foundry).match, true); +}); + +test('ride-along: an unreadable Chronicle weekday list (0) also does not pause when months match', () => { + const chronicle = { months: twelveMonths, weekdays: [] }; + const foundry = { name: 'X', monthDays: twelveMonthDays, weekdayCount: 7 }; + assert.equal(compareCalendarStructures(chronicle, foundry).match, true); +}); + +test('ride-along: a REAL weekday difference (both > 0) still pauses', () => { + const chronicle = { months: twelveMonths, weekdays: new Array(7).fill({}) }; + const foundry = { name: 'X', monthDays: twelveMonthDays, weekdayCount: 6 }; + const r = compareCalendarStructures(chronicle, foundry); + assert.equal(r.match, false); + assert.match(r.detail, /weekday count/); +}); + +test('ride-along: a month mismatch still pauses even when weekdays are unreadable (fail-closed on months)', () => { + const chronicle = { months: twelveMonths, weekdays: new Array(7).fill({}) }; + const foundry = { name: 'X', monthDays: new Array(15).fill(24), weekdayCount: 0 }; + const r = compareCalendarStructures(chronicle, foundry); + assert.equal(r.match, false); + assert.match(r.detail, /month count/); +}); diff --git a/tools/test-calendar-sync-hotfix.mjs b/tools/test-calendar-sync-hotfix.mjs index fd264f4..fa43f8f 100644 --- a/tools/test-calendar-sync-hotfix.mjs +++ b/tools/test-calendar-sync-hotfix.mjs @@ -69,7 +69,14 @@ test('missing/invalid startDate returns null', () => { // ── Item 1: _calendariaNoteToChronicleEvent wiring (raw vs getNote) ─────────── function makeCalendarSync(overrides) { - return Object.assign(Object.create(CalendarSync.prototype), { _hasModernCalendariaApi: false }, overrides); + // _syncDepth: 0 mirrors the constructor's reentrant-guard init (the guard is + // now a depth counter read through the _syncing getter — FM-CAL-BACKCATALOG-FIX + // item 3). Object.create skips the constructor, so seed it here. + return Object.assign( + Object.create(CalendarSync.prototype), + { _hasModernCalendariaApi: false, _syncDepth: 0 }, + overrides, + ); } test('_calendariaNoteToChronicleEvent: raw hook payload (no modern API) is +1 corrected', () => { @@ -149,9 +156,10 @@ test('back-catalog sync holds _syncing while creating local notes (no echo re-pu const syncingAtCreate = []; const cs = makeCalendarSync({ _hasModernCalendariaApi: true, - _syncing: false, _chronicleCalendar: { current_year: 1492, months: new Array(12).fill({ days: 30 }) }, - _api: { get: async () => [{ id: 'e1' }] }, // each month-window returns the same event + // Stub the REAL Chronicle envelope { data:[...], total:N } — not a bare array + // (the #76 stub that masked the BLOCKER). FM-CAL-BACKCATALOG-FIX item 1. + _api: { get: async () => ({ data: [{ id: 'e1' }], total: 1 }) }, _getLocalEventId: () => null, // nothing mapped yet _createLocalEvent(event) { // _createLocalEvent → CALENDARIA.api.createNote fires the noteCreated hook @@ -161,7 +169,7 @@ test('back-catalog sync holds _syncing while creating local notes (no echo re-pu }, }); await cs._syncChronicleEventsToCalendariaNotes(); - assert.ok(syncingAtCreate.length > 0, 'at least one event was created'); + assert.ok(syncingAtCreate.length > 0, 'at least one event was created (envelope was unwrapped)'); assert.ok(syncingAtCreate.every((v) => v === true), '_syncing must be held true during every create'); assert.equal(cs._syncing, false, '_syncing is reset after the loop'); });