Files
msd-core/tests/fix-2136-clock-local-today.test.cjs
Tom Boucher 51b6e3c35c 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).
2026-07-12 15:34:59 -04:00

121 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)');
});
});