Skip to content

Commit

Permalink
Refactor: Convert Countdown & Timer to TypeScript (#1137)
Browse files Browse the repository at this point in the history
* refactor: Convert CountDown component to TypeScript

* refactor: Improve CountDown component & timer utility

* story: Add CountDown component story

* test: Add unit tests for Timer utility

* type: Add better typing for timer util

* test: Add unit tests for CountDown component and Timer utility

* fix(lint): Fix linting issues

* chore: Remove console.log
  • Loading branch information
drikusroor authored Jun 25, 2024
1 parent 2983d7a commit 92a5e65
Show file tree
Hide file tree
Showing 5 changed files with 303 additions and 9 deletions.
117 changes: 117 additions & 0 deletions frontend/src/components/CountDown/CountDown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, it, expect, vi, Mock } from "vitest";
import CountDown from "./CountDown";
import Timer from "@/util/timer";

// Mock the Timer utility
vi.mock("@/util/timer", () => ({
__esModule: true,
default: vi.fn(),
}));

const MockedTimer = Timer as Mock;

describe("CountDown", () => {
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});

it("should display the initial countdown value", () => {
render(<CountDown duration={10} />);

const score = screen.getByText("10");
expect(document.body.contains(score)).toBe(true);
});

it("should start the timer when running is true", async () => {
const mockTimer = vi.fn();
MockedTimer.mockImplementation(mockTimer);

render(<CountDown duration={10} running={true} />);

expect(mockTimer).toHaveBeenCalled();
});

it("should not start the timer when running is false", () => {
const mockTimer = vi.fn();
MockedTimer.mockImplementation(mockTimer);

render(<CountDown duration={10} running={false} />);

expect(mockTimer).not.toHaveBeenCalled();
});

it("should update the countdown value as the timer progresses", async () => {
let onTickCallback: (time: number) => void;
MockedTimer.mockImplementation(({ onTick }) => {
onTickCallback = onTick;
return vi.fn();
});

render(<CountDown duration={10} running={true} />);

await waitFor(() => {
const score = screen.getByText("10");
expect(document.body.contains(score)).toBe(true);
});

// Simulate timer ticks
onTickCallback!(5);
await waitFor(() => {
const score = screen.getByText("5");
expect(document.body.contains(score)).toBe(true);
});

onTickCallback!(9.5);
await waitFor(() => {
const score = screen.getByText("1");
expect(document.body.contains(score)).toBe(true);
});
});

it("should display 0 when the timer finishes", async () => {
let onFinishCallback: () => void;
MockedTimer.mockImplementation(({ onFinish }) => {
onFinishCallback = onFinish;
return vi.fn();
});

render(<CountDown duration={10} running={true} />);

await waitFor(() => {
const score = screen.getByText("10");
expect(document.body.contains(score)).toBe(true);
});

// Simulate timer finish
onFinishCallback!();
await waitFor(() => {
const score = screen.getByText("0");
expect(document.body.contains(score)).toBe(true);
});
});

it("should apply the correct classes based on the state", async () => {
const firstContainer = render(<CountDown duration={3} running={true} />);
let firstHeading = firstContainer.getByText("3");

MockedTimer.mockImplementation(({ onFinish }) => {
onFinish();
return vi.fn();
});

const secondContainer = render(<CountDown duration={5} running={true} />);

const secondHeading = secondContainer.getByText("0");

// First countdown should be active and not zero
expect(firstHeading.classList.contains('aha__count-down')).toBe(true);
expect(firstHeading.classList.contains('active')).toBe(true);
expect(firstHeading.classList.contains('zero')).toBe(false);

// Second countdown should be active and zero
expect(secondHeading.classList.contains('zero')).toBe(true);
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import React, { useState, useEffect } from "react";
import Timer from "@/util/timer";
import classNames from "classnames";

interface CountDownProps {
duration: number;
running?: boolean;
}

// CountDown to zero
const CountDown = ({ duration, running = true }) => {
const CountDown = ({ duration, running = true }: CountDownProps) => {
// automatic timer
const [time, setTime] = useState(0);
const [zero, setZero] = useState(false);
Expand All @@ -16,12 +21,8 @@ const CountDown = ({ duration, running = true }) => {
// start time when running
return Timer({
duration,
onTick: (t) => {
setTime(Math.min(t, duration));
},
onFinish: () => {
setZero(true);
},
onTick: (t) => setTime(Math.min(t, duration)),
onFinish: () => setZero(true),
});
}, [duration, running]);

Expand Down
25 changes: 25 additions & 0 deletions frontend/src/stories/CountDown.stories.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import CountDown from "../components/CountDown/CountDown";

export default {
title: "CountDown",
component: CountDown,
parameters: {
layout: "fullscreen",
},
};

export const Default = {
args: {
duration: 3,
running: true,
},
decorators: [
(Story) => (
<div
style={{ width: "100%", height: "100%", backgroundColor: "#666", padding: "1rem" }}
>
<Story />
</div>
),
],
};
136 changes: 136 additions & 0 deletions frontend/src/util/timer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import Timer from './timer';

describe('Timer', () => {
let originalRequestAnimationFrame: any;
let now = 0;

beforeEach(() => {
// Use fake timers
vi.useFakeTimers();
now = 0;
vi.spyOn(performance, 'now').mockImplementation(() => now);

// Mock requestAnimationFrame
originalRequestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (cb) => {
setTimeout(() => {
now += 1000 / 60; // Simulate 60fps
cb(performance.now());
}, 1000 / 60);
};
});

afterEach(() => {
// Clear all timers and mocks
vi.clearAllTimers();
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
});

it('should call onTick at intervals', () => {
const onTick = vi.fn();
const duration = 2;
const interval = 0.5;
const stop = Timer({
duration,
onTick,
interval,
});
// The number of ticks should be ceil(duration / interval) - 1
// as there is no tick when it finishes
const expectedTicks = Math.ceil(duration / interval) - 1;

// Advance the timer by 3 seconds
vi.advanceTimersByTime(3000);

// Check that onTick was called the correct number of times
expect(onTick).toHaveBeenCalledTimes(expectedTicks);

stop();
});

it('should call onFinish when the duration is reached', () => {
const onFinish = vi.fn();
const duration = 1;
const stop = Timer({
duration,
onFinish,
});

// Advance the timer by 2 seconds
vi.advanceTimersByTime(2000);

// Check that onFinish was called once
expect(onFinish).toHaveBeenCalledTimes(1);

stop();
});

it('should stop the timer when stop function is called', () => {
const onTick = vi.fn();
const duration = 2;
const interval = 0.5;
const stop = Timer({
duration,
onTick,
interval,
});

// Advance the timer by 1 second
vi.advanceTimersByTime(1000);

// Stop the timer
stop();

// Advance the timer by another 1 second
vi.advanceTimersByTime(1000);

// Check that onTick was called only for the first second
expect(onTick).toHaveBeenCalledTimes(2); // Should be called twice before stopping

stop();
});

it('should correctly handle the initial time parameter', () => {
const onTick = vi.fn();
const onFinish = vi.fn();
const duration = 3;
const interval = 0.5;
const time = 1;
const stop = Timer({
time,
duration,
onFinish,
onTick,
interval,
});

// Advance the timer by 500ms
vi.advanceTimersByTime(500);

expect(onFinish).not.toHaveBeenCalled(); // Should not have finished yet
expect(onTick).toHaveBeenCalledTimes(1); // Should have ticked once

// Advance the timer by another 500ms
vi.advanceTimersByTime(500);

expect(onFinish).not.toHaveBeenCalled(); // Should not have finished yet
expect(onTick).toHaveBeenCalledTimes(2); // Should have ticked twice

// Advance the timer by another 500ms
vi.advanceTimersByTime(500);

expect(onFinish).not.toHaveBeenCalled(); // Should not have finished yet
expect(onTick).toHaveBeenCalledTimes(3); // Should have ticked twice

// Advance the timer by another 500ms
vi.advanceTimersByTime(500);

// Check that onFinish was called once
expect(onFinish).toHaveBeenCalledTimes(1);
expect(onTick).toHaveBeenCalledTimes(3); // No extra tick after finishing

stop();
});
});
19 changes: 17 additions & 2 deletions frontend/src/util/timer.js → frontend/src/util/timer.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
/**
* OnTick callback type
* @param time - current time in seconds
* @param delta - time since last tick in seconds
*/
type TOnTick = (time: number, delta: number) => void;

interface TimerParams {
time?: number; // initial time in seconds
duration: number; // duration in seconds
onTick?: TOnTick;
onFinish?: () => void;
interval?: number;
}

// Timer component with callback
export const Timer = ({
time = 0,
duration,
onTick,
onFinish,
interval = 0.1,
}) => {
}: TimerParams) => {
let lastTimestamp = performance.now();
let lastTime = time;
let running = true;

const callback = (timestamp) => {
const callback = (timestamp: number) => {
if (!running) {
return;
}
Expand Down

0 comments on commit 92a5e65

Please sign in to comment.