diff --git a/frontend/src/components/CountDown/CountDown.test.tsx b/frontend/src/components/CountDown/CountDown.test.tsx
new file mode 100644
index 000000000..36aa951f6
--- /dev/null
+++ b/frontend/src/components/CountDown/CountDown.test.tsx
@@ -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();
+
+ 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();
+
+ expect(mockTimer).toHaveBeenCalled();
+ });
+
+ it("should not start the timer when running is false", () => {
+ const mockTimer = vi.fn();
+ MockedTimer.mockImplementation(mockTimer);
+
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+ let firstHeading = firstContainer.getByText("3");
+
+ MockedTimer.mockImplementation(({ onFinish }) => {
+ onFinish();
+ return vi.fn();
+ });
+
+ const secondContainer = render();
+
+ 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);
+ });
+
+});
\ No newline at end of file
diff --git a/frontend/src/components/CountDown/CountDown.jsx b/frontend/src/components/CountDown/CountDown.tsx
similarity index 73%
rename from frontend/src/components/CountDown/CountDown.jsx
rename to frontend/src/components/CountDown/CountDown.tsx
index cef794f65..bee6f8b70 100644
--- a/frontend/src/components/CountDown/CountDown.jsx
+++ b/frontend/src/components/CountDown/CountDown.tsx
@@ -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);
@@ -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]);
diff --git a/frontend/src/stories/CountDown.stories.jsx b/frontend/src/stories/CountDown.stories.jsx
new file mode 100644
index 000000000..4559525c1
--- /dev/null
+++ b/frontend/src/stories/CountDown.stories.jsx
@@ -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) => (
+
+
+
+ ),
+ ],
+};
\ No newline at end of file
diff --git a/frontend/src/util/timer.test.ts b/frontend/src/util/timer.test.ts
new file mode 100644
index 000000000..c074e150f
--- /dev/null
+++ b/frontend/src/util/timer.test.ts
@@ -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();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/util/timer.js b/frontend/src/util/timer.ts
similarity index 67%
rename from frontend/src/util/timer.js
rename to frontend/src/util/timer.ts
index e3978568a..038304594 100644
--- a/frontend/src/util/timer.js
+++ b/frontend/src/util/timer.ts
@@ -1,3 +1,18 @@
+/**
+ * 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,
@@ -5,12 +20,12 @@ export const Timer = ({
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;
}