diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e774c..c38bb71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +The format is based on [EZEZ Changelog](https://ezez.dev/changelog/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [UNRELEASED] -(nothing yet) +### Added +- `timeLeft` property +### Changed +- `restartOnly` methods supports updating timeout and instant first run +- `startOnly` methods supports updating instant first run +### Fixed +- JSDoc is invalid about return types ## [5.0.0] - 2023-05-07 ### Added diff --git a/README.md b/README.md index 7bb6a23..074c879 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ This library wraps JavaScript timers (timeout and interval) in a class to provid - 🌟 Extra features - stop repeating yourself - 🛠 First class TypeScript support - 100% type safe and intellisense friendly -- 📦 No dependencies - use it anywhere +- 📦 No dependencies - it's small and can be used anywhere - 🌎 Universal - exposes both ESM modules and CommonJS -- 🛡️ Secure - fully tested and used in production +- 🛡️ Safe - fully tested and used in production ## Quick example diff --git a/package.json b/package.json index 4a499f6..ae60fcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oop-timers", - "version": "5.0.0", + "version": "5.0.0+", "repository": "git@github.com:dzek69/oop-timers.git", "author": "Jacek Nowacki", "license": "MIT", diff --git a/src/Interval.spec.ts b/src/Interval.spec.ts index 193ee1f..b0bc9a4 100644 --- a/src/Interval.spec.ts +++ b/src/Interval.spec.ts @@ -419,4 +419,206 @@ describe("Interval", () => { callback.__spy.calls.length.must.equal(1); interval.stop(); }); + + it("allows to change interval while restarting-only the timer", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.start(); + + await wait(50); + callback.__spy.calls.must.have.length(0); + await wait(50); + callback.__spy.calls.must.have.length(1); + const restarted = interval.restartOnly(200); + restarted.must.be.true(); + + callback.__spy.reset(); + + await wait(150); + callback.__spy.calls.must.have.length(0); + await wait(50); + callback.__spy.calls.must.have.length(1); + + interval.stop(); + }); + + it("allows to change instant first run when restarting-only", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.start(); + await wait(10); + + callback.__spy.calls.must.have.length(0); + interval.restartOnly(undefined, true); + callback.__spy.calls.must.have.length(1); + interval.restartOnly(undefined, true); + callback.__spy.calls.must.have.length(2); + interval.restartOnly(undefined, false); + callback.__spy.calls.must.have.length(2); + interval.restartOnly(undefined, false); + callback.__spy.calls.must.have.length(2); + + interval.stop(); + }); + + it("allows to change instant first run when starting-only", async () => { + const callback = spy(); + const interval = new Interval(callback, 100, false, false); + callback.__spy.calls.must.have.length(0); + + interval.startOnly(true); + callback.__spy.calls.must.have.length(1); + + interval.stop(); + interval.startOnly(false); + callback.__spy.calls.must.have.length(1); + + interval.stop(); + interval.startOnly(true); + callback.__spy.calls.must.have.length(2); + + interval.startOnly(false); + interval.stop(); + interval.start(); + callback.__spy.calls.must.have.length(2); + + interval.stop(); + }); + + describe("has a function to change time", () => { + it("that works when timer is started ", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.start(); + interval.changeTime(150); + + await wait(100); + callback.__spy.calls.must.have.length(0); + + await wait(50); + callback.__spy.calls.must.have.length(1); + + await wait(150); + callback.__spy.calls.must.have.length(2); + + interval.stop(); + }); + + it("that restarts the timer to change time", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.start(); + + interval.changeTime(101); + await wait(50); + callback.__spy.calls.must.have.length(0); + interval.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(0); + interval.changeTime(101); + await wait(50); + callback.__spy.calls.must.have.length(0); + interval.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(0); + + await wait(100); + callback.__spy.calls.must.have.length(1); + + interval.stop(); + }); + + it("that works when timer is stopped", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.stop(); + interval.changeTime(150); + interval.start(); + + await wait(100); + callback.__spy.calls.must.have.length(0); + + await wait(50); + callback.__spy.calls.must.have.length(1); + + interval.stop(); + }); + + it("does not start the time if stopped", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.stop(); + interval.changeTime(150); + + interval.started.must.be.false(); + await wait(200); + callback.__spy.calls.must.have.length(0); + + interval.stop(); + }); + + it("does not restart the timer if the same exact time is given", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.start(); + + interval.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(0); + + interval.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(1); + + interval.stop(); + }); + }); + + describe("has timeLeft property", () => { + it("that returns time to next invocation", async () => { + let resolve: ((v: unknown) => void) = () => {}; + const p = new Promise((r) => { + resolve = r; + }); + + const callback = spy(async () => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const i = interval; + + await wait(25); + const time3 = i.timeLeft; + i.timeLeft.must.be.between(0, 75); + + await wait(25); + const time4 = i.timeLeft; + i.timeLeft.must.be.between(0, 50); + + time4.must.be.lt(time3); + + i.stop(); + resolve(null); + }); + + const interval = new Interval(callback, 100); + interval.start(); + + await wait(25); + const time1 = interval.timeLeft; + interval.timeLeft.must.be.between(0, 75); + + await wait(25); + const time2 = interval.timeLeft; + interval.timeLeft.must.be.between(0, 50); + + time2.must.be.lt(time1); + + await p; + }); + + it("that returns zero if timer is not started", async () => { + const callback = spy(); + const interval = new Interval(callback, 100); + interval.timeLeft.must.equal(0); + }); + }); }); diff --git a/src/Interval.ts b/src/Interval.ts index b2d21fd..6c39b7a 100644 --- a/src/Interval.ts +++ b/src/Interval.ts @@ -1,5 +1,7 @@ import { validateTime } from "./validateTime.js"; +type Nil = null | undefined; + /** * Replacement class for `setInterval` */ @@ -12,11 +14,13 @@ class Interval { private _instantFirstRun: boolean; + private _started: number = 0; + /** - * @param {function} callback - function to be called when given time passes - * @param {number} time - time in ms to fire the callback - * @param {boolean} [start] - start the interval - * @param {boolean} [instantFirstRun] - run the callback instantly + * @param callback - function to be called when given time passes + * @param time - time in ms to fire the callback + * @param start - start the interval + * @param instantFirstRun - run the callback instantly */ public constructor(callback: () => void, time: number, start: boolean = false, instantFirstRun: boolean = false) { validateTime(time); @@ -32,36 +36,74 @@ class Interval { /** * Starts or restarts the interval run * - * @param {number} [newTime] - override time to call the callback - * @param {boolean} [instantFirstRun] - run the callback instantly - * @returns {Interval} current instance + * @param newTime - override time to call the callback + * @param instantFirstRun - run the callback instantly */ - public start(newTime?: number, instantFirstRun?: boolean) { - if (newTime != null) { - validateTime(newTime); - this._time = newTime; - } - if (instantFirstRun != null) { - this._instantFirstRun = instantFirstRun; - } + public start(newTime?: number | Nil, instantFirstRun?: boolean | Nil) { + this._updateTime(newTime); + this._updateInstantFirstRun(instantFirstRun); + this.stop(); this._start(); } private _start() { if (this._time !== Infinity) { - this._timerId = setInterval(this._cb, this._time); + this._timerId = setInterval(() => { + this._started = Date.now(); + this._cb(); + }, this._time); + this._started = Date.now(); } if (this._instantFirstRun) { this._cb(); } } + private _updateTime(newTime: number | Nil) { + if (newTime == null) { + return; + } + validateTime(newTime); + this._time = newTime; + } + + private _updateInstantFirstRun(instantFirstRun: boolean | Nil) { + if (instantFirstRun == null) { + return; + } + this._instantFirstRun = instantFirstRun; + } + + /** + * Changes the interval time between the callback calls. + * If the timer is started, it will be restarted (but not when the same time is given, in this case, the timer will continue). + * If the timer is not started, new time will be used when it's started. + * + * @param newTime - time to call the callback + */ + public changeTime(newTime: number) { + if (this._time === newTime) { + return; + } + this._updateTime(newTime); + this.restartOnly(newTime); + } + /** - * Starts the interval only if it's not already started - * @returns {boolean} - true if newly started, false if already started + * Starts the interval only if it's not already started. + * + * This function does not allow you to change the time while calling it to avoid any ambiguity about if the timer + * should be restarted as usual when changing the time or not. + * It accepts an optional parameter to run the callback instantly - it will be saved for next restart or immediately + * if the interval is stopped. + * + * @param instantFirstRun - run the callback instantly + * @returns true if newly started, false if already started */ - public startOnly() { + public startOnly(instantFirstRun?: boolean | Nil) { + this._updateInstantFirstRun(instantFirstRun); + if (this._timerId !== null) { return false; } @@ -71,9 +113,15 @@ class Interval { /** * Restarts the interval only if it's already started - * @returns {boolean} - true if restarted, false if not started + * + * @param newTime - override time to call the callback + * @param instantFirstRun - run the callback instantly + * @returns true if restarted, false if not started */ - public restartOnly() { + public restartOnly(newTime?: number | Nil, instantFirstRun?: boolean | Nil) { + this._updateTime(newTime); + this._updateInstantFirstRun(instantFirstRun); + if (this._timerId === null) { return false; } @@ -84,8 +132,6 @@ class Interval { /** * Stops the interval, so callback won't be fired anymore - * - * @returns {Interval} current instance */ public stop() { if (this._timerId !== null) { @@ -97,6 +143,16 @@ class Interval { public get started() { return this._timerId !== null; } + + /** + * @returns time left in ms + */ + public get timeLeft() { + if (this._timerId === null) { + return 0; + } + return this._started + this._time - Date.now(); + } } export { Interval }; diff --git a/src/Timeout.spec.ts b/src/Timeout.spec.ts index c2e4350..7c93816 100644 --- a/src/Timeout.spec.ts +++ b/src/Timeout.spec.ts @@ -294,4 +294,144 @@ describe("Timeout", () => { callback.__spy.calls.length.must.equal(1); }); + + it("allows to change the time while restarting-only the timer", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.start(); + + await wait(100); + callback.__spy.calls.must.have.length(1); + timeout.restartOnly(50); + + await wait(100); + callback.__spy.calls.must.have.length(1); + timeout.start(); + + await wait(50); + callback.__spy.calls.must.have.length(2); + timeout.restartOnly(50); + }); + + it("allows to change the time while restarting-only, remembering new time if timer is stopped", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.stop(); + + await wait(100); + callback.__spy.calls.must.have.length(0); + timeout.restartOnly(50); + + await wait(100); + callback.__spy.calls.must.have.length(0); + timeout.start(); + + await wait(50); + callback.__spy.calls.must.have.length(1); + }); + + describe("has a function to change time", () => { + it("that works when timer is started ", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.start(); + timeout.changeTime(150); + + await wait(100); + callback.__spy.calls.must.have.length(0); + + await wait(50); + callback.__spy.calls.must.have.length(1); + }); + + it("that restarts the timer to change time", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.start(); + + timeout.changeTime(101); + await wait(50); + callback.__spy.calls.must.have.length(0); + timeout.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(0); + timeout.changeTime(101); + await wait(50); + callback.__spy.calls.must.have.length(0); + timeout.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(0); + + await wait(100); + callback.__spy.calls.must.have.length(1); + }); + + it("that works when timer is stopped", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.stop(); + timeout.changeTime(150); + timeout.start(); + + await wait(100); + callback.__spy.calls.must.have.length(0); + + await wait(50); + callback.__spy.calls.must.have.length(1); + }); + + it("does not start the time if stopped", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.stop(); + timeout.changeTime(150); + + timeout.started.must.be.false(); + await wait(200); + callback.__spy.calls.must.have.length(0); + }); + + it("does not restart the timer if the same exact time is given", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.start(); + + timeout.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(0); + + timeout.changeTime(100); + await wait(50); + callback.__spy.calls.must.have.length(1); + }); + }); + + describe("has timeLeft property", () => { + it("that returns time to next invocation", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.start(); + + await wait(25); + const time1 = timeout.timeLeft; + timeout.timeLeft.must.be.between(0, 75); + + await wait(25); + const time2 = timeout.timeLeft; + timeout.timeLeft.must.be.between(0, 50); + + time2.must.be.lt(time1); + + await wait(50); + timeout.timeLeft.must.equal(0); + await wait(50); + timeout.timeLeft.must.equal(0); + }); + + it("that returns zero if timer is not started", async () => { + const callback = spy(); + const timeout = new Timeout(callback, 100); + timeout.timeLeft.must.equal(0); + }); + }); }); diff --git a/src/Timeout.ts b/src/Timeout.ts index ed44297..7e220f9 100644 --- a/src/Timeout.ts +++ b/src/Timeout.ts @@ -1,5 +1,7 @@ import { validateTime } from "./validateTime.js"; +type Nil = null | undefined; + /** * Replacement class for `setTimeout` */ @@ -8,12 +10,14 @@ class Timeout { private _time: number; + private _started = 0; + private _timerId: ReturnType | null; /** - * @param {function} callback - function to be called when given time passes - * @param {number} time - time in ms to fire the callback - * @param {boolean} [start] - start the timer + * @param callback - function to be called when given time passes + * @param time - time in ms to fire the callback + * @param start - start the timer */ public constructor(callback: () => void, time: number, start: boolean = false) { validateTime(time); @@ -26,15 +30,13 @@ class Timeout { } /** - * Starts or restarts the timer + * Starts or restarts the timer. * - * @param {number} [newTime] - override time to call the callback + * @param newTime - override time to call the callback */ - public start(newTime?: number) { - if (newTime != null) { - validateTime(newTime); - this._time = newTime; - } + public start(newTime?: number | Nil) { + this._updateTime(newTime); + this.stop(); this._start(); } @@ -45,12 +47,40 @@ class Timeout { this._cb(); this.stop(); }, this._time); + this._started = Date.now(); + } + } + + private _updateTime(newTime: number | Nil) { + if (newTime == null) { + return; } + validateTime(newTime); + this._time = newTime; } /** - * Starts the timer only if it's not already started - * @returns {boolean} - true if newly started, false if already started + * Changes the time to call the callback. + * If the timer is started, it will be restarted (but not when the same time is given, in this case, the timer will continue). + * If the timer is not started, new time will be used when it's started. + * + * @param newTime - time to call the callback + */ + public changeTime(newTime: number) { + if (this._time === newTime) { + return; + } + this._updateTime(newTime); + this.restartOnly(newTime); + } + + /** + * Starts the timer only if it's not already started. + * + * This function does not allow you to change the time while calling it to avoid any ambiguity about if the timer + * should be restarted as usual when changing the time or not. + * + * @returns true if newly started, false if already started */ public startOnly() { if (this._timerId !== null) { @@ -61,10 +91,15 @@ class Timeout { } /** - * Restarts the timer only if it's already started - * @returns {boolean} - true if restarted, false if not started + * Restarts the timer only if it's already started. + * If newTime is given and the timer is stopped, it will be saved and used when the timer is started. + * + * @param newTime - override time to call the callback + * @returns true if restarted, false if not started */ - public restartOnly() { + public restartOnly(newTime?: number | Nil) { + this._updateTime(newTime); + if (this._timerId === null) { return false; } @@ -83,9 +118,22 @@ class Timeout { } } + /** + * @returns true if the timer is started, false otherwise + */ public get started() { return this._timerId !== null; } + + /** + * @returns time left in ms + */ + public get timeLeft() { + if (this._timerId === null) { + return 0; + } + return this._started + this._time - Date.now(); + } } export { Timeout }; diff --git a/src/__test/utils.ts b/src/__test/utils.ts index cad9d9b..bb6b7f9 100644 --- a/src/__test/utils.ts +++ b/src/__test/utils.ts @@ -1,10 +1,11 @@ -const spy = () => { +const spy = function spy(fn?: (...args: unknown[]) => unknown) { const calls: unknown[] = []; - const mySpy = function() { + const mySpy = function(...innerArgs: unknown[]) { // @ts-expect-error Whatever, TS :) // eslint-disable-next-line @typescript-eslint/no-invalid-this,@typescript-eslint/no-unsafe-assignment arguments.context = this; calls.push(arguments); // eslint-disable-line prefer-rest-params + return fn?.(...innerArgs); }; mySpy.__spy = { reset() {