Skip to content

Commit

Permalink
feat: add time related utilities
Browse files Browse the repository at this point in the history
  • Loading branch information
zastrowm committed Mar 6, 2024
1 parent 17b32d1 commit 985c78c
Show file tree
Hide file tree
Showing 7 changed files with 320 additions and 0 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -47,6 +48,7 @@
}
},
"devDependencies": {
"@vitest/ui": "^1.3.1",
"onchange": "^7.1.0",
"prettier": "^3.2.4",
"release-it": "^17.0.3",
Expand Down
8 changes: 8 additions & 0 deletions src/async.ts
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()))
}
97 changes: 97 additions & 0 deletions src/time/debounce-timer.test.ts
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()
})
})
83 changes: 83 additions & 0 deletions src/time/debounce-timer.ts
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
}
}
28 changes: 28 additions & 0 deletions src/time/duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test, vi } from 'vitest'

Check failure on line 1 in src/time/duration.test.ts

View workflow job for this annotation

GitHub Actions / Build & release

'vi' is declared but its value is never read.
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)
})
})
26 changes: 26 additions & 0 deletions src/time/duration.ts
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
}
}
Loading

0 comments on commit 985c78c

Please sign in to comment.