diff --git a/package.json b/package.json index 92c7afb..c4ff6a4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "compile:watch": "tsc --watch", "test": "vitest run", "test:watch": "vitest", + "test:ui": "vitest --ui", "__DEV__": "", "format": "prettier src/", "format:fix": "prettier --write src/", @@ -47,6 +48,7 @@ } }, "devDependencies": { + "@vitest/ui": "^1.3.1", "onchange": "^7.1.0", "prettier": "^3.2.4", "release-it": "^17.0.3", diff --git a/src/async.ts b/src/async.ts new file mode 100644 index 0000000..061f404 --- /dev/null +++ b/src/async.ts @@ -0,0 +1,8 @@ +import { Duration } from './time/duration' + +/** + * Return a promise that resolves in the given amount of time + */ +export function delay(duration: Duration) { + return new Promise((resolve) => setTimeout(resolve, duration.toMilliseconds())) +} diff --git a/src/time/debounce-timer.test.ts b/src/time/debounce-timer.test.ts new file mode 100644 index 0000000..e2eba10 --- /dev/null +++ b/src/time/debounce-timer.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, test, vi, vitest } from 'vitest' +import { DebounceTimer } from './debounce-timer' +import { Duration } from './duration' +import { delay } from '../async' + +describe('debounce-timer', () => { + beforeEach(() => { + // tell vitest we use mocked time + vi.useFakeTimers() + }) + + function createTimer(duration: Duration, timercallback = () => {}) { + const callback = vi.fn(timercallback) + + const timer: DebounceTimer = new DebounceTimer(callback, duration) + timer.retrigger() + + afterEach(() => { + timer.dispose() + }) + + return { timer, callback } + } + + test('invokes callback after elapsed time', () => { + const duration = Duration.fromSeconds(1) + const { callback } = createTimer(duration) + + vitest.advanceTimersByTime(duration.toMilliseconds()) + + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('does not invoke before elapsed time', () => { + const duration = Duration.fromSeconds(1) + const { callback } = createTimer(duration) + + vitest.advanceTimersByTime(duration.toMilliseconds() - 1) + + expect(callback).toHaveBeenCalledTimes(0) + + vitest.advanceTimersByTime(1) + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('calling retrigger() debounced the timer', () => { + const duration = Duration.fromSeconds(1) + const { callback, timer } = createTimer(duration) + + vitest.advanceTimersByTime(duration.toMilliseconds() - 1) + timer.retrigger() + + vitest.advanceTimersByTime(duration.toMilliseconds() - 1) + expect(callback).toHaveBeenCalledTimes(0) + + vitest.advanceTimersByTime(1) + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('retriggering can be done from the callback (sync)', () => { + const duration = Duration.fromSeconds(1) + const { callback, timer } = createTimer(duration, () => { + if (callback.mock.calls.length == 1) { + timer.retrigger() + } + }) + + vitest.advanceTimersByTime(duration.toMilliseconds()) + expect(callback).toHaveBeenCalledTimes(1) + expect(timer.isPendingExecution).toBeTruthy() + + vitest.advanceTimersByTime(duration.toMilliseconds()) + expect(callback).toHaveBeenCalledTimes(2) + expect(timer.isPendingExecution).toBeFalsy() + }) + + test('retriggering can be done from the callback (async)', async () => { + const duration = Duration.fromSeconds(1) + const { callback, timer } = createTimer(duration, async () => { + if (callback.mock.calls.length == 1) { + await delay(Duration.fromMilliseconds(1)) + timer.retrigger() + } + }) + + await vitest.advanceTimersByTimeAsync(duration.toMilliseconds()) + expect(callback).toHaveBeenCalledTimes(1) + expect(timer.isPendingExecution).toBeFalsy() + + await vitest.advanceTimersByTimeAsync(1) + expect(timer.isPendingExecution).toBeTruthy() + + await vitest.advanceTimersByTimeAsync(duration.toMilliseconds()) + expect(callback).toHaveBeenCalledTimes(2) + expect(timer.isPendingExecution).toBeFalsy() + }) +}) diff --git a/src/time/debounce-timer.ts b/src/time/debounce-timer.ts new file mode 100644 index 0000000..aadcc67 --- /dev/null +++ b/src/time/debounce-timer.ts @@ -0,0 +1,83 @@ +import { Duration } from './duration' +import { Releasable } from '../typing/types' + +/** + * A timer that invokes the given callback a fixed amount of time after the last + * invocation to retrigger(). Invoking retrigger() resets amount of time until the + * callback is invoked. + */ +export class DebounceTimer implements Releasable { + private readonly timeInMs: number + + private timerId = 0 as any + + private isDirty = false + private isExecuting = false + + constructor( + private callback: () => any | Promise, + time: Duration, + ) { + this.timeInMs = time.toMilliseconds() + } + + /** + * True if the timer needs to be executed + */ + public get isPendingExecution() { + return this.isDirty && this.timerId + } + + /** + * Stops the timer from running, if it is queued to run + */ + public dispose() { + this.stop() + } + + /** + * Stops the timer from running, if it is queued to run + */ + public stop() { + clearTimeout(this.timerId) + this.timerId = 0 as any + this.isDirty = false + } + + /** + * Trigger the timer to run after the pre-specified delay of time + */ + public retrigger() { + this.isDirty = true + + // reset the timer if we're not currently executing + if (!this.isExecuting) { + this.restartTimer() + } + } + + private async execute() { + try { + this.isExecuting = true + this.stop() + + const value = this.callback() + // todo extract to helper + if (value && 'then' in value) { + await value + } + } finally { + this.isExecuting = false + + if (this.isDirty) { + this.restartTimer() + } + } + } + + private restartTimer() { + this.stop() + this.timerId = setTimeout(() => this.execute(), this.timeInMs) + this.isDirty = true + } +} diff --git a/src/time/duration.test.ts b/src/time/duration.test.ts new file mode 100644 index 0000000..3047cd5 --- /dev/null +++ b/src/time/duration.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest' +import { Duration } from './duration' + +describe('Duration', () => { + type TestCase = [keyof typeof Duration, value: number, expectedMs: number] + + // noinspection PointlessArithmeticExpressionJS + const testCases: Array = [ + ['fromMilliseconds', 100, 100], + ['fromMilliseconds', 10, 10], + ['fromSeconds', 1, 1 * 1000], + ['fromSeconds', 1.5, 1.5 * 1000], + ['fromSeconds', -100, -100 * 1000], + ['fromMinutes', 5, 5 * 60 * 1000], + ] + + test.each(testCases)('%s(%d).toMilliseconds() == %d', (method, value, expected) => { + const callback = Duration[method] as (value: number) => Duration + + expect(callback(value).toMilliseconds()).toBe(expected) + }) + + test.each(testCases)('%s(%d).toSeconds() == %d', (method, value, expected) => { + const callback = Duration[method] as (value: number) => Duration + + expect(callback(value).toSeconds()).toBe(expected / 1000) + }) +}) diff --git a/src/time/duration.ts b/src/time/duration.ts new file mode 100644 index 0000000..3d883c8 --- /dev/null +++ b/src/time/duration.ts @@ -0,0 +1,26 @@ +/** + * Strongly typed class to represent an amount of time + */ +export class Duration { + private constructor(private ms: number) {} + + static fromMilliseconds(ms: number) { + return new Duration(ms) + } + + static fromSeconds(seconds: number) { + return new Duration(seconds * 1000) + } + + static fromMinutes(minutes: number) { + return new Duration(minutes * 60 * 1000) + } + + public toMilliseconds() { + return this.ms + } + + public toSeconds() { + return this.ms / 1000 + } +} diff --git a/yarn.lock b/yarn.lock index 6e70050..d2bc776 100644 --- a/yarn.lock +++ b/yarn.lock @@ -249,6 +249,7 @@ __metadata: version: 0.0.0-use.local resolution: "@kreativ/core@workspace:." dependencies: + "@vitest/ui": ^1.3.1 onchange: ^7.1.0 prettier: ^3.2.4 release-it: ^17.0.3 @@ -475,6 +476,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.24 + resolution: "@polka/url@npm:1.0.0-next.24" + checksum: 00baec4458ac86ca27edf7ce807ccfad97cd1d4b67bdedaf3401a9e755757588f3331e891290d1deea52d88df2bf2387caf8d94a6835b614d5b37b638a688273 + languageName: node + linkType: hard + "@rollup/rollup-android-arm-eabi@npm:4.9.5": version: 4.9.5 resolution: "@rollup/rollup-android-arm-eabi@npm:4.9.5" @@ -659,6 +667,23 @@ __metadata: languageName: node linkType: hard +"@vitest/ui@npm:^1.3.1": + version: 1.3.1 + resolution: "@vitest/ui@npm:1.3.1" + dependencies: + "@vitest/utils": 1.3.1 + fast-glob: ^3.3.2 + fflate: ^0.8.1 + flatted: ^3.2.9 + pathe: ^1.1.1 + picocolors: ^1.0.0 + sirv: ^2.0.4 + peerDependencies: + vitest: 1.3.1 + checksum: 76fccb955cd305db6d2cb797e9ce6fe892b6116e7f1feecbcc04ec448c958a28ced53d0947e75b8a22754022fcfe31c08f5a795cfe632fddf29f4640d8d15a47 + languageName: node + linkType: hard + "@vitest/utils@npm:1.2.1": version: 1.2.1 resolution: "@vitest/utils@npm:1.2.1" @@ -671,6 +696,18 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:1.3.1": + version: 1.3.1 + resolution: "@vitest/utils@npm:1.3.1" + dependencies: + diff-sequences: ^29.6.3 + estree-walker: ^3.0.3 + loupe: ^2.3.7 + pretty-format: ^29.7.0 + checksum: dab1f66c223a4de90d01a9ba03a6110edd110794675a9e73a2b3af689bbaee2371a0a0afd93e6b9447bcf61659c60727ece343a3e04b734f178f542a53586ef0 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -1841,6 +1878,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.1": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 29470337b85d3831826758e78f370e15cda3169c5cd4477c9b5eea2402261a74b2975bae816afabe1c15d21d98591e0d30a574f7103aa117bff60756fa3035d4 + languageName: node + linkType: hard + "figures@npm:^5.0.0": version: 5.0.0 resolution: "figures@npm:5.0.0" @@ -1860,6 +1904,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.2.9": + version: 3.3.1 + resolution: "flatted@npm:3.3.1" + checksum: 85ae7181650bb728c221e7644cbc9f4bf28bc556f2fc89bb21266962bdf0ce1029cc7acc44bb646cd469d9baac7c317f64e841c4c4c00516afa97320cdac7f94 + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -3298,6 +3349,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.0 + resolution: "mrmime@npm:2.0.0" + checksum: f6fe11ec667c3d96f1ce5fd41184ed491d5f0a5f4045e82446a471ccda5f84c7f7610dff61d378b73d964f73a320bd7f89788f9e6b9403e32cc4be28ba99f569 + languageName: node + linkType: hard + "ms@npm:2.1.2": version: 2.1.2 resolution: "ms@npm:2.1.2" @@ -4308,6 +4366,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^2.0.4": + version: 2.0.4 + resolution: "sirv@npm:2.0.4" + dependencies: + "@polka/url": ^1.0.0-next.24 + mrmime: ^2.0.0 + totalist: ^3.0.0 + checksum: 6853384a51d6ee9377dd657e2b257e0e98b29abbfbfa6333e105197f0f100c8c56a4520b47028b04ab1833cf2312526206f38fcd4f891c6df453f40da1a15a57 + languageName: node + linkType: hard + "slash@npm:^5.1.0": version: 5.1.0 resolution: "slash@npm:5.1.0" @@ -4597,6 +4666,13 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 5132d562cf88ff93fd710770a92f31dbe67cc19b5c6ccae2efc0da327f0954d211bbfd9456389655d726c624f284b4a23112f56d1da931ca7cfabbe1f45e778a + languageName: node + linkType: hard + "tree-kill@npm:^1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2"