From e17fed54be97b18c5bff48d01dbe145f7b36cc73 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 17:43:28 -0600 Subject: [PATCH 01/17] Fix core time-distortion bug; Fix fake-timers breakage --- jest.config.js | 6 +++ src/ConstantTimeCompression.ts | 8 ++- src/ConstantTimeDilation.ts | 8 ++- test/Clock.test.ts | 77 ++++++++++++++-------------- test/ConstantTimeCompression.test.ts | 13 +++-- test/ConstantTimeDilation.test.ts | 18 +++++-- 6 files changed, 82 insertions(+), 48 deletions(-) diff --git a/jest.config.js b/jest.config.js index 6e894b3..e5bb557 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,12 @@ module.exports = { roots: ['/test'], testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'], + // Modern Node makes `globalThis.performance` non-configurable, which the fake-timers + // implementation bundled with Jest 28 cannot hijack (it throws "Cannot assign to read + // only property 'performance'"). We never fake `performance`, so exclude it. + fakeTimers: { + doNotFake: ['performance'], + }, transform: { '^.+\\.(ts|tsx)$': 'ts-jest', }, diff --git a/src/ConstantTimeCompression.ts b/src/ConstantTimeCompression.ts index 967c69c..d2b3225 100644 --- a/src/ConstantTimeCompression.ts +++ b/src/ConstantTimeCompression.ts @@ -1,6 +1,12 @@ import RelativeTimeDistortion from './RelativeTimeDistortion'; export default class TimeCompression extends RelativeTimeDistortion { + // Compression speeds the fake clock up: more relative time passes than reference time + // (relativeDuration > referenceDuration). `Clock` adds this return value to an offset + // that is itself added to the reference time, so what we return is the *offset delta* + // contributed by `numberOfMilliseconds` of reference time. Over a slice of real time + // `dt` the fake clock advances `dt * (relative / reference)`, so the offset (the gap + // between fake and real time) changes by `dt * (relative / reference - 1)`. distortTime(numberOfMilliseconds: number): number { - return numberOfMilliseconds * (this.referenceDurationInMillis / this.relativeDurationInMillis); + return numberOfMilliseconds * (this.relativeDurationInMillis / this.referenceDurationInMillis - 1); } } diff --git a/src/ConstantTimeDilation.ts b/src/ConstantTimeDilation.ts index e356926..3afc5d1 100644 --- a/src/ConstantTimeDilation.ts +++ b/src/ConstantTimeDilation.ts @@ -1,6 +1,12 @@ import RelativeTimeDistortion from './RelativeTimeDistortion'; export default class TimeDilation extends RelativeTimeDistortion { + // Dilation slows the fake clock down: less relative time passes than reference time + // (relativeDuration < referenceDuration). `Clock` adds this return value to an offset + // that is itself added to the reference time, so what we return is the *offset delta* + // contributed by `numberOfMilliseconds` of reference time. Over a slice of real time + // `dt` the fake clock advances `dt * (relative / reference)`, so the offset (the gap + // between fake and real time) changes by `dt * (relative / reference - 1)`. distortTime(numberOfMilliseconds: number): number { - return -numberOfMilliseconds * (this.relativeDurationInMillis / this.referenceDurationInMillis); + return numberOfMilliseconds * (this.relativeDurationInMillis / this.referenceDurationInMillis - 1); } } diff --git a/test/Clock.test.ts b/test/Clock.test.ts index e827e3b..ec4505d 100644 --- a/test/Clock.test.ts +++ b/test/Clock.test.ts @@ -33,50 +33,45 @@ describe(`Clock class`, () => { }); }); describe(`getRelativeTime`, () => { - it('documentation example', () => { + it('documentation example (matches the hour-by-hour table in the README)', () => { + // System time starts at the epoch (1970-01-01T00:00:00Z), i.e. midnight UTC, so the + // hourly checkpoints below line up with the wall-clock windows of the distortions. const clock = new Clock([ new ConstantTimeDilation( - { hour: 1 }, // start at 1am (system), - { - hours: 3, // Relative duration: 3 hours will appear to pass - }, - { - hours: 6, // Reference duration: It will take 6 hours, real-time for the 3 hour `dilationWindow` to appear to pass - }, + { hour: 1 }, // start at 1am (system) + { hours: 3 }, // Relative duration: 3 hours of fake time appear to pass... + { hours: 6 }, // Reference duration: ...over 6 real hours (clock runs at half speed) ), new ConstantTimeCompression( - { hour: 7 }, // start at 7 am, - { hours: 3 }, // Relative time: It will appear that 3 hours have passed - { - minutes: 90, // Reference time: but, in reality, only 90 minutes have passed - }, + { hour: 7 }, // start at 7am + { hours: 6 }, // Relative duration: 6 hours of fake time appear to pass... + { hours: 3 }, // Reference duration: ...over 3 real hours (clock runs at double speed) ), ]); const hour = 1000 * 60 * 60; - jest.advanceTimersByTime(hour); - expect(clock.relativeTimeInMillis).toEqual(hour); - expect(clock.referenceTimeInMillis).toEqual(hour); - - jest.advanceTimersByTime(hour); - - expect(clock.relativeTimeInMillis).toEqual(hour * 1.5); - expect(clock.referenceTimeInMillis).toEqual(hour * 2); - - jest.advanceTimersByTime(hour); - - expect(clock.relativeTimeInMillis).toEqual(hour * 2); - expect(clock.referenceTimeInMillis).toEqual(hour * 3); - - jest.advanceTimersByTime(hour); - - expect(clock.relativeTimeInMillis).toEqual(hour * 2.5); - expect(clock.referenceTimeInMillis).toEqual(hour * 4); - - jest.advanceTimersByTime(hour); - - expect(clock.relativeTimeInMillis).toEqual(hour * 3); - expect(clock.referenceTimeInMillis).toEqual(hour * 5); + // [real hours since midnight, expected fake hours] — copied straight from the README. + const table: Array<[number, number]> = [ + [1, 1], // before any distortion: 1-to-1 + [2, 1.5], // dilation @ half speed + [3, 2], + [4, 2.5], + [5, 3], + [6, 3.5], + [7, 4], // dilation window done: fake clock is 3 hours behind + [8, 6], // compression @ double speed begins catching up + [9, 8], + [10, 10], // **back in sync** + [11, 11], // 1-to-1 again + ]; + + // The clock must be polled as time advances so it can accumulate the offset; reading + // `relativeTimeInMillis` each hour does exactly that. + for (const [realHour, fakeHour] of table) { + jest.setSystemTime(realHour * hour); + expect(clock.relativeTimeInMillis).toEqual(fakeHour * hour); + expect(clock.referenceTimeInMillis).toEqual(realHour * hour); + } }); it('returns the relative time', () => { /* @@ -137,17 +132,21 @@ describe(`Clock class`, () => { jest.advanceTimersByTime(500); - expect(clock.relativeTimeInMillis).toEqual(3250); + // compression window (double-time): 500ms real => 1000ms fake + expect(clock.relativeTimeInMillis).toEqual(3500); expect(clock.referenceTimeInMillis).toEqual(3500); jest.advanceTimersByTime(500); - expect(clock.relativeTimeInMillis).toEqual(4000); + // end of compression window: dilation removed 500ms of fake time, compression added + // 1000ms, so the fake clock ends up 500ms ahead of real time + expect(clock.relativeTimeInMillis).toEqual(4500); expect(clock.referenceTimeInMillis).toEqual(4000); jest.advanceTimersByTime(2750); - expect(clock.relativeTimeInMillis).toEqual(6750); + // back to 1-to-1: the 500ms lead is carried forward unchanged + expect(clock.relativeTimeInMillis).toEqual(7250); expect(clock.referenceTimeInMillis).toEqual(6750); }); }); diff --git a/test/ConstantTimeCompression.test.ts b/test/ConstantTimeCompression.test.ts index 686a588..9d545f6 100644 --- a/test/ConstantTimeCompression.test.ts +++ b/test/ConstantTimeCompression.test.ts @@ -17,21 +17,26 @@ describe(`ConstantTimeCompression class`, () => { }); }); + // `distortTime(dt)` returns the *offset delta* the distortion contributes for `dt` + // milliseconds of real time — how far the fake clock moves relative to real time. describe(`distortTime`, () => { describe('w/ defaults', () => { - it(`effectively, a doubling`, () => { + it(`is a no-op when relative and reference durations are equal`, () => { + // referenceDuration defaults to relativeDuration, so time passes 1-to-1. const timewarp = new ConstantTimeCompression(start, end); const result = timewarp.distortTime(500); expect(typeof result).toBe('number'); - expect(result).toEqual(500); + expect(result).toEqual(0); }); }); describe('with `relativeDuration` specified', () => { - it(`performs a constant compression, based on the ratio of real-to-relative duration`, () => { + it(`speeds the clock up by the ratio of relative-to-reference duration`, () => { + // 10s of fake time per 5s of real time (double speed) => over 500ms real the fake + // clock advances 1000ms, i.e. it pulls 500ms ahead of real time. const timewarp = new ConstantTimeCompression({ second: 4 }, { milliseconds: 10000 }, { seconds: 5 }); const result = timewarp.distortTime(500); expect(typeof result).toBe('number'); - expect(result).toEqual(250); + expect(result).toEqual(500); }); }); }); diff --git a/test/ConstantTimeDilation.test.ts b/test/ConstantTimeDilation.test.ts index de9d3cd..45a9e58 100644 --- a/test/ConstantTimeDilation.test.ts +++ b/test/ConstantTimeDilation.test.ts @@ -17,22 +17,34 @@ describe(`ConstantTimeDilation class`, () => { }); }); + // `distortTime(dt)` returns the *offset delta* the distortion contributes for `dt` + // milliseconds of real time — how far the fake clock moves relative to real time. describe(`distortTime`, () => { describe('w/ defaults', () => { - it(`effectively, a pause`, () => { + it(`is a no-op when relative and reference durations are equal`, () => { + // referenceDuration defaults to relativeDuration, so time passes 1-to-1. const timewarp = new ConstantTimeDilation(start, end); const result = timewarp.distortTime(500); expect(typeof result).toBe('number'); - expect(result).toEqual(-500); + expect(result).toEqual(0); }); }); describe('with `relativeDuration` specified', () => { - it(`performs a constant dilation, based on the ratio of real-to-relative duration`, () => { + it(`slows the clock by the ratio of relative-to-reference duration`, () => { + // 1s of fake time per 2s of real time => over 500ms real the fake clock advances + // 250ms, i.e. it falls 250ms behind real time. const timewarp = new ConstantTimeDilation(start, { seconds: 1 }, end); const result = timewarp.distortTime(500); expect(typeof result).toBe('number'); expect(result).toEqual(-250); }); + it(`fully pauses the clock when no relative time passes`, () => { + // 0s of fake time per 2s of real time => the fake clock is frozen, so the offset + // must cancel the entire 500ms of real time. + const timewarp = new ConstantTimeDilation(start, { seconds: 0 }, end); + const result = timewarp.distortTime(500); + expect(result).toEqual(-500); + }); }); }); }); From 0c03bfc1515d239c3712eb1064e31510dfb85206 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 17:59:00 -0600 Subject: [PATCH 02/17] test: assert polling-frequency independence invariant Constant distortions are linear, so the accumulated offset must not depend on how finely the clock is polled. Poll the README example once vs. every minute across the same span and assert identical results. This property guards Clock's offset-accumulation/overlap-clamping math against whole classes of regressions. --- test/Clock.invariants.test.ts | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/Clock.invariants.test.ts diff --git a/test/Clock.invariants.test.ts b/test/Clock.invariants.test.ts new file mode 100644 index 0000000..b01fc77 --- /dev/null +++ b/test/Clock.invariants.test.ts @@ -0,0 +1,46 @@ +import { Clock, ConstantTimeCompression, ConstantTimeDilation } from '../src/index'; + +// Properties that must hold for any combination of constant distortions, regardless of how +// the clock happens to be polled. These guard the offset-accumulation math in `Clock` as a +// whole, rather than any single hand-computed value. +describe(`Clock invariants`, () => { + const hour = 1000 * 60 * 60; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(0)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // The README example: half-speed from 1am-7am, double-speed from 7am-10am, which nets out + // to zero distortion by 10am. + const buildDistortions = () => [ + new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 }), + new ConstantTimeCompression({ hour: 7 }, { hours: 6 }, { hours: 3 }), + ]; + + describe(`polling-frequency independence`, () => { + it(`reaches the same relative time whether polled once or every minute`, () => { + // Polled exactly once, after every window has elapsed. + const coarse = new Clock(buildDistortions()); + jest.setSystemTime(12 * hour); + const coarseResult = coarse.relativeTimeInMillis; + + // Polled every minute across the same span. + const fine = new Clock(buildDistortions()); + let fineResult = 0; + for (let t = 0; t <= 12 * hour; t += 60 * 1000) { + jest.setSystemTime(t); + fineResult = fine.relativeTimeInMillis; + } + + // Because every distortion is linear, the accumulated offset must not depend on how + // many slices the interval was broken into. + expect(fineResult).toEqual(coarseResult); + expect(fineResult).toEqual(12 * hour); // the distortions cancel out by noon + }); + }); +}); From 9c937da6eada88c693c4e438f1cf88a09c621c71 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:27:07 -0600 Subject: [PATCH 03/17] test: assert distortion order independence Distortion offsets are summed, so the order of the array passed to Clock must not affect the result. Walk a forward- and reverse-ordered clock through identical hourly checkpoints and assert they agree at every step. --- test/Clock.invariants.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/Clock.invariants.test.ts b/test/Clock.invariants.test.ts index b01fc77..84f4b72 100644 --- a/test/Clock.invariants.test.ts +++ b/test/Clock.invariants.test.ts @@ -43,4 +43,19 @@ describe(`Clock invariants`, () => { expect(fineResult).toEqual(12 * hour); // the distortions cancel out by noon }); }); + + describe(`order independence`, () => { + it(`produces the same relative time regardless of the order of the distortions`, () => { + const [dilation, compression] = buildDistortions(); + const forward = new Clock([dilation, compression]); + const reversed = new Clock([compression, dilation]); + + // Walk both clocks through the same hourly checkpoints; their offsets are summed, so + // the array order must not matter at any point in time. + for (let h = 1; h <= 12; h++) { + jest.setSystemTime(h * hour); + expect(reversed.relativeTimeInMillis).toEqual(forward.relativeTimeInMillis); + } + }); + }); }); From 4737db80274b16d188d9c69dc96c6a462d383750 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:29:19 -0600 Subject: [PATCH 04/17] test: assert dilation/compression round-trip nets to zero A dilation that loses 3h followed by a compression that reclaims exactly 3h must return the clock to perfect sync and leave no residual drift. Also pins the symmetric lag profile (-1.5h at 4am and 8.5am, -3h peak at 7am) through the excursion. --- test/Clock.invariants.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/Clock.invariants.test.ts b/test/Clock.invariants.test.ts index 84f4b72..68655e7 100644 --- a/test/Clock.invariants.test.ts +++ b/test/Clock.invariants.test.ts @@ -58,4 +58,31 @@ describe(`Clock invariants`, () => { } }); }); + + describe(`round-trip net-zero`, () => { + it(`returns to perfect sync after a dilation is exactly compensated by a compression`, () => { + // Lose 3 hours over 1am-7am, then claw exactly 3 hours back over 7am-10am. + const clock = new Clock(buildDistortions()); + + // The clock runs behind for the whole excursion, peaking at -3h when the dilation ends + // at 7am, and the lag profile is symmetric: it reads -1.5h both halfway into the + // dilation (4am) and halfway back through the compression (8.5am). + jest.setSystemTime(4 * hour); + expect(clock.relativeTimeInMillis - 4 * hour).toEqual(-1.5 * hour); + + jest.setSystemTime(7 * hour); + expect(clock.relativeTimeInMillis - 7 * hour).toEqual(-3 * hour); + + jest.setSystemTime(8.5 * hour); + expect(clock.relativeTimeInMillis - 8.5 * hour).toEqual(-1.5 * hour); + + // By 10am the windows are done and the offset must be back to exactly zero. + jest.setSystemTime(10 * hour); + expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis); + + // ...and it stays in sync afterward, with no residual drift. + jest.setSystemTime(20 * hour); + expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis); + }); + }); }); From dcfba1cb571de89d346e5b99ba0e86048b351863 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:31:12 -0600 Subject: [PATCH 05/17] fix: make Clock.offset idempotent instead of double-counting The offset getter folded the elapsed [lastCheck, now] slice into _offset but left _lastCheck unadvanced; advancing it was done separately by markLastCheck() on the relativeTimeInMillis path. So reading offset on its own (or twice) re-accumulated the same interval, inflating the distortion. Advance _lastCheck inside offset so each slice is consumed exactly once, and drop the now-redundant markLastCheck(). relativeTimeInMillis is unchanged in behavior. Adds tests asserting repeated reads are stable. Co-Authored-By: Claude Opus 4.8 --- src/Clock.ts | 16 +++++++--------- test/Clock.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/Clock.ts b/src/Clock.ts index 1309143..c24fcb2 100644 --- a/src/Clock.ts +++ b/src/Clock.ts @@ -16,7 +16,7 @@ export default class Clock { get offset() { const now = this.referenceTimeInMillis; - return (this._offset = this._timeDistortions.reduce((offset, distortion) => { + this._offset = this._timeDistortions.reduce((offset, distortion) => { const window = distortion.getTimeWindow(); if (window.compareWithinWindow(now) === TimeWindowComparison.EARLIER) return offset; if (window.compareWithinWindow(this._lastCheck) === TimeWindowComparison.LATER) return offset; @@ -27,16 +27,14 @@ export default class Clock { Math.min(now - window.windowStartInMillis, timeSinceLastCheck, window.windowEndInMillis - this._lastCheck), ); return offset + distortion.getElapsedTimeInMillis(windowOffset, windowLength); - }, this._offset)); - } - - private markLastCheck() { - this._lastCheck = this.referenceTimeInMillis; + }, this._offset); + // Consume the elapsed slice so that reading `offset` (or `relativeTimeInMillis`) again + // without time advancing does not accumulate the same interval twice. + this._lastCheck = now; + return this._offset; } get relativeTimeInMillis() { - const relativeTime = this.referenceTimeInMillis + this.offset; - this.markLastCheck(); - return relativeTime; + return this.referenceTimeInMillis + this.offset; } } diff --git a/test/Clock.test.ts b/test/Clock.test.ts index ec4505d..58ada11 100644 --- a/test/Clock.test.ts +++ b/test/Clock.test.ts @@ -32,6 +32,31 @@ describe(`Clock class`, () => { expect(end - start).toEqual(1000); }); }); + describe(`offset`, () => { + it('is idempotent when read repeatedly without time advancing', () => { + const hour = 1000 * 60 * 60; + const clock = new Clock([new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 })]); + + jest.setSystemTime(4 * hour); // partway through the dilation window + + // Each read consumes the elapsed slice, so subsequent reads at the same instant must + // not keep accumulating the same interval. + const first = clock.offset; + expect(clock.offset).toEqual(first); + expect(clock.offset).toEqual(first); + expect(first).toEqual(-1.5 * hour); + }); + + it('does not double-count when read via offset and then relativeTimeInMillis', () => { + const hour = 1000 * 60 * 60; + const clock = new Clock([new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 })]); + + jest.setSystemTime(4 * hour); + + const offset = clock.offset; + expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis + offset); + }); + }); describe(`getRelativeTime`, () => { it('documentation example (matches the hour-by-hour table in the README)', () => { // System time starts at the epoch (1970-01-01T00:00:00Z), i.e. midnight UTC, so the From 0a6880da3b3b3a198473ff6322ba917c2f6c96f5 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:34:41 -0600 Subject: [PATCH 06/17] test: characterize midnight-wrapping window limitation A distortion window is described as a wall-clock time-of-day and always resolved against the current calendar date, so a window that wraps past midnight goes dead once the date rolls over: its start is recomputed as tonight's (future) occurrence and the in-progress distortion drops out. Pin both the working before-midnight case and the broken after-midnight case so the gap is visible and a future date/timezone-aware version has a regression target. This is the README's overnight scenario. --- test/Clock.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/Clock.test.ts b/test/Clock.test.ts index 58ada11..65fc738 100644 --- a/test/Clock.test.ts +++ b/test/Clock.test.ts @@ -57,6 +57,40 @@ describe(`Clock class`, () => { expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis + offset); }); }); + // KNOWN LIMITATION: a distortion's window is described purely as a wall-clock time-of-day + // and is always resolved against the *current* calendar date (ClockTime.forTodayInMillis). + // A window that wraps past midnight (end-of-day < start-of-day) is therefore only active on + // the calendar day it starts; once the date rolls over, its start time is recomputed as + // tonight's occurrence (in the future) and the in-progress distortion silently drops out. + // These tests pin that behavior so the gap is visible and a future date/timezone-aware + // implementation has a regression target. The midnight scenario is exactly the README's + // overnight use case, so this matters in practice. + describe(`midnight-wrapping window (known limitation)`, () => { + const hour = 1000 * 60 * 60; + const minute = 1000 * 60; + // 11pm start, runs at half speed for a 4h reference window => ends 3am the next day. + const buildClock = () => new Clock([new ConstantTimeDilation({ hour: 23 }, { hours: 2 }, { hours: 4 })]); + + it('distorts correctly before midnight, on the day the window starts', () => { + const clock = buildClock(); + jest.setSystemTime(23 * hour); // 11:00pm, window start + clock.relativeTimeInMillis; // prime lastCheck at the window start + + jest.setSystemTime(23 * hour + 30 * minute); // 11:30pm, 30 real minutes into the window + // Half speed => only 15 fake minutes have passed; the clock is 15 minutes behind. + expect(clock.relativeTimeInMillis - clock.referenceTimeInMillis).toEqual(-15 * minute); + }); + + it('does NOT distort after midnight, even though 00:30 is inside the 23:00->03:00 window', () => { + jest.setSystemTime(24 * hour + 30 * minute); // 12:30am the following day + const clock = buildClock(); + + // The window *should* still be active here, but because its start is re-resolved to the + // current (already-past-midnight) date, it is treated as not-yet-started and no + // distortion is applied. Documents the bug rather than endorsing it. + expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis); + }); + }); describe(`getRelativeTime`, () => { it('documentation example (matches the hour-by-hour table in the README)', () => { // System time starts at the epoch (1970-01-01T00:00:00Z), i.e. midnight UTC, so the From 31ad8945769637f56e212f520afb4fd1a2b8659f Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:37:39 -0600 Subject: [PATCH 07/17] test: characterize degenerate distortion durations The library does not validate distortion parameters (README roadmap #2). Pin current behavior: a zero reference duration makes distortTime return Infinity, two zero durations return NaN, and a negative relative duration silently produces an exaggerated dilation. End-to-end, a zero-reference window poisons relativeTimeInMillis with NaN permanently -- documented here as motivation for future validation. --- test/DegenerateDurations.test.ts | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/DegenerateDurations.test.ts diff --git a/test/DegenerateDurations.test.ts b/test/DegenerateDurations.test.ts new file mode 100644 index 0000000..59ff090 --- /dev/null +++ b/test/DegenerateDurations.test.ts @@ -0,0 +1,49 @@ +import { Clock, ConstantTimeCompression, ConstantTimeDilation } from '../src/index'; + +// The library performs no validation of distortion parameters (see README roadmap #2). +// These tests characterize what currently happens for degenerate inputs so the behavior is +// at least visible, and so a future validating implementation has a baseline to change. +describe(`degenerate distortion durations`, () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(0)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe(`distortTime`, () => { + it(`returns Infinity when the reference duration is zero (division by zero)`, () => { + const distortion = new ConstantTimeCompression({ hour: 1 }, { hours: 2 }, { hours: 0 }); + expect(distortion.distortTime(500)).toBe(Infinity); + }); + + it(`returns NaN when both durations are zero`, () => { + const distortion = new ConstantTimeCompression({ hour: 1 }, { hours: 0 }, { hours: 0 }); + expect(Number.isNaN(distortion.distortTime(500))).toBe(true); + }); + + it(`exaggerates dilation for a negative relative duration (no sign validation)`, () => { + // -1h relative over 2h reference => ratio -0.5 => offset delta of -1.5x real time. + const distortion = new ConstantTimeDilation({ hour: 1 }, { hours: -1 }, { hours: 2 }); + expect(distortion.distortTime(500)).toEqual(-750); + }); + }); + + describe(`Clock`, () => { + it(`poisons relative time with NaN once a zero-reference-duration window is reached`, () => { + const hour = 1000 * 60 * 60; + // A zero-length window at 1am: the division by zero yields an Infinity ratio, and the + // zero-length elapsed slice (0 * Infinity) corrupts the accumulated offset to NaN -- + // which then permanently poisons every subsequent reading. + const clock = new Clock([new ConstantTimeCompression({ hour: 1 }, { hours: 2 }, { hours: 0 })]); + + jest.setSystemTime(0); + expect(clock.relativeTimeInMillis).toEqual(0); // before the window: still fine + + jest.setSystemTime(2 * hour); // past the empty window + expect(Number.isNaN(clock.relativeTimeInMillis)).toBe(true); + }); + }); +}); From 8f404e3cbe374d955e5048c2715e448b4d227254 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:39:45 -0600 Subject: [PATCH 08/17] test: assert distortion direction comes from durations, not class name ConstantTimeDilation and ConstantTimeCompression share one formula, so their names are advisory intent rather than enforced behavior. Pin that identical params yield identical results across the two classes, and that a "dilation" with relative > reference speeds the clock up while a "compression" with relative < reference slows it down. --- test/DistortionDirection.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 test/DistortionDirection.test.ts diff --git a/test/DistortionDirection.test.ts b/test/DistortionDirection.test.ts new file mode 100644 index 0000000..4307009 --- /dev/null +++ b/test/DistortionDirection.test.ts @@ -0,0 +1,27 @@ +import { ConstantTimeCompression, ConstantTimeDilation } from '../src/index'; + +// ConstantTimeDilation and ConstantTimeCompression apply the same formula; their names only +// express intent and are not enforced (README roadmap #2 contemplates validation that would). +// What actually determines whether the clock speeds up or slows down is the ratio of relative +// to reference duration -- not which class was instantiated. +describe(`distortion direction is set by the durations, not the class name`, () => { + it(`the two classes are mathematically identical for identical parameters`, () => { + const dilation = new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 }); + const compression = new ConstantTimeCompression({ hour: 1 }, { hours: 3 }, { hours: 6 }); + expect(dilation.distortTime(1000)).toEqual(compression.distortTime(1000)); + }); + + it(`a "dilation" with relative > reference actually speeds the clock up`, () => { + // Mislabeled: this is really a compression. 2x speed => +1000ms of fake time per 1000ms + // of real time, i.e. a positive offset delta (the clock runs ahead). + const mislabeled = new ConstantTimeDilation({ hour: 1 }, { hours: 6 }, { hours: 3 }); + expect(mislabeled.distortTime(1000)).toEqual(1000); + }); + + it(`a "compression" with relative < reference actually slows the clock down`, () => { + // Mislabeled: this is really a dilation. Half speed => -500ms per 1000ms of real time, + // i.e. a negative offset delta (the clock runs behind). + const mislabeled = new ConstantTimeCompression({ hour: 1 }, { hours: 3 }, { hours: 6 }); + expect(mislabeled.distortTime(1000)).toEqual(-500); + }); +}); From 7e718afd7982f1301b107a19522c680666f39a73 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:41:27 -0600 Subject: [PATCH 09/17] test: lock in DST-aware TimeWindow durations durationInMillis is real elapsed time between two wall-clock times, so across a DST transition it diverges from the nominal wall-clock span. Assert America/New_York loses an hour on the spring-forward day (3h span => 2 real hours), gains one on fall-back (3h span => 4 real hours), and matches the span on an ordinary day. Guards the Temporal-backed behavior. --- test/TimeWindow.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/TimeWindow.test.ts b/test/TimeWindow.test.ts index 8bc1da4..4a694b4 100644 --- a/test/TimeWindow.test.ts +++ b/test/TimeWindow.test.ts @@ -45,4 +45,30 @@ describe(`TimeWindow class`, () => { expect(result).toEqual(12 * 60 * 60 * 1000); }); }); + + // durationInMillis is real elapsed time between two wall-clock times, so across a DST + // transition the real duration differs from the nominal wall-clock span. These lock in the + // Temporal-backed, timezone-aware behavior so it can't be "simplified" into naive math. + describe(`durationInMillis across DST transitions (America/New_York)`, () => { + const hour = 60 * 60 * 1000; + const window = (startHour: number, endHour: number) => + new TimeWindow(new ClockTime({ hour: startHour }), new ClockTime({ hour: endHour }), `America/New_York`); + + it(`loses an hour on the spring-forward day`, () => { + jest.setSystemTime(Date.UTC(2025, 2, 9, 12)); // 2025-03-09: clocks jump 02:00 -> 03:00 + // Wall-clock span is 3 hours but only 2 real hours elapse. + expect(window(1, 4).durationInMillis).toEqual(2 * hour); + }); + + it(`gains an hour on the fall-back day`, () => { + jest.setSystemTime(Date.UTC(2025, 10, 2, 12)); // 2025-11-02: clocks fall 02:00 -> 01:00 + // Wall-clock span is 3 hours but 4 real hours elapse (the 1am hour happens twice). + expect(window(0, 3).durationInMillis).toEqual(4 * hour); + }); + + it(`equals the wall-clock span on a day with no transition`, () => { + jest.setSystemTime(Date.UTC(2025, 5, 1, 12)); // 2025-06-01: no DST change + expect(window(1, 4).durationInMillis).toEqual(3 * hour); + }); + }); }); From e0158839750bd432f3f1794479f73c6c1fc333fb Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:43:30 -0600 Subject: [PATCH 10/17] test: pin TimeWindow half-open boundary semantics compareWithinWindow treats [start, end) as half-open: the start instant is WITHIN, the end instant is LATER. Clock depends on this so adjacent non-overlapping windows hand off without gap or overlap. Assert both boundaries to the millisecond. --- test/TimeWindow.test.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/TimeWindow.test.ts b/test/TimeWindow.test.ts index 4a694b4..d1e2518 100644 --- a/test/TimeWindow.test.ts +++ b/test/TimeWindow.test.ts @@ -1,5 +1,5 @@ import ClockTime from '../src/ClockTime'; -import TimeWindow from '../src/TimeWindow'; +import TimeWindow, { TimeWindowComparison } from '../src/TimeWindow'; describe(`TimeWindow class`, () => { const currentDate = new Date(0); @@ -71,4 +71,24 @@ describe(`TimeWindow class`, () => { expect(window(1, 4).durationInMillis).toEqual(3 * hour); }); }); + + // The window is a half-open interval [start, end): the start instant is inside, the end + // instant is outside. Clock relies on this for adjacent, non-overlapping windows to hand + // off cleanly, so pin the exact boundary behavior. + describe(`compareWithinWindow`, () => { + const hour = 60 * 60 * 1000; + const window = new TimeWindow(new ClockTime({ hour: 12 }), new ClockTime({ hour: 13 })); + const windowStart = 12 * hour; // resolved against the epoch day (UTC) from beforeEach + const windowEnd = 13 * hour; + + it(`treats the start of the window as inclusive`, () => { + expect(window.compareWithinWindow(windowStart - 1)).toEqual(TimeWindowComparison.EARLIER); + expect(window.compareWithinWindow(windowStart)).toEqual(TimeWindowComparison.WITHIN); + }); + + it(`treats the end of the window as exclusive`, () => { + expect(window.compareWithinWindow(windowEnd - 1)).toEqual(TimeWindowComparison.WITHIN); + expect(window.compareWithinWindow(windowEnd)).toEqual(TimeWindowComparison.LATER); + }); + }); }); From 89c1f0f9ce51271e720b0e336d30d6e8aaa7046e Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:45:05 -0600 Subject: [PATCH 11/17] test: cover ClockTime.add wrapping and sub-second precision Assert add advances the wall-clock time, wraps past midnight (a time-of- day carries no date, so 23:00 + 2h is 01:00), and preserves millisecond precision. Also assert forTodayInMillis resolves seconds and milliseconds. --- test/ClockTime.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/ClockTime.test.ts b/test/ClockTime.test.ts index 57f2a6f..78da5c5 100644 --- a/test/ClockTime.test.ts +++ b/test/ClockTime.test.ts @@ -57,7 +57,31 @@ describe(`ClockTime class`, () => { }); }); + describe(`add`, () => { + it(`advances the wall-clock time by the duration`, () => { + const result = new ClockTime({ hour: 12 }).add({ hours: 1, minutes: 30 }); + expect(result.compare(new ClockTime({ hour: 13, minute: 30 }))).toEqual(ClockTimeComparison.EQUIVALENT); + }); + + it(`wraps past midnight, since a wall-clock time carries no date`, () => { + const result = new ClockTime({ hour: 23 }).add({ hours: 2 }); + expect(result.compare(new ClockTime({ hour: 1 }))).toEqual(ClockTimeComparison.EQUIVALENT); + expect(result.forTodayInMillis()).toEqual(60 * 60 * 1000); // 01:00, not 25:00 + }); + + it(`preserves millisecond precision`, () => { + const result = new ClockTime({ hour: 0, millisecond: 250 }).add({ milliseconds: 1500 }); + expect(result.forTodayInMillis()).toEqual(1750); // 250ms + 1500ms past midnight + }); + }); + describe(`forTodayInMillis`, () => { + describe(`sub-second precision`, () => { + it(`resolves seconds and milliseconds`, () => { + const clockTime = new ClockTime({ second: 1, millisecond: 500 }); + expect(clockTime.forTodayInMillis()).toEqual(1500); + }); + }); describe(`no params`, () => { it(`returns today's occurrence, in UTC, of the wall-clock time, in epoch millis'`, () => { const hour = 12; From ca7a2243658b1045461c7f229e17dcde8f266ea5 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:46:56 -0600 Subject: [PATCH 12/17] test: add Christmas-morning and permanent-lag scenarios --- test/Scenarios.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/Scenarios.test.ts diff --git a/test/Scenarios.test.ts b/test/Scenarios.test.ts new file mode 100644 index 0000000..d4f4cd5 --- /dev/null +++ b/test/Scenarios.test.ts @@ -0,0 +1,44 @@ +import { Clock, ConstantTimeDilation } from '../src/index'; + +// End-to-end scenarios that read as executable documentation of what the library is for. +describe(`scenarios`, () => { + const hour = 60 * 60 * 1000; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(0)); // midnight UTC on the epoch day + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe(`Christmas morning (the README story)`, () => { + it(`keeps the clock below 4:00am until it is really 7:00am`, () => { + const fourAm = 4 * hour; + // Slow time starting at 1am so the fake clock only crawls from 1am to 4am over the 6 + // real hours leading up to 7am -- the small human won't see 4:00am until then. + const clock = new Clock([new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 })]); + + jest.setSystemTime((6 * 60 + 59) * 60 * 1000); // 6:59am real + // Still reads 3:59am-ish, so the early riser rolls over and goes back to sleep. + expect(clock.relativeTimeInMillis).toBeLessThan(fourAm); + + jest.setSystemTime(7 * hour); // 7:00am real + expect(clock.relativeTimeInMillis).toEqual(fourAm); // now, and only now, it reads 4:00am + }); + }); + + describe(`a dilation with no compensating compression`, () => { + it(`leaves the clock permanently behind by the time it lost`, () => { + // Half speed for 2 hours from 1am: the clock loses exactly 1 hour and never regains it. + const clock = new Clock([new ConstantTimeDilation({ hour: 1 }, { hours: 1 }, { hours: 2 })]); + + jest.setSystemTime(3 * hour); // window just ended + expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis - hour); + + jest.setSystemTime(12 * hour); // much later -- still exactly 1 hour behind, no recovery + expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis - hour); + }); + }); +}); From f0c89c8bf2c4913c6fb90217d3bd31f655bd72f6 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:53:04 -0600 Subject: [PATCH 13/17] ci: test current Node LTS lines instead of EOL versions The matrix tested 12/14/16/18, all now end-of-life, while the bug that broke the suite was a modern-Node issue. Switch to 18/20/22/24 (18 kept as a compatibility floor). Verified all four pass locally. --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 06a4881..e091f9b 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x, 16.x, 18.x] + node-version: [18.x, 20.x, 22.x, 24.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: From 56249878a7edb0291e7cc70a2e6cd31b35dc4686 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:56:37 -0600 Subject: [PATCH 14/17] Make NodeJSv18 the baseline --- .github/workflows/node.js.yml | 4 ++-- README.md | 4 +++- package.json | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index e091f9b..8f83e73 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -19,9 +19,9 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: 'npm' diff --git a/README.md b/README.md index e40bdbb..ad905aa 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Let's be honest, you'll only be able to get away with this once a year, on Chris npm install clockblocker ``` +Requires Node.js >= 18. + ## Usage ``` @@ -54,7 +56,7 @@ const timeCompression = new ConstantTimeCompression( { hour: 7 }, // start at 7am reference ("real") time { hours: 6 }, // Relative Time: In the time the "fake" time shows the passage of 6 hours { hours: 3}, // Reference Time: Only 3 hours, real time will have elapsed -), +); // So, by the time 10:00am (reference) rolls-around, the clock is back to normal 1-to-1 time. // Fake clock will read: 10:00am, real clock: 10:00am diff --git a/package.json b/package.json index 3a8e712..a5dd53b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ ], "author": "Jonathan Griggs ", "license": "MIT", + "engines": { + "node": ">=18" + }, "dependencies": { "@js-temporal/polyfill": "^0.4.1" }, From 453e48297afcbdde7676e3829e47ca1d33875294 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 18:59:08 -0600 Subject: [PATCH 15/17] Fix NPM packaging issues --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a5dd53b..0f73b69 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.0.1", "description": "A Typescript library for warping the very fabric of space-time, or for building deceitful clocks.", "main": "dist/index.js", - "types": "dist/types/index.d.js", + "types": "dist/index.d.ts", "scripts": { "build": "npm run clean && tsc", "clean": "rm -rf ./dist", + "prepublishOnly": "npm run build && npm run test:ci", "test:ci": "jest -c jest.config.ci.js", "test": "jest --no-cache --runInBand", "test:cov": "jest --coverage --no-cache --runInBand" From 5937a92b427cee4241b9f65ca8b4556cd7fc6969 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 19:01:54 -0600 Subject: [PATCH 16/17] ci: pin publish workflow actions to SHAs and fix header comment Pin actions/checkout (v7.0.0) and actions/setup-node (v6.4.0) to commit SHAs in the publish workflow, matching the CI workflow. This is the workflow that holds the npm token, so tag-hijack hardening matters most here. Also correct the misleading "GitHub Packages" comment to "npm registry", which is what it actually targets. --- .github/workflows/npm-publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4075c16..162ab8c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,4 +1,4 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# This workflow will run tests using node and then publish a package to the npm registry when a release is created # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages name: Publish npm Package @@ -11,8 +11,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 18 - run: npm ci @@ -23,8 +23,8 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 18 registry-url: https://registry.npmjs.org/ From 27ff5b229762d8a4303d5afad98c794367244940 Mon Sep 17 00:00:00 2001 From: Jonathan Griggs Date: Fri, 26 Jun 2026 19:02:01 -0600 Subject: [PATCH 17/17] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f73b69..9280efa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clockblocker", - "version": "0.0.1", + "version": "0.1.0", "description": "A Typescript library for warping the very fabric of space-time, or for building deceitful clocks.", "main": "dist/index.js", "types": "dist/index.d.ts",