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; }