-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
320 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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())) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: NodeJS.Timeout = 0 as any | ||
|
||
private isDirty = false | ||
private isExecuting = false | ||
|
||
constructor( | ||
private callback: () => any | Promise<any>, | ||
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import { describe, expect, test, vi } from 'vitest' | ||
import { Duration } from './duration' | ||
|
||
describe('Duration', () => { | ||
type TestCase = [keyof typeof Duration, value: number, expectedMs: number] | ||
|
||
// noinspection PointlessArithmeticExpressionJS | ||
const testCases: Array<TestCase> = [ | ||
['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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} |
Oops, something went wrong.