DEV Community

Kev the Dev
Kev the Dev

Posted on • Edited on

Making a Speedrun Timer: Chapter 2

Enhancing the Timer ๐Ÿ’ช

In the previous post, we started our project by developing the building blocks of our application and creating a simple stopwatch timer. In this chapter, I would like to abstract the timer functionality and put it in a web worker. Running our code in a web worker will allow our app to run in the background, so we don't have to worry about throttling from the browser. It will also permit our timer to maintain optimal performance by operating asynchronously on another thread. You can read and learn more about a web worker here.

Abstracting the Timer

When abstracting our code, we should split our timer code into two parts:

  • The timer's core functionality, which will be running inside of a web worker.
  • The web worker's functionality, which will be the code we use to initialize our web worker and to send messages to and from the worker.

Separating the code this way will give us code clarity and allow us to unit test the code to the fullest extent.

The Timer's Core Functionality

This code won't be too complex as we are just going to port our timer functions over to their file to be used by the web worker:

// timer-worker.js
import { WorkerCommands, WorkerCommunicator } from "../helpers/timer-worker-helper";

let [milliseconds, seconds, minutes, hours] = [0, 0, 0, 0];
let timer;
let paused = false;
let prevTime;

// Initialize the function to be used when receiving a message 
(function init() {
  WorkerCommunicator.setOnMessageFunc(onMessageFunc);
})();

// The function to be called when receiving a message
export function onMessageFunc(event) {
  switch(event.data.command) {
    case WorkerCommands.START:
      timerStart();
      break;
    case WorkerCommands.STOP:
      timerStop();
      break;
    case WorkerCommands.RESET:
      timerReset();
      break;
  }
}

export function timerStart() {
  if (timer) {
    return;
  }

  paused = false;
  prevTime = new Date();
  timer = setInterval(incrementTimer, 10);
}

export function timerStop() {
  paused = true;
  clearInterval(timer);
  timer = null;
}

export function timerReset() {
  paused = true;
  clearInterval(timer);
  [milliseconds, seconds, minutes, hours] = [0, 0, 0, 0];
  paused = false;
  WorkerCommunicator.postMessageToMainThread({ 
    timerTxt: getTimeFormatString() 
  });
  timer = null;
}

// Increments the tracked time values and sends message back to main thread
function incrementTimer() {
  if (paused) {
    return;
  }

  const elapsedMilliseconds = ((new Date()) - prevTime);
  setTimeValues(elapsedMilliseconds);
  WorkerCommunicator.postMessageToMainThread({ 
    timerTxt: getTimeFormatString() 
  });
  prevTime = new Date();
}

// Helps remove redundant logic that would be used on setTimeValues
function setTimeValueHelper(prevVal, currVal, incrementor) {
  if (prevVal < incrementor) {
    return [ prevVal, currVal ];
  }

  currVal += (prevVal - (prevVal % incrementor)) / incrementor ;
  prevVal = prevVal % incrementor;
  return [ prevVal, currVal ];
}

// Set timer values to be formatted
function setTimeValues(elapsedMilliseconds) {
  milliseconds += elapsedMilliseconds;

  [ milliseconds, seconds ] = setTimeValueHelper(milliseconds, seconds, 1000);
  [ seconds, minutes ] = setTimeValueHelper(seconds, minutes, 60);
  [ minutes, hours ] = setTimeValueHelper(minutes, hours, 60);
}

// Formats the message to be sent to main thread
export function getTimeFormatString() {
  const h = hours.toLocaleString('en-US', {
    minimumIntegerDigits: 2,
    useGrouping: false
  });
  const min = minutes.toLocaleString('en-US', {
    minimumIntegerDigits: 2,
    useGrouping: false
  });
  const s = seconds.toLocaleString('en-US', {
    minimumIntegerDigits: 2,
    useGrouping: false
  });
  const ms = milliseconds.toLocaleString('en-US', {
    minimumIntegerDigits: 3,
    useGrouping: false
  });

  return `${h}:${min}:${s}.${ms}`;
}
Enter fullscreen mode Exit fullscreen mode

One notable change is the refactored setTimeValues function, which now contains less nesting and repetitive code.

You'll also notice new code where we used to set the timerTxt component variable. Since our worker will have no context of timerTxt, we have to utilize the worker's built-in functions postMessage and onmessage to provide communication from the web worker to the main thread that has access to the component. I've created a wrapper called WorkerCommunicator that encapsulates this functionality.

I've also created an object structure (WorkerCommands) that helps us define what our stopwatch worker's "start", "stop", and "reset" commands look like.

Here is the code for this abstraction:

// timer-worker-helper.js
export const WorkerCommands = {
  START: 0,
  STOP: 1,
  RESET: 2
}

// Communicates with Web Worker from main thread
export const WorkerCommunicator = {
  setOnMessageFunc: (func) => {
    onmessage = func;
  },
  postMessageToMainThread: (data) => {
    postMessage(data);
  }
};
Enter fullscreen mode Exit fullscreen mode

With this abstraction, we can isolate the worker code from the timer code and unit test the timer code extensively.

The Web Worker's Functionality

We've now moved the timer code out from the stopwatch.js composable and into it's own file called timer-worker.js. Now we need our composable to initialize a timer worker and send and receive messages from it. To do this, let's add more to our timer-worker-helper.js code:

// timer-worker-helper.js
import Worker from '../workers/timer-worker?worker';

/* NEW CODE START */
// Class wrapper for stopwatch Web Worker
export class TimerWorker {
  constructor() {
    this.worker = new Worker();
  }

  setOnMessageFunc(onMsgFunc) {
    this.worker.onmessage = onMsgFunc;
  }

  postMessageToWorker(data) {
    this.worker.postMessage(data);
  }

  terminate() {
    this.worker.terminate();
  }
}
/* NEW CODE END */

export const WorkerCommands = {
  START: 0,
  STOP: 1,
  RESET: 2
}

// Communicates with Web Worker from main thread
export const WorkerCommunicator = {
  setOnMessageFunc: (func) => {
    onmessage = func;
  },
  postMessageToWorker: (data) => {
    postMessage(data);
  }
};
Enter fullscreen mode Exit fullscreen mode

This code is very straight-forward as we create a wrapper class called TimerWorker and initialize a Worker within it. Creating a wrapper will allow us to customize the worker's functionality later on and abstract the code for testing.

Now we can implement this new TimerWorker class into our composable:

// stopwatch.js
import { ref } from 'vue';
import { TimerWorker, WorkerCommands } from '../helpers/timer-worker-helper';

export function useStopwatch() {
  let timerWorker;

  const timerTxt = ref('00:00:00.000');

  function onTimerInit() {
    if (timerWorker) {
      return;
    }

    timerWorker = new TimerWorker();
    timerWorker.setOnMessageFunc(updateTimerTxtFromWorkerMsg);
  }

  function updateTimerTxtFromWorkerMsg(event) {
    timerTxt.value = event.data.timerTxt;
  }

  function onTimerStart() {
    timerWorker.postMessageToWorker({
      command: WorkerCommands.START
    });
  }

  function onTimerStop() {
    timerWorker.postMessageToWorker({
      command: WorkerCommands.STOP
    });
  }

  function onTimerReset() {
    timerWorker.postMessageToWorker({
      command: WorkerCommands.RESET
    });
  }

  function onTimerTeardown() {
    if (!timerWorker) {
      return;
    }

    timerWorker.terminate();
    timerWorker = undefined;
  }

  return {
    timerTxt,
    onTimerStart, 
    onTimerStop, 
    onTimerReset,
    onTimerInit,
    onTimerTeardown,
    updateTimerTxtFromWorkerMsg
  };
}
Enter fullscreen mode Exit fullscreen mode

As you can see, our composable becomes a simple and easy to understand component.

We added some code here that introduced two new event functions titled onTimerInit and onTimerTeardown. We'll need to call these functions in our component when the component is mounted and unmounted:

// Stopwatch.vue
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { useStopwatch } from '../composables/stopwatch';

const {
  timerTxt,
  onTimerStart,
  onTimerStop,
  onTimerReset,
  onTimerInit,
  onTimerTeardown
} = useStopwatch();

onMounted(() => {
  onTimerInit();
});

onUnmounted(() => {
  onTimerTeardown();
});

</script>

<template>
  <div>
    <p>{{ timerTxt }}</p>
    <button @mousedown="onTimerStart">Start</button>
    <button @mousedown="onTimerStop">Stop</button>
    <button @mousedown="onTimerReset">Reset</button>
  </div>
</template>

<style scoped>
button {
  margin: 0 5px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Lastly, let's check to see if our browser supports web workers. If it doesn't, you probably shouldn't be using this application. So let's tell our users that ๐Ÿ˜‰

// App.vue
<script setup>
import { onMounted, ref } from 'vue';
import Stopwatch from './components/Stopwatch.vue';

const isSupported = ref(false);

onMounted(() => {
  isSupported.value = typeof(Worker) !== "undefined";
});

</script>

<template>
  <div>
    <stopwatch v-if="isSupported"></stopwatch>
    <div v-else>
      <p>Sorry, your browser does not support this application</p>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Writing the Tests

Writing the unit tests that involved web workers turned out to be more difficult than I expected. After time it was clear I needed to abstract the code to keep it portable and testable. I won't get into too much more detail, but here's the code for unit testing in vitest:

// stopwatch.spec.js
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { useStopwatch } from "../../src/composables/stopwatch";
import { waitForTime } from "../helpers/mock-time-helper";
import  { TimerWorker, WorkerCommunicator } from "../../src/helpers/timer-worker-helper";
import { onMessageFunc } from "../../src/workers/timer-worker";

vi.mock("../../src/helpers/timer-worker-helper", () => ({
  WorkerCommunicator: {
    postMessageToMainThread: vi.fn(),
    setOnMessageFunc: vi.fn(),
  },
  TimerWorker: vi.fn().mockImplementation(() => {
    return {
      setOnMessageFunc: vi.fn(),
      postMessageToWorker: vi.fn().mockImplementation((data) => {
        onMessageFunc({
          data: {
            command: data.command,
          },
        });
      }),
      terminate: vi.fn(),
    };
  }),
  WorkerCommands: {
    START: 0,
    STOP: 1,
    RESET: 2
  }
}));

describe("Stopwatch unit tests", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.clearAllTimers();
    vi.useRealTimers();
  });

  it("Timer start, stop, and reset", async () => {
    const {
      timerTxt,
      onTimerInit,
      onTimerTeardown,
      onTimerStart,
      onTimerStop,
      onTimerReset,
      updateTimerTxtFromWorkerMsg,
    } = useStopwatch();

    WorkerCommunicator.postMessageToMainThread.mockImplementation((data) => {
      updateTimerTxtFromWorkerMsg({
        data: {
          timerTxt: data.timerTxt,
        },
      });
    });

    onTimerInit();

    onTimerStart();
    await vi.advanceTimersByTimeAsync(10);

    expect(timerTxt.value).toEqual("00:00:00.010");
    expect(vi.getTimerCount(), "The timer was not started").toEqual(1);

    onTimerStop();
    await vi.advanceTimersByTimeAsync(10);

    expect(vi.getTimerCount(), "A timer still exists").toEqual(0);
    expect(timerTxt.value).toEqual("00:00:00.010");

    onTimerReset();
    await vi.advanceTimersByTimeAsync(10);

    expect(vi.getTimerCount(), "A timer still exists").toEqual(0);
    expect(timerTxt.value).toEqual("00:00:00.000");

    onTimerTeardown();
  });

  it("Prevent duplicate start timers", async () => {
    const { timerTxt, onTimerInit, onTimerStart, onTimerReset, onTimerTeardown, updateTimerTxtFromWorkerMsg } = useStopwatch();

    WorkerCommunicator.postMessageToMainThread.mockImplementation((data) => {
      updateTimerTxtFromWorkerMsg({
        data: {
          timerTxt: data.timerTxt,
        },
      });
    });

    onTimerInit();

    onTimerStart();
    await vi.advanceTimersByTimeAsync(10);

    expect(vi.getTimerCount(), "The timer was not started").toEqual(1);
    expect(timerTxt.value).toEqual("00:00:00.010");

    onTimerStart();
    await vi.advanceTimersByTimeAsync(10);

    expect(vi.getTimerCount(), "Invalid number of timers").toEqual(1);
    expect(timerTxt.value).toEqual("00:00:00.020");

    onTimerReset();
    await vi.advanceTimersByTimeAsync(10);

    onTimerTeardown();
  });

  it("Ensure timer increments appropriately", async () => {
    const { timerTxt, onTimerInit, onTimerTeardown, onTimerStart, onTimerReset, updateTimerTxtFromWorkerMsg } = useStopwatch();

    WorkerCommunicator.postMessageToMainThread.mockImplementation((data) => {
      updateTimerTxtFromWorkerMsg({
        data: {
          timerTxt: data.timerTxt,
        },
      });
    });

    onTimerInit();

    vi.setSystemTime(vi.getRealSystemTime());

    onTimerStart();
    await waitForTime(15);

    expect(timerTxt.value).toEqual("00:00:00.025");

    await waitForTime(2000);

    expect(timerTxt.value).toEqual("00:00:02.025");

    await waitForTime(2000 * 60);

    expect(timerTxt.value).toEqual("00:02:02.025");

    await waitForTime(1000 * 60 * 60);

    expect(timerTxt.value).toEqual("01:02:02.025");

    onTimerReset();
    await vi.advanceTimersByTimeAsync(10);

    onTimerTeardown();
  });
});
Enter fullscreen mode Exit fullscreen mode

When writing the unit tests, I ran into a problem with them timing out. I thought this was very odd, since I was using mocked time. Theoretically the tests should be completed instantly. It turns out that this is because when you call vi.advanceTimersByTimeAsync() with a large number, it calls all timers that could be invoked within that time frame recursively. For example, if I called vi.advanceTimersByTimeAsync(1000 * 60 * 60), which is simply an hour, it would recursively call incrementTimer that many times. You would need a pretty hefty computer to compute that in a short amount of time. So, I had to work around it by writing this function:

// mock-time-helper.js
import { vi } from "vitest";

export async function waitForTime(timeToWait) {
  await vi.runOnlyPendingTimersAsync();
  vi.setSystemTime(new Date(vi.getMockedSystemTime()).getTime() + timeToWait);
  await vi.advanceTimersToNextTimerAsync();
}
Enter fullscreen mode Exit fullscreen mode

Now we can utilize the system time set at the beginning of each timer test, skip the system time ahead by the elapsed time, and only run the timer code once after that.

Conclusion

Code abstraction has allowed the code to be more portable and enhance the timer by running the code in its own thread. The code also has become more testable this way, ensuring the reliability of it as we progress in development.

Here's the GitHub repo for this project. If you haven't already, be sure to give me a follow on here and other socials to see updates to this series and more!

Top comments (0)