diff --git a/package.json b/package.json index c8b2fb5..7a31ffb 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "scripts": { "clear": "rimraf dist", "build": "npm run clear && tsc", + "pretest": "npm build", "build-dev": "npm run clear && tsc --sourceMap", "test": "npm run build-dev && mocha -r ts-node/register tests/**/*.test.ts", "dist": "npm install && npm run test && npm publish" diff --git a/src/Duration.ts b/src/Duration.ts index 19158d7..94a959f 100644 --- a/src/Duration.ts +++ b/src/Duration.ts @@ -1,5 +1,7 @@ import moment, {Duration as MomentDuration} from 'moment'; import {Instant} from "./Instant"; +import {DurationSpec} from "./DurationSpec"; +import {requireInt} from "./util/requireInt"; export class Duration { @@ -27,6 +29,36 @@ export class Duration return this.momentDuration.toISOString(); } + public static of( + { + years = 0, + days = 0, + hours = 0, + minutes = 0, + seconds = 0, + millis = 0, + }: DurationSpec + ) + { + requireInt( + seconds * 1000, + `Not accepting values with precision <1/1000ths of seconds; got ${seconds}` + ); + requireInt( + millis, + `Only accepting whole milliseconds; got ${millis}` + ); + return Duration.parse( + `P` + + `${years}Y` + + `${days}D` + + 'T' + + `${hours}H` + + `${minutes}M` + + `${seconds + (millis / 1000)}S` + ); + } + public static ofYears(years: number) { return Duration.parse(`P${years}Y`); diff --git a/src/DurationSpec.ts b/src/DurationSpec.ts new file mode 100644 index 0000000..c7c8d24 --- /dev/null +++ b/src/DurationSpec.ts @@ -0,0 +1,9 @@ +export interface DurationSpec +{ + years?: number + days?: number + hours?: number + minutes?: number + seconds?: number + millis?: number +} \ No newline at end of file diff --git a/src/Instant.ts b/src/Instant.ts index 96f463e..f8d2961 100644 --- a/src/Instant.ts +++ b/src/Instant.ts @@ -4,44 +4,51 @@ import {ZoneId, ZoneIdString} from "./ZoneId"; import {requireValidDate} from "./util/requireValidDate"; import {requireInt} from "./util/requireInt"; import {toZoneId} from "./util/toZoneId"; +import {DurationSpec} from "./DurationSpec"; export class Instant { - private readonly epochMilli: number; + public static readonly EPOCH = new Instant(0, 0); + private readonly secondsSinceEpoch: number; + private readonly microsInSecond: number; - private constructor(epochMilli: number) + private constructor(secondsSinceEpoch: number, microsInSecond: number) { - this.epochMilli = requireInt(epochMilli); + this.secondsSinceEpoch = requireInt( + secondsSinceEpoch, + `Only a whole number allowed for the number of seconds since the epoch; got ${secondsSinceEpoch}` + ); + this.microsInSecond = requireInt( + microsInSecond, + `Only a whole number allowed for the number of microseconds in the current second; got ${microsInSecond}` + ); } public static now() { - return new Instant(new Date().getTime()); + return Instant.ofEpochMilli(Date.now()); } public static parse(stringValue: string) { - const date = new Date(stringValue); - return new Instant( - requireValidDate(date).getTime() + const date = requireValidDate( + new Date(stringValue) ); + return Instant.ofEpochMilli(date.getTime()); } public static from(date: Date) { - return new Instant( + return Instant.ofEpochMilli( requireValidDate(date).getTime() ); } public static ofEpochMilli(epochMilli: number) { - return new Instant( - requireInt( - epochMilli, - `Expected valid integer for epochMilli, but got ${epochMilli}` - ) - ); + const secondsSinceEpoch = Math.floor(epochMilli / 1000); + const microsInSecond = (epochMilli % 1000) * 1000; + return new Instant(secondsSinceEpoch, microsInSecond); } public static ofEpochSecond(epochSecond: number) @@ -51,13 +58,19 @@ export class Instant `Expected valid integer for epoch second, but got ${epochSecond}` ); return new Instant( - validEpochSecond * 1000 + validEpochSecond, + 0, ); } + /** + * Alias for {@link .plus} + */ + public add(duration: DurationSpec): Instant public add(duration: Duration): Instant + public add(duration: Duration | DurationSpec): Instant { - return new Instant(this.epochMilli + duration.asMillis); + return this.plus(duration); } public atZone(zoneIdString: ZoneIdString): ZonedDateTime @@ -67,9 +80,14 @@ export class Instant return new ZonedDateTime(this.toJS(), toZoneId(arg)); } + public plus(duration: DurationSpec): Instant public plus(duration: Duration): Instant + public plus(duration: Duration | DurationSpec): Instant { - return Instant.ofEpochMilli(this.epochMilli + duration.asMillis); + const asDuration = duration instanceof Duration + ? duration + : Duration.of(duration); + return Instant.ofEpochMilli(this.toEpochMilli() + asDuration.asMillis); } public plusSeconds(secondsToAdd: number) @@ -92,9 +110,24 @@ export class Instant )) } + /** + * Alias for {@link .minus} + */ + public subtract(duration: DurationSpec): Instant + public subtract(duration: Duration): Instant + public subtract(duration: Duration | DurationSpec): Instant + { + return this.minus(duration); + } + + public minus(duration: DurationSpec): Instant public minus(duration: Duration): Instant + public minus(duration: Duration | DurationSpec): Instant { - return Instant.ofEpochMilli(this.epochMilli - duration.asMillis); + const asDuration = duration instanceof Duration + ? duration + : Duration.of(duration); + return Instant.ofEpochMilli(this.toEpochMilli() - asDuration.asMillis); } public minusSeconds(secondsToAdd: number) @@ -124,7 +157,7 @@ export class Instant */ public compareTo(otherInstant: Instant): number { - return this.epochMilli - otherInstant.epochMilli; + return this.toEpochMicro() - otherInstant.toEpochMicro(); } public isAfter(otherInstant: Instant): boolean @@ -144,15 +177,26 @@ export class Instant { if (this === otherInstant) return true; - return this.epochMilli === otherInstant.epochMilli; + return this.secondsSinceEpoch === otherInstant.secondsSinceEpoch + && this.microsInSecond === otherInstant.microsInSecond; } /** - * Gets the number of milliseconds from the Java epoch of 1970-01-01T00:00:00Z. + * Gets the number of milliseconds from the Java epoch of 1970-01-01T00:00:00Z. Microseconds, if present, are + * rounded down, so this method returns an integer. */ public toEpochMilli(): number { - return this.epochMilli; + return this.toEpochMicro() / 1000; + } + + /** + * In most cases simply a thousandfold of {@link .toEpochMilli()}, this may return a different value than 1000times + * {@link .toEpochMilli()} if this {@link Instant} was instantiated with a timestamp with sub-millisecond precision. + */ + public toEpochMicro(): number + { + return this.secondsSinceEpoch * 1000_000 + this.microsInSecond; } /** @@ -169,13 +213,13 @@ export class Instant */ public getEpochSecond(): number { - return Math.floor(this.epochMilli / 1000); + return this.secondsSinceEpoch; } public toJS(): Date { return requireValidDate( - new Date(this.epochMilli) + new Date(this.toEpochMilli()) ); } } diff --git a/src/util/requireInt.ts b/src/util/requireInt.ts index e52efc4..7fef205 100644 --- a/src/util/requireInt.ts +++ b/src/util/requireInt.ts @@ -3,6 +3,8 @@ import {isInt} from "./isInt"; export function requireInt(numberValue: number, message?: string): number { if (!isInt(numberValue)) + { throw new Error(message ?? `Expected value '${numberValue}' to be an integer`); + } return numberValue; } \ No newline at end of file diff --git a/tests/Duration.test.ts b/tests/Duration.test.ts new file mode 100644 index 0000000..90c1db4 --- /dev/null +++ b/tests/Duration.test.ts @@ -0,0 +1,38 @@ +import {Duration} from "../dist"; +import {assert} from "chai"; +import {describe} from "mocha"; + +describe('Duration', () => { + describe('.of factory method', () => { + it('accepts whole years', () => { + assert.equal(Duration.of({years: 2}).toString(), 'P2Y'); + }) + it('accepts whole fractional years', () => { + assert.equal(Duration.of({years: 2.5}).toString(), 'P2Y6M'); + }) + it('accepts whole negative whole years', () => { + assert.equal(Duration.of({years: -2}).toString(), '-P2Y'); + }) + it('accepts whole negative fractional years', () => { + assert.equal(Duration.of({years: -2.5}).toString(), '-P2Y6M'); + }) + it('accepts whole days', () => { + assert.equal(Duration.of({days: 2}).toString(), 'P2D'); + }) + it('accepts whole hours', () => { + assert.equal(Duration.of({hours: 2}).toString(), 'PT2H'); + }) + it('accepts whole minutes', () => { + assert.equal(Duration.of({minutes: 2}).toString(), 'PT2M'); + }) + it('accepts whole seconds', () => { + assert.equal(Duration.of({seconds: 2}).toString(), 'PT2S'); + }) + it('accepts 1/1000ths of seconds', () => { + assert.equal(Duration.of({seconds: 0.002}).toString(), 'PT0.002S'); + }) + it('throws error when attempting to enter <1/1000ths of seconds', () => { + assert.throws(() => Duration.of({seconds: 0.0002})); + }) + }); +}); \ No newline at end of file diff --git a/tests/Instant.test.ts b/tests/Instant.test.ts index 2d413c2..2f9d852 100644 --- a/tests/Instant.test.ts +++ b/tests/Instant.test.ts @@ -2,39 +2,66 @@ import {Duration, Instant, now} from "../dist"; import {assert} from "chai"; import {LocalDate, LocalDateTime, LocalTime, ZoneId} from "../src"; import {describe} from "mocha"; +import {DurationSpec} from "../src/DurationSpec"; describe('instant', () => { - it('adds duration at time transition', () => { - testAddition(Instant.parse('2021-03-28T00:45+00:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T00:45+01:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T00:45+02:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T00:45+03:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T00:45+04:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T01:45+00:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T01:45+01:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T01:45+02:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T01:45+03:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T01:45+04:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T02:45+00:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T02:45+01:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T02:45+02:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T02:45+03:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T02:45+04:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T03:45+00:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T03:45+01:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T03:45+02:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T03:45+03:00'), Duration.ofMinutes(15)); - testAddition(Instant.parse('2021-03-28T03:45+04:00'), Duration.ofMinutes(15)); - }) - it('adds duration at time transition', () => { - const zonedDateTime = LocalDateTime.of( - new LocalDate(2021, 5, 16), - new LocalTime(11, 0) - ).atZone(ZoneId.of('UTC')); - assert.equal(zonedDateTime.toString(), '2021-05-16T11:00:00.000+00:00'); - const sameInstantOtherZone = zonedDateTime.toInstant().atZone(ZoneId.of('Europe/Amsterdam')) - assert.equal(sameInstantOtherZone.toString(), '2021-05-16T13:00:00.000+02:00'); - }) + describe('.add(Duration)', () => { + it('adds duration at time transition', () => { + testAddition(Instant.parse('2021-03-28T00:45+00:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T00:45+01:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T00:45+02:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T00:45+03:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T00:45+04:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T01:45+00:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T01:45+01:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T01:45+02:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T01:45+03:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T01:45+04:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T02:45+00:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T02:45+01:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T02:45+02:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T02:45+03:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T02:45+04:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T03:45+00:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T03:45+01:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T03:45+02:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T03:45+03:00'), Duration.ofMinutes(15)); + testAddition(Instant.parse('2021-03-28T03:45+04:00'), Duration.ofMinutes(15)); + }) + it('adds duration at time transition', () => { + const zonedDateTime = LocalDateTime.of( + new LocalDate(2021, 5, 16), + new LocalTime(11, 0) + ).atZone(ZoneId.of('UTC')); + assert.equal(zonedDateTime.toString(), '2021-05-16T11:00:00.000+00:00'); + const sameInstantOtherZone = zonedDateTime.toInstant().atZone(ZoneId.of('Europe/Amsterdam')) + assert.equal(sameInstantOtherZone.toString(), '2021-05-16T13:00:00.000+02:00'); + }) + }); + describe('.add(DurationSpec) method', () => { + it('adds duration correctly', () => { + testAddition(Instant.parse('2021-03-28T00:45+00:00'), {years: 15}); + testAddition(Instant.parse('2021-03-28T00:45+01:00'), {days: 15}); + testAddition(Instant.parse('2021-03-28T00:45+02:00'), {hours: 15}); + testAddition(Instant.parse('2021-03-28T00:45+03:00'), {minutes: 15}); + testAddition(Instant.parse('2021-03-28T00:45+04:00'), {seconds: 15}); + testAddition(Instant.parse('2021-03-28T01:45+00:00'), {millis: 15}); + testAddition(Instant.parse('2021-03-28T01:45+01:00'), {seconds: .015}); + testAddition(Instant.parse('2021-03-28T01:45+02:00'), {years: 15}); + testAddition(Instant.parse('2021-03-28T01:45+03:00'), {days: 15}); + testAddition(Instant.parse('2021-03-28T01:45+04:00'), {hours: 15}); + testAddition(Instant.parse('2021-03-28T02:45+00:00'), {minutes: 15}); + testAddition(Instant.parse('2021-03-28T02:45+01:00'), {seconds: 15}); + testAddition(Instant.parse('2021-03-28T02:45+02:00'), {millis: 15}); + testAddition(Instant.parse('2021-03-28T02:45+03:00'), {seconds: .015}); + testAddition(Instant.parse('2021-03-28T02:45+04:00'), {years: 15}); + testAddition(Instant.parse('2021-03-28T03:45+00:00'), {days: 15}); + testAddition(Instant.parse('2021-03-28T03:45+01:00'), {hours: 15}); + testAddition(Instant.parse('2021-03-28T03:45+02:00'), {minutes: 15}); + testAddition(Instant.parse('2021-03-28T03:45+03:00'), {seconds: 15}); + testAddition(Instant.parse('2021-03-28T03:45+04:00'), {millis: 15}); + }); + }); }); describe('now() function', () => { @@ -47,16 +74,16 @@ describe('now() function', () => { }) }); - function testAddition( start: Instant, - duration: Duration, + duration: Duration | DurationSpec, ) { const end = start.add(duration); + const asDuration = (duration instanceof Duration ? duration : Duration.of(duration)); assert.equal( - duration.asMillis, + asDuration.asMillis, end.toJS().getTime() - start.toJS().getTime(), - `End time ${end.toJS()} not ${duration.asMillis}ms after start time ${start.toJS()}` + `End time ${end.toJS()} not ${asDuration.asMillis}ms after start time ${start.toJS()}` ); } \ No newline at end of file