diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 06a4881..8f83e73 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -15,13 +15,13 @@ 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: - - 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/.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/ 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/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/package.json b/package.json index 3a8e712..9280efa 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "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/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" @@ -29,6 +30,9 @@ ], "author": "Jonathan Griggs ", "license": "MIT", + "engines": { + "node": ">=18" + }, "dependencies": { "@js-temporal/polyfill": "^0.4.1" }, 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/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.invariants.test.ts b/test/Clock.invariants.test.ts new file mode 100644 index 0000000..68655e7 --- /dev/null +++ b/test/Clock.invariants.test.ts @@ -0,0 +1,88 @@ +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 + }); + }); + + 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); + } + }); + }); + + 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); + }); + }); +}); diff --git a/test/Clock.test.ts b/test/Clock.test.ts index e827e3b..65fc738 100644 --- a/test/Clock.test.ts +++ b/test/Clock.test.ts @@ -32,51 +32,105 @@ describe(`Clock class`, () => { expect(end - start).toEqual(1000); }); }); - describe(`getRelativeTime`, () => { - it('documentation example', () => { - 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 - }, - ), - 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 - }, - ), - ]); - + describe(`offset`, () => { + it('is idempotent when read repeatedly without time advancing', () => { const hour = 1000 * 60 * 60; - jest.advanceTimersByTime(hour); - expect(clock.relativeTimeInMillis).toEqual(hour); - expect(clock.referenceTimeInMillis).toEqual(hour); + const clock = new Clock([new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 })]); - jest.advanceTimersByTime(hour); + jest.setSystemTime(4 * hour); // partway through the dilation window - expect(clock.relativeTimeInMillis).toEqual(hour * 1.5); - expect(clock.referenceTimeInMillis).toEqual(hour * 2); + // 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); + }); - jest.advanceTimersByTime(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 })]); - expect(clock.relativeTimeInMillis).toEqual(hour * 2); - expect(clock.referenceTimeInMillis).toEqual(hour * 3); + jest.setSystemTime(4 * hour); - jest.advanceTimersByTime(hour); + const offset = clock.offset; + 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); + }); - expect(clock.relativeTimeInMillis).toEqual(hour * 2.5); - expect(clock.referenceTimeInMillis).toEqual(hour * 4); + 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(); - jest.advanceTimersByTime(hour); + // 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 + // 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 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 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) + ), + ]); - expect(clock.relativeTimeInMillis).toEqual(hour * 3); - expect(clock.referenceTimeInMillis).toEqual(hour * 5); + const hour = 1000 * 60 * 60; + // [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 +191,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/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; 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); + }); }); }); }); 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); + }); + }); +}); 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); + }); +}); 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); + }); + }); +}); diff --git a/test/TimeWindow.test.ts b/test/TimeWindow.test.ts index 8bc1da4..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); @@ -45,4 +45,50 @@ 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); + }); + }); + + // 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); + }); + }); });