diff --git a/tests/fix-2136-clock-local-today.test.cjs b/tests/fix-2136-clock-local-today.test.cjs new file mode 100644 index 000000000..d824b229a --- /dev/null +++ b/tests/fix-2136-clock-local-today.test.cjs @@ -0,0 +1,120 @@ +'use strict'; + +/** + * #2136 — operator-facing date-only fields must use the HOST-LOCAL calendar day, + * not the UTC calendar day. + * + * `clock.today()` derived the day by slicing a UTC ISO instant + * (`nowIso().split('T')[0]`), so in any negative-UTC-offset zone during UTC's + * early hours `last_activity` named a day the operator had not reached yet — a + * day AHEAD of `last_updated`'s local date, written by the same call. + * + * The fix adds `clock.localToday()` (host-local calendar day, honoring the same + * GSD_NOW_MS pin) and routes operator-facing date-only fields through it. These + * tests prove: + * (1) realClock.localToday() returns the local day under a pinned instant + TZ; + * (2) realClock.today() is unchanged (UTC) — internal/cosmetic stamps stay UTC; + * (3) the fake clock mirrors localToday(); + * (4) the `last_activity` field is stamped from localToday, not today + * (a split clock where the two differ proves the wiring). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const CLOCK_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'clock.cjs'); +const STATE_TRANSITION_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'state-transition.cjs'); +const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs'); +const { makeFakeClock } = require('./helpers/clock.cjs'); + +// 2020-06-15T02:00:00.000Z. Under America/Chicago (UTC−5 in June) this instant +// is 2020-06-14 21:00 local — the local calendar day is 2020-06-14 while the UTC +// calendar day is 2020-06-15. This is the issue's exact repro instant. +const PINNED_MS = '1592186400000'; + +/** + * Run a one-liner in a FRESH subprocess so TZ is set at process start (Node + * caches timezone; runtime mutation of process.env.TZ is unreliable across + * versions). GSD_TEST_MODE + GSD_NOW_MS pin realClock via the documented seam. + */ +function clockInSubprocess(expr, env) { + return execFileSync(process.execPath, ['-e', expr], { + env, + encoding: 'utf8', + }).trim(); +} + +describe('#2136 realClock.localToday() — host-local calendar day', () => { + test('returns the LOCAL calendar day under a negative-UTC-offset zone', () => { + const out = clockInSubprocess( + `const {realClock}=require(${JSON.stringify(CLOCK_CJS)});process.stdout.write(realClock.localToday());`, + { PATH: process.env.PATH, GSD_TEST_MODE: '1', GSD_NOW_MS: PINNED_MS, TZ: 'America/Chicago' }, + ); + assert.strictEqual(out, '2020-06-14', + 'localToday() must be the local calendar day (2020-06-14 in Chicago), not the UTC day (2020-06-15)'); + }); + + test('realClock.today() is unchanged (UTC calendar day) — internal stamps stay UTC', () => { + const out = clockInSubprocess( + `const {realClock}=require(${JSON.stringify(CLOCK_CJS)});process.stdout.write(realClock.today());`, + { PATH: process.env.PATH, GSD_TEST_MODE: '1', GSD_NOW_MS: PINNED_MS, TZ: 'America/Chicago' }, + ); + assert.strictEqual(out, '2020-06-15', + 'today() must remain the UTC calendar day; only operator-facing fields moved to localToday()'); + }); + + test('localToday === today on a UTC host (no spurious divergence)', () => { + const out = clockInSubprocess( + `const {realClock}=require(${JSON.stringify(CLOCK_CJS)});process.stdout.write(realClock.localToday()+'|'+realClock.today());`, + { PATH: process.env.PATH, GSD_TEST_MODE: '1', GSD_NOW_MS: PINNED_MS, TZ: 'UTC' }, + ); + const [local, utc] = out.split('|'); + assert.strictEqual(local, '2020-06-15'); + assert.strictEqual(utc, '2020-06-15'); + }); +}); + +describe('#2136 makeFakeClock mirrors localToday()', () => { + test('fake clock localToday() derives the local day from the pinned epoch', () => { + const clock = makeFakeClock(Number(PINNED_MS)); + // The fake honors the host TZ of the test process; assert it returns a + // YYYY-MM-DD string derived from local Date methods (structure + determinism + // under the pinned epoch), mirroring realClock.localToday(). + assert.ok(/^\d{4}-\d{2}-\d{2}$/.test(clock.localToday()), + 'fake localToday() must return a YYYY-MM-DD string'); + assert.strictEqual(typeof clock.localToday, 'function', + 'makeFakeClock must expose localToday so it stays a drop-in Clock substitute'); + }); +}); + +describe('#2136 last_activity is stamped from localToday, not today', () => { + // A clock where the UTC day and the local day DIFFER. If the transition core + // still consulted today(), Last Activity would read the UTC value; the fix + // routes it through localToday(). + const splitClock = Object.freeze({ + today: () => '2020-06-15', + localToday: () => '2020-06-14', + nowIso: () => '2020-06-15T02:00:00.000Z', + }); + + test('sync transition writes the LOCAL day into Last Activity', () => { + const { transitionCore } = require(STATE_TRANSITION_CJS); + const input = [ + '# Project State', + '', + '**Total Plans in Phase:** 2', + '**Last Activity:** 2020-06-10', + '**Progress:** [████░░░░░░] 40%', + '', + ].join('\n'); + const result = transitionCore( + input, + { kind: 'sync', totalPlansInPhase: 5, percent: 60 }, + { clock: splitClock, progressProvider: () => null }, + ); + assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2020-06-14', + 'Last Activity must use localToday() (2020-06-14), not today() (2020-06-15)'); + }); +}); diff --git a/tests/helpers/clock.cjs b/tests/helpers/clock.cjs index 87146c8ea..56a3c93ee 100644 --- a/tests/helpers/clock.cjs +++ b/tests/helpers/clock.cjs @@ -90,6 +90,20 @@ function makeFakeClock(startMs) { return new Date(_now).toISOString().split('T')[0]; }, + /** + * Return the virtual date as a YYYY-MM-DD string in the HOST-LOCAL calendar + * day. Mirrors realClock.localToday() so fake clocks are drop-in substitutes. + * (#2136: operator-facing date-only fields use the local calendar day.) + * + * @returns {string} e.g. "2020-06-14" (local), may differ from today() near UTC midnight + */ + localToday() { + const d = new Date(_now); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return d.getFullYear() + '-' + mm + '-' + dd; + }, + /** Array of ms values passed to sleep() in call order. */ get sleepCalls() { return _sleepCalls; diff --git a/tests/state-rebuild.test.cjs b/tests/state-rebuild.test.cjs index ba4492e85..d9ebb7b68 100644 --- a/tests/state-rebuild.test.cjs +++ b/tests/state-rebuild.test.cjs @@ -25,6 +25,7 @@ const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs'); const fixedClock = Object.freeze({ today: () => '2026-06-29', + localToday: () => '2026-06-29', nowIso: () => '2026-06-29T12:00:00.000Z', }); diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 349d2f703..71f3b090f 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -22,6 +22,7 @@ const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs'); const fixedClock = Object.freeze({ today: () => '2026-06-27', + localToday: () => '2026-06-27', nowIso: () => '2026-06-27T12:00:00.000Z', });