test(#2136): add localToday regression + mirror fake/inline clock stubs
Adds a dedicated regression (tests/fix-2136-clock-local-today.test.cjs): - realClock.localToday() returns the LOCAL calendar day under a pinned instant + TZ (America/Chicago → 2020-06-14, the issue's repro instant). - realClock.today() is unchanged (UTC) — internal/cosmetic stamps stay UTC. - localToday === today on a UTC host (no spurious divergence). - makeFakeClock mirrors localToday (drop-in Clock substitute). - field-level: a 'sync' transition with a split clock (today≠localToday) writes the LOCAL day into Last Activity, proving the wiring uses localToday. Mirrors localToday() in the fake clock helper and the inline fixedClock stubs in state-transition.test.cjs / state-rebuild.test.cjs (the Clock interface now requires it).
This commit is contained in:
120
tests/fix-2136-clock-local-today.test.cjs
Normal file
120
tests/fix-2136-clock-local-today.test.cjs
Normal file
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user