From 3cf447a2467674d85b7368088f91042ea520185f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 04:11:17 +0000 Subject: [PATCH] feat(calendar): confirm applied dates back to Chronicle (FM-SYNC-CONFIRMED-DATE) Foundry-side half of the applied-date upgrade: after calendar-sync.mjs successfully applies a date pulled from Chronicle (poll path via onInitialSync, WebSocket path via _onChronicaleDateAdvanced), POST /calendar/date/confirm with the applied y/m/d so Chronicle's sync chip can distinguish "Foundry saw this" from "Foundry applied this" (pairs with the Chronicle-side C-SYNC-APPLIED-BEACON change). _setLocalDate now returns whether a real calendar-module setter ran without throwing (the strongest success signal either Calendaria or SimpleCalendar's API offers) so callers confirm only on genuine apply success, never on a bare fetch or a no-op/failed apply. The confirm POST runs after the _syncDepth guard unwinds and touches no Foundry hooks, so it can't re-enter the sync loop. A 404/405 (older Chronicle without the endpoint) is tolerated via a session-singleton debug log, mirroring the RC-4 real-time-date-guard pattern. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014sV3MXquBemGYntdkSy11B --- .ai.md | 27 +- API-CONTRACT.md | 32 +++ scripts/_applied-date-confirm.mjs | 97 ++++++++ scripts/calendar-sync.mjs | 47 +++- tools/test-applied-date-confirm.mjs | 367 ++++++++++++++++++++++++++++ 5 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 scripts/_applied-date-confirm.mjs create mode 100644 tools/test-applied-date-confirm.mjs diff --git a/.ai.md b/.ai.md index 4b82d03..11f121c 100644 --- a/.ai.md +++ b/.ai.md @@ -63,6 +63,30 @@ by both files, mirroring the structure guard's session-scoped state the flag to either class instance. Tested at `tools/test-realtime-date-signal.mjs`. +### Applied-date confirmation (FM-SYNC-CONFIRMED-DATE) + +Pairs with a parallel Chronicle-side change (C-SYNC-APPLIED-BEACON) that lets +Chronicle's sync chip distinguish what Foundry *saw* (the `GET +/calendar/date` beacon) from what it actually *applied* to the local +calendar module. `calendar-sync.mjs`'s `_setLocalDate` — the single choke +point both apply paths funnel through (the poll path, `onInitialSync`'s +`GET /calendar` fetch on connect/reconnect, and the WebSocket-driven path, +`_onChronicaleDateAdvanced` on `calendar.date.advanced`) — now returns a +`Promise` reporting whether a real local calendar-module setter ran +without throwing (Calendaria `setDateTime`/legacy `setDate`, or SimpleCalendar +`setDate`, now awaited). Neither calendar module's setter returns an +inspectable success signal, so "no throw" is the strongest available proxy — +flagged as a stop-and-flag rather than invented. Both call sites POST +`scripts/_applied-date-confirm.mjs`'s `confirmAppliedDate(api, {year, month, +day})` → `POST /calendar/date/confirm` ONLY when `_setLocalDate` resolves +`true` — never on a bare fetch, never on apply failure. The confirm call runs +after `_setLocalDate`'s `_syncDepth` guard has already unwound (it only sends +an outbound POST to Chronicle; it triggers no Foundry hooks, so it cannot +re-enter the push handlers' `_syncing` checks). A 404/405 (older Chronicle +without the endpoint) is tolerated via a session-singleton debug-log-once, +mirroring the RC-4 guard's shared-singleton pattern above. Tested at +`tools/test-applied-date-confirm.mjs`. + ## Files | File | Purpose | @@ -92,6 +116,7 @@ the flag to either class instance. Tested at | `tools/test-actor-sync-pc-claim.mjs` | Structural + behavioral tests for PC-CLAIM-4 addon-awareness. Verifies SyncManager._fetchAddonState / isPlayerClaimingEnabled, ActorSync._resolvePcSubTypeId / _maybeShowClaimingHint, entity_type_id selection ternary, and the _claimHintShown once-per-session guard. Run: `node --test tools/test-actor-sync-pc-claim.mjs`. | | `scripts/calendar-sync.mjs` | Adapter pattern for Calendaria and SimpleCalendar. 0-indexed↔1-indexed conversion | | `scripts/_realtime-date-guard.mjs` | Pure/session-singleton helper gating `PUT /calendar/date` when Chronicle's `tracks_real_time` is set (RC-4). `shouldSkipDatePush(api)` does the fetch-before-push `GET /calendar/date` check; `isRealTimeRejection(err)` classifies a 422 backstop; `notifyRealTimePushPaused()` shows the once-per-session GM notice. Shared by `calendar-sync.mjs` and `sync-dashboard.mjs`. Unit-tested at `tools/test-realtime-date-signal.mjs`. | +| `scripts/_applied-date-confirm.mjs` | FM-SYNC-CONFIRMED-DATE: `confirmAppliedDate(api, {year,month,day})` POSTs `/calendar/date/confirm` (optional endpoint, Chronicle ≥ applied-beacon release) after `calendar-sync.mjs`'s `_setLocalDate` reports a real local apply. `isConfirmNotSupported(err)` classifies a 404/405 (older Chronicle); `notifyConfirmNotSupportedOnce()` debug-logs that tolerance once per session. Never throws — a confirm failure is swallowed so it can't surface as a sync error. Unit-tested at `tools/test-applied-date-confirm.mjs`. | | `scripts/shop-widget.mjs` | Shop window (Application v1). Context menu, drag-to-character-sheet, real-time updates | | `scripts/actor-sync.mjs` | Actor ↔ character entity bidirectional sync. System adapter loading, hook registration. PC-CLAIM-4: addon detection via `isPlayerClaimingEnabled()` (SyncManager), PC sub-type routing (`_resolvePcSubTypeId` matches `preset_category=player_character` or `slug=player-character`), `owner_user_id` gated on addon state, `_maybeShowClaimingHint()` surfaces a one-time advisory when the addon is off but player-owned actors exist. | | `scripts/item-sync.mjs` | Actor inventory ↔ Chronicle "Has Item" relations. System data sync (quantity, equipped) via relation metadata | @@ -124,7 +149,7 @@ The table below summarizes which module uses which API area: | Relations | `GET /entities/:id/relations`, `POST/PUT/DELETE .../relations/*` | `item-sync.mjs`, `shop-widget.mjs` | | Maps | `GET /maps` | `map-sync.mjs` | | Map Markers | `GET/POST/PUT/DELETE /maps/:id/markers/*` | `map-sync.mjs` | -| Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push, RC-4), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs` | +| Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push, RC-4), `POST /calendar/date/confirm` (optional, applied-beacon, FM-SYNC-CONFIRMED-DATE), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs`, `_applied-date-confirm.mjs` | | Calendar Events | `GET/POST/PUT/DELETE /calendar/events/*` | `calendar-sync.mjs` | | Calendar Sub-Resources | `GET/PUT /calendar/{seasons,moons,eras,event-categories,cycles,festivals,weather}` | Not yet used | | Calendar Structure | `GET /calendar/structure`, `PUT /calendar/{settings,months,weekdays}`, `GET /calendar/export`, `POST /calendar/import` | Not yet used | diff --git a/API-CONTRACT.md b/API-CONTRACT.md index 08f95ed..af608dd 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -689,6 +689,38 @@ a retryable sync error. { "year": 1492, "month": 3, "day": 1, "hour": 8, "minute": 0 } ``` +#### POST /calendar/date/confirm +**Optional — Chronicle ≥ the applied-beacon release (C-SYNC-APPLIED-BEACON).** +Confirms that Foundry successfully *applied* a date pulled from Chronicle to +the active local calendar module (Calendaria or SimpleCalendar), as opposed +to merely having fetched/seen it. Chronicle's sync chip uses this to +distinguish "Foundry SAW this date" (the `GET /calendar/date` beacon) from +"Foundry APPLIED this date." + +**Used by:** `calendar-sync.mjs` → `_confirmAppliedDate` via +`scripts/_applied-date-confirm.mjs`, called from both the poll apply path +(`onInitialSync`) and the WebSocket-driven apply path +(`_onChronicaleDateAdvanced`, `calendar.date.advanced`) — **only** after the +local calendar-module setter (`_setLocalDate`) reports it actually ran +without throwing. Never sent on a bare fetch/pull, and never on an apply +failure. An already-current date that still round-trips through a real +setter call counts as applied and is confirmed. + +**Request:** +```json +{ "year": 1492, "month": 3, "day": 1 } +``` + +**Response:** `204 No Content` + +**Graceful degradation:** a Chronicle deployment that predates this endpoint +returns 404 (route doesn't exist) or 405 (method not recognized). The module +tolerates both silently — one `console.debug` per session, no retries — so +it keeps working unchanged against pre-upgrade Chronicle servers. Any other +failure (network, 5xx) is debug-logged and swallowed; a missed confirmation +only leaves Chronicle's applied-beacon stale, it never blocks or retries +sync. See `scripts/_applied-date-confirm.mjs::isConfirmNotSupported`. + #### POST /calendar/advance Advances the calendar by N days (1-3650). diff --git a/scripts/_applied-date-confirm.mjs b/scripts/_applied-date-confirm.mjs new file mode 100644 index 0000000..6a6d562 --- /dev/null +++ b/scripts/_applied-date-confirm.mjs @@ -0,0 +1,97 @@ +/** + * Chronicle Sync — applied-date confirmation (FM-SYNC-CONFIRMED-DATE). + * + * Pairs with a parallel Chronicle-side change (C-SYNC-APPLIED-BEACON). + * Chronicle's sync chip distinguishes what Foundry SAW (the #548 beacon, + * recorded server-side on every `GET /calendar/date`) from what it actually + * APPLIED to the active Foundry calendar module. This module owns the + * Foundry-side half: `POST /calendar/date/confirm` with the applied + * `{year, month, day}`. + * + * The endpoint is OPTIONAL — only present on Chronicle deployments that have + * shipped the applied-beacon release. A 404 (route doesn't exist) or 405 + * (method not allowed — an older router that doesn't recognize the path) + * means an un-upgraded Chronicle; that is tolerated silently (one debug log + * per session, no retries) so this module keeps working unchanged against + * pre-upgrade servers. Any other failure is logged and swallowed by the + * caller — a missed confirmation only leaves Chronicle's "applied" beacon + * stale, it never blocks or retries sync. + */ + +/** + * Session-scoped "not supported" log state. A module-level singleton + * (mirrors `_realtime-date-guard.mjs`'s `state.noticeShown`) so every call + * site sharing this helper logs the tolerance exactly once per session + * instead of once per apply. + */ +const state = { + notSupportedLogged: false, +}; + +/** + * Reset session state. Test-only. + */ +export function _resetAppliedDateConfirmForTests() { + state.notSupportedLogged = false; +} + +/** + * Classify a thrown api-client error as "the confirm endpoint isn't present + * on this Chronicle deployment" (404 route-not-found, or 405 method-not- + * allowed). Mirrors `_realtime-date-guard.mjs`'s `isRealTimeRejection`: + * prefer an explicit numeric `err.status`, else parse the api-client's + * authoritative "Chronicle API error :" prefix — never key on a + * bare digit run in the message body. + * @param {{status?: number, message?: string}|null|undefined} err + * @returns {boolean} + */ +export function isConfirmNotSupported(err) { + const msg = String(err?.message || ''); + const status = (typeof err?.status === 'number' && err.status) + || Number(msg.match(/Chronicle API error (\d{3})\b/)?.[1]) + || 0; + return status === 404 || status === 405; +} + +/** + * Log the "confirm endpoint not supported" tolerance once per session. + * Deliberately `console.debug` only (never `ui.notifications`) — an + * un-upgraded Chronicle is an expected, non-actionable condition for the + * GM, not an error. + */ +export function notifyConfirmNotSupportedOnce() { + if (state.notSupportedLogged) return; + state.notSupportedLogged = true; + console.debug( + 'Chronicle: /calendar/date/confirm is not available on this Chronicle deployment ' + + '(older server, applied-beacon release not yet installed) — skipping applied-date confirmation.', + ); +} + +/** + * POST the applied date to Chronicle. Callers must invoke this ONLY after a + * real, successful local apply — never speculatively, never on a bare + * fetch/pull. Best-effort and non-throwing: a 404/405 is tolerated via + * `notifyConfirmNotSupportedOnce`; any other failure is debug-logged and + * swallowed so a confirmation hiccup never surfaces as a sync error or + * blocks the (already-completed) apply. + * @param {import('./api-client.mjs').ChronicleAPI} api + * @param {{year:number, month:number, day:number}} date + * @returns {Promise} + */ +export async function confirmAppliedDate(api, date) { + if (!api || !date) return; + try { + await api.post('/calendar/date/confirm', { + year: date.year, + month: date.month, + day: date.day, + }); + } catch (err) { + if (isConfirmNotSupported(err)) { + notifyConfirmNotSupportedOnce(); + return; + } + console.debug('Chronicle: Failed to confirm applied date', err?.message); + } +} diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index a31a5d9..3a26c9e 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -22,6 +22,7 @@ import { getSetting, getCalendarSyncExclusions } from './settings.mjs'; import { FLAG_SCOPE } from './constants.mjs'; import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs'; +import { confirmAppliedDate } from './_applied-date-confirm.mjs'; /** * Canonical wire-visibility values per the calendar-sync wire contract @@ -460,13 +461,24 @@ export class CalendarSync { } // Sync the current date from Chronicle to the Foundry calendar module. - await this._setLocalDate({ + // This is the "poll" apply path (a fetch of GET /calendar on connect/ + // reconnect, as opposed to the WebSocket-driven _onChronicaleDateAdvanced + // below) — confirm back to Chronicle only when _setLocalDate reports a + // real apply (FM-SYNC-CONFIRMED-DATE). + const applied = await this._setLocalDate({ year: this._chronicleCalendar.current_year, month: this._chronicleCalendar.current_month, day: this._chronicleCalendar.current_day, hour: this._chronicleCalendar.current_hour, minute: this._chronicleCalendar.current_minute, }); + if (applied) { + await confirmAppliedDate(this._api, { + year: this._chronicleCalendar.current_year, + month: this._chronicleCalendar.current_month, + day: this._chronicleCalendar.current_day, + }); + } // Sync Chronicle calendar events to Calendaria notes (if using Calendaria). if (this._calendarModule === 'calendaria') { @@ -616,13 +628,19 @@ export class CalendarSync { // --- Chronicle → Foundry --- /** - * Update the local Foundry calendar date from Chronicle. + * Update the local Foundry calendar date from Chronicle. This is the + * WebSocket-driven apply path (`calendar.date.advanced`) — confirm back to + * Chronicle only when `_setLocalDate` reports a real apply + * (FM-SYNC-CONFIRMED-DATE). * @param {object} data - { year, month, day, hour, minute } * @private */ async _onChronicaleDateAdvanced(data) { if (!data) return; - await this._setLocalDate(data); + const applied = await this._setLocalDate(data); + if (applied) { + await confirmAppliedDate(this._api, { year: data.year, month: data.month, day: data.day }); + } } /** @@ -1144,11 +1162,24 @@ export class CalendarSync { /** * Set the date on the active Foundry calendar module. * Uses CALENDARIA.api.setDateTime() when available for full hour/minute support. + * + * "Successfully applied" (FM-SYNC-CONFIRMED-DATE): the code offers no ack + * from either calendar module's date setter beyond "the call didn't + * throw" — neither Calendaria's `setDateTime`/`setDate` nor + * SimpleCalendar's `setDate` returns a result we can inspect for success. + * So the return value here is a best-effort signal: `true` means a real + * setter for the detected module was found and invoked without throwing; + * `false` means either no setter was available (module detected but its + * API surface wasn't there — a no-op, nothing was applied) or the setter + * threw (already logged below). Flagged per the dispatch's stop-and-flag: + * there is no stronger "it actually took effect" hook to key off. * @param {object} data - { year, month, day, hour, minute } + * @returns {Promise} true if a local setter was invoked and did not throw. * @private */ async _setLocalDate(data) { this._syncDepth++; + let applied = false; try { if (this._calendarModule === 'calendaria') { if (this._hasModernCalendariaApi) { @@ -1160,6 +1191,7 @@ export class CalendarSync { hour: data.hour ?? 0, minute: data.minute ?? 0, }); + applied = true; } else if (game.Calendaria?.setDate) { // Legacy Calendaria: setDate only supports date (no time). await game.Calendaria.setDate({ @@ -1167,11 +1199,15 @@ export class CalendarSync { month: data.month, day: data.day, }); + applied = true; } } else if (this._calendarModule === 'simple-calendar') { const sc = SimpleCalendar?.api; if (sc?.setDate) { - sc.setDate({ + // Awaited (unlike the pre-FM-SYNC-CONFIRMED-DATE code) so a + // promise-returning setDate is actually settled before this method + // reports success to its callers. + await sc.setDate({ year: data.year, // SimpleCalendar months are 0-indexed. month: (data.month || 1) - 1, @@ -1180,13 +1216,16 @@ export class CalendarSync { minute: data.minute || 0, seconds: 0, }); + applied = true; } } } catch (err) { console.error('Chronicle: Failed to set local calendar date', err); + applied = false; } finally { this._syncDepth--; } + return applied; } /** diff --git a/tools/test-applied-date-confirm.mjs b/tools/test-applied-date-confirm.mjs new file mode 100644 index 0000000..baea16f --- /dev/null +++ b/tools/test-applied-date-confirm.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node +/** + * test-applied-date-confirm.mjs — FM-SYNC-CONFIRMED-DATE regression tests. + * + * Run: node --test tools/test-applied-date-confirm.mjs + * + * Pins the Foundry-side half of the applied-date upgrade (pairs with a + * parallel Chronicle-side change, C-SYNC-APPLIED-BEACON): + * 1. `_applied-date-confirm.mjs`'s pure helpers: 404/405 classification, + * the once-per-session debug-log tolerance, and confirmAppliedDate's + * request shape + non-throwing swallow behavior. + * 2. `calendar-sync.mjs::_setLocalDate` reports whether it actually + * invoked a local calendar-module setter (Calendaria modern/legacy, + * SimpleCalendar) without throwing. + * 3. Both apply call sites — the poll path (`onInitialSync`) and the + * WebSocket-driven path (`_onChronicaleDateAdvanced`) — confirm ONLY on + * real apply-success: never on a bare fetch, never on apply failure or + * a no-op (no calendar-module setter available). + * 4. The confirm POST does not re-enter the `_syncDepth` reentrancy guard. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Stub the Foundry globals calendar-sync.mjs touches at module-load time +// (mirrors tools/test-calendar-backcatalog-fix.mjs / test-realtime-date-signal.mjs). +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 { + isConfirmNotSupported, + notifyConfirmNotSupportedOnce, + confirmAppliedDate, + _resetAppliedDateConfirmForTests, +} = await import('../scripts/_applied-date-confirm.mjs'); +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); + +function makeCalendarSync(overrides) { + return Object.assign( + Object.create(CalendarSync.prototype), + { _hasModernCalendariaApi: true, _syncDepth: 0, _calendarSyncDisabled: false, _isActiveCalendarExcluded: () => false }, + overrides, + ); +} + +function makeApi({ postImpl, getImpl } = {}) { + const posts = []; + const gets = []; + return { + posts, + gets, + async post(path, body) { + posts.push({ path, body }); + if (postImpl) return postImpl(path, body); + return null; + }, + async get(path) { + gets.push(path); + if (getImpl) return getImpl(path); + return null; + }, + }; +} + +test.beforeEach(() => { + _resetAppliedDateConfirmForTests(); +}); + +// ── isConfirmNotSupported ──────────────────────────────────────────────── + +test('isConfirmNotSupported: 404 via explicit err.status', () => { + assert.equal(isConfirmNotSupported({ status: 404, message: 'whatever' }), true); +}); + +test('isConfirmNotSupported: 405 via explicit err.status', () => { + assert.equal(isConfirmNotSupported({ status: 405, message: 'whatever' }), true); +}); + +test('isConfirmNotSupported: 404/405 via the api-client "Chronicle API error :" message prefix', () => { + assert.equal(isConfirmNotSupported(new Error('Chronicle API error 404: not found')), true); + assert.equal(isConfirmNotSupported(new Error('Chronicle API error 405: method not allowed')), true); +}); + +test('isConfirmNotSupported: other statuses (422, 409, 500) are not the not-supported case', () => { + assert.equal(isConfirmNotSupported(new Error('Chronicle API error 422: real-time calendar')), false); + assert.equal(isConfirmNotSupported({ status: 409, message: 'conflict' }), false); + assert.equal(isConfirmNotSupported(new Error('Chronicle API error 500: boom')), false); +}); + +test('isConfirmNotSupported: a bare "404" substring in an unrelated message does not misclassify', () => { + assert.equal(isConfirmNotSupported(new Error('Entity named "Room 404" not found')), false); +}); + +test('isConfirmNotSupported: null/undefined error does not throw', () => { + assert.equal(isConfirmNotSupported(null), false); + assert.equal(isConfirmNotSupported(undefined), false); +}); + +// ── notifyConfirmNotSupportedOnce ──────────────────────────────────────── + +test('notifyConfirmNotSupportedOnce: logs exactly once per session', () => { + let debugCount = 0; + const original = console.debug; + console.debug = () => { debugCount += 1; }; + try { + notifyConfirmNotSupportedOnce(); + notifyConfirmNotSupportedOnce(); + notifyConfirmNotSupportedOnce(); + } finally { + console.debug = original; + } + assert.equal(debugCount, 1); +}); + +// ── confirmAppliedDate ─────────────────────────────────────────────────── + +test('confirmAppliedDate: POSTs the applied date to /calendar/date/confirm', async () => { + const api = makeApi(); + await confirmAppliedDate(api, { year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); + assert.deepEqual(api.posts, [ + { path: '/calendar/date/confirm', body: { year: 1492, month: 3, day: 1 } }, + ], 'body carries only year/month/day, extra fields dropped'); +}); + +test('confirmAppliedDate: a 404 is tolerated silently (single debug log, no throw)', async () => { + const api = makeApi({ postImpl: () => { throw new Error('Chronicle API error 404: not found'); } }); + let debugCount = 0; + const original = console.debug; + console.debug = () => { debugCount += 1; }; + try { + await assert.doesNotReject(confirmAppliedDate(api, { year: 1492, month: 3, day: 1 })); + } finally { + console.debug = original; + } + assert.equal(debugCount, 1); +}); + +test('confirmAppliedDate: a 405 is tolerated the same way as a 404', async () => { + const api = makeApi({ postImpl: () => { throw new Error('Chronicle API error 405: method not allowed'); } }); + await assert.doesNotReject(confirmAppliedDate(api, { year: 1492, month: 3, day: 1 })); +}); + +test('confirmAppliedDate: no retry storm — a repeated 404 across calls still logs once total', async () => { + const api = makeApi({ postImpl: () => { throw new Error('Chronicle API error 404: not found'); } }); + let debugCount = 0; + const original = console.debug; + console.debug = () => { debugCount += 1; }; + try { + await confirmAppliedDate(api, { year: 1492, month: 3, day: 1 }); + await confirmAppliedDate(api, { year: 1492, month: 3, day: 2 }); + } finally { + console.debug = original; + } + assert.equal(debugCount, 1); + assert.equal(api.posts.length, 2, 'each call still attempts once — no internal retry loop'); +}); + +test('confirmAppliedDate: a genuine non-404/405 failure is swallowed, not thrown', async () => { + const api = makeApi({ postImpl: () => { throw new Error('Chronicle API error 500: boom'); } }); + await assert.doesNotReject(confirmAppliedDate(api, { year: 1492, month: 3, day: 1 })); + assert.equal(api.posts.length, 1); +}); + +test('confirmAppliedDate: null api or date is a no-op, never throws', async () => { + await assert.doesNotReject(confirmAppliedDate(null, { year: 1492, month: 3, day: 1 })); + await assert.doesNotReject(confirmAppliedDate(makeApi(), null)); +}); + +// ── calendar-sync.mjs: _setLocalDate reports whether a real apply happened ── + +test('_setLocalDate: Calendaria modern API — setDateTime invoked, resolves true', async () => { + let called = null; + globalThis.CALENDARIA = { api: { setDateTime: async (d) => { called = d; } } }; + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true }); + const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); + assert.equal(applied, true); + assert.deepEqual(called, { year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); +}); + +test('_setLocalDate: Calendaria legacy API — setDate invoked, resolves true', async () => { + globalThis.game.Calendaria = { setDate: async () => {} }; + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: false }); + const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); + assert.equal(applied, true); + delete globalThis.game.Calendaria; +}); + +test('_setLocalDate: Calendaria detected but neither API surface present — no-op, resolves false', async () => { + globalThis.CALENDARIA = undefined; + delete globalThis.game.Calendaria; + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: false }); + const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); + assert.equal(applied, false); +}); + +test('_setLocalDate: Calendaria setDateTime throws — logs the error, resolves false', async () => { + globalThis.CALENDARIA = { api: { setDateTime: async () => { throw new Error('boom'); } } }; + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true }); + const original = console.error; + let logged = null; + console.error = (...args) => { logged = args; }; + let applied; + try { + applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); + } finally { + console.error = original; + } + assert.equal(applied, false); + assert.ok(logged); +}); + +test('_setLocalDate: SimpleCalendar — setDate invoked (and awaited), resolves true', async () => { + let called = null; + globalThis.SimpleCalendar = { api: { setDate: async (d) => { called = d; } } }; + const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); + const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); + assert.equal(applied, true); + assert.deepEqual(called, { year: 1492, month: 2, day: 0, hour: 8, minute: 0, seconds: 0 }); +}); + +test('_setLocalDate: SimpleCalendar detected but no setDate — no-op, resolves false', async () => { + globalThis.SimpleCalendar = { api: {} }; + const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); + const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); + assert.equal(applied, false); +}); + +test('_setLocalDate: no calendar module detected at all — resolves false', async () => { + const cs = makeCalendarSync({ _calendarModule: null }); + const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); + assert.equal(applied, false); +}); + +test('_setLocalDate: the _syncDepth reentrancy guard still wraps the call (increments then decrements)', async () => { + globalThis.CALENDARIA = { + api: { + setDateTime: async function () { + assert.equal(this === undefined ? cs._syncDepth : cs._syncDepth, 1, '_syncDepth is 1 while the setter runs'); + }, + }, + }; + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _syncDepth: 0 }); + assert.equal(cs._syncDepth, 0); + await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); + assert.equal(cs._syncDepth, 0, '_syncDepth unwound after the call'); +}); + +// ── _onChronicaleDateAdvanced (WebSocket-driven apply path) ──────────────── + +test('_onChronicaleDateAdvanced: confirms ONLY after a real apply', async () => { + globalThis.CALENDARIA = { api: { setDateTime: async () => {} } }; + const api = makeApi(); + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _api: api }); + await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); + assert.deepEqual(api.posts, [ + { path: '/calendar/date/confirm', body: { year: 1492, month: 3, day: 1 } }, + ]); +}); + +test('_onChronicaleDateAdvanced: does NOT confirm when apply is a no-op (no calendar-module setter)', async () => { + globalThis.CALENDARIA = undefined; + delete globalThis.game.Calendaria; + const api = makeApi(); + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: false, _api: api }); + await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1 }); + assert.equal(api.posts.length, 0); +}); + +test('_onChronicaleDateAdvanced: does NOT confirm when the local setter throws (apply failure)', async () => { + globalThis.CALENDARIA = { api: { setDateTime: async () => { throw new Error('boom'); } } }; + const api = makeApi(); + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _api: api }); + const original = console.error; + console.error = () => {}; + try { + await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1 }); + } finally { + console.error = original; + } + assert.equal(api.posts.length, 0); +}); + +test('_onChronicaleDateAdvanced: null data is a no-op (no apply, no confirm)', async () => { + const api = makeApi(); + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _api: api }); + await cs._onChronicaleDateAdvanced(null); + assert.equal(api.posts.length, 0); +}); + +test('_onChronicaleDateAdvanced: confirm never re-enters the _syncDepth reentrancy guard', async () => { + globalThis.CALENDARIA = { api: { setDateTime: async () => {} } }; + const depths = []; + const api = makeApi({ + postImpl(path, body) { + // The confirm POST fires strictly after _setLocalDate's finally block + // has unwound the guard back to 0 — a confirm call that re-entered the + // guard (or ran DURING the apply) would observe a nonzero depth here. + depths.push(cs._syncDepth); + return null; + }, + }); + const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _api: api, _syncDepth: 0 }); + await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1 }); + assert.deepEqual(depths, [0]); + assert.equal(cs._syncDepth, 0); +}); + +// ── onInitialSync (poll apply path) ───────────────────────────────────────── +// Uses the simple-calendar module so the Calendaria-only structure-mismatch +// guard (B-R2) never engages — out of scope here, covered by +// tools/test-calendar-sync-hotfix.mjs. + +function makeInitialSyncHarness({ chronicleCalendar, setDateImpl, postImpl } = {}) { + const api = makeApi({ + getImpl: (path) => (path === '/calendar' ? chronicleCalendar : null), + postImpl, + }); + globalThis.SimpleCalendar = { api: { setDate: setDateImpl ?? (async () => {}) } }; + const cs = makeCalendarSync({ + _calendarModule: 'simple-calendar', + _api: api, + getSetting: undefined, + }); + return { cs, api }; +} + +test('onInitialSync: confirms the applied date after a successful poll-path apply', async () => { + const chronicleCalendar = { + current_year: 1492, current_month: 3, current_day: 1, current_hour: 8, current_minute: 0, + months: [], + }; + const { cs, api } = makeInitialSyncHarness({ chronicleCalendar }); + // getSetting('syncCalendar') is read at the top of onInitialSync via the + // module-level import; stub it through globalThis.game.settings.get. + globalThis.game.settings.get = () => true; + await cs.onInitialSync(); + assert.deepEqual(api.posts, [ + { path: '/calendar/date/confirm', body: { year: 1492, month: 3, day: 1 } }, + ]); +}); + +test('onInitialSync: does NOT confirm when the poll-path apply is a no-op', async () => { + const chronicleCalendar = { + current_year: 1492, current_month: 3, current_day: 1, current_hour: 8, current_minute: 0, + months: [], + }; + const { cs, api } = makeInitialSyncHarness({ chronicleCalendar, setDateImpl: undefined }); + globalThis.SimpleCalendar = { api: {} }; // no setDate — apply is a no-op + globalThis.game.settings.get = () => true; + await cs.onInitialSync(); + assert.equal(api.posts.length, 0); +}); + +test('onInitialSync: no Chronicle calendar configured — no apply attempted, no confirm', async () => { + const { cs, api } = makeInitialSyncHarness({ chronicleCalendar: null }); + globalThis.game.settings.get = () => true; + await cs.onInitialSync(); + assert.equal(api.posts.length, 0); +});