diff --git a/package.json b/package.json index efa6ce1e..b602ea15 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lint": "fedx-scripts eslint --ext .js --ext .jsx .", "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .", "snapshot": "fedx-scripts jest --updateSnapshot", - "test": "fedx-scripts jest --coverage --passWithNoTests" + "test": "fedx-scripts jest --coverage --passWithNoTests", + "test:watch": "fedx-scripts jest --passWithNoTests --watch" }, "husky": { "hooks": { @@ -89,4 +90,4 @@ "rosie": "2.0.1", "semantic-release": "^20.1.3" } -} +} \ No newline at end of file diff --git a/src/helpers.js b/src/helpers.js index f5da5ba7..3d42ba45 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -45,9 +45,7 @@ export const appendTimerEnd = (activeAttempt) => { return activeAttempt; } - const timerEnds = new Date(); - timerEnds.setSeconds(timerEnds.getSeconds() + activeAttempt.time_remaining_seconds); - + const timerEnds = new Date(Date.now() + activeAttempt.time_remaining_seconds * 1000); const updatedActiveAttempt = { ...activeAttempt, timer_ends: timerEnds.toISOString(), diff --git a/src/timer/TimerProvider.jsx b/src/timer/TimerProvider.jsx index d657f455..746249e7 100644 --- a/src/timer/TimerProvider.jsx +++ b/src/timer/TimerProvider.jsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { + useEffect, useState, useCallback, useRef, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; -import { useToggle } from '@edx/paragon'; import { Emitter, pollAttempt, pingAttempt } from '../data'; import { TIMER_IS_CRITICALLY_LOW, @@ -30,7 +31,7 @@ const TimerProvider = ({ }) => { const { activeAttempt: attempt, exam } = useSelector(state => state.specialExams); const [timeState, setTimeState] = useState({}); - const [limitReached, setLimitReached] = useToggle(false); + const lastSignal = useRef(null); const dispatch = useDispatch(); const { time_limit_mins: timeLimitMins } = exam; const { @@ -54,45 +55,55 @@ const TimerProvider = ({ }, [attempt.exam_started_poll_url, dispatch]); const processTimeLeft = useCallback((secondsLeft) => { + const emit = (signal) => { + // This prevents spamming + if (lastSignal.current === lastSignal) { + return; + } + Emitter.emit(signal); + lastSignal.current = signal; + }; + const criticalLowTime = timeLimitMins * 60 * TIME_LIMIT_CRITICAL_PCT; const lowTime = timeLimitMins * 60 * TIME_LIMIT_LOW_PCT; - if (secondsLeft <= criticalLowTime) { - Emitter.emit(TIMER_IS_CRITICALLY_LOW); - } else if (secondsLeft <= lowTime) { - Emitter.emit(TIMER_IS_LOW); + if (secondsLeft <= LIMIT) { + emit(TIMER_LIMIT_REACHED); + return true; // Kill the timer. } - // Used to hide continue exam button on submit exam pages. - // Since TIME_LIMIT_REACHED is fired after the grace period we - // need to emit separate event when timer reaches 00:00 + if (secondsLeft <= 0) { - Emitter.emit(TIMER_REACHED_NULL); + // Used to hide continue exam button on submit exam pages. + // Since TIME_LIMIT_REACHED is fired after the grace period we + // need to emit separate event when timer reaches 00:00 + emit(TIMER_REACHED_NULL); + return false; } - if (!limitReached && secondsLeft < LIMIT) { - setLimitReached(); - Emitter.emit(TIMER_LIMIT_REACHED); + if (secondsLeft <= criticalLowTime) { + emit(TIMER_IS_CRITICALLY_LOW); + return false; + } - return false; // Stop the time ticker. + if (secondsLeft <= lowTime) { + emit(TIMER_IS_LOW); + return false; } - return true; + return false; }, [ timeLimitMins, - limitReached, - setLimitReached, ]); useEffect(() => { - let timerHandler = true; + const timerRef = { current: true }; let timerTick = -1; const deadline = new Date(timerEnds); - let timerRef = null; const ticker = () => { timerTick++; - const now = new Date(); - const remainingTime = (deadline.getTime() - now.getTime()) / 1000; + const now = Date.now(); + const remainingTime = (deadline.getTime() - now) / 1000; const secondsLeft = Math.floor(remainingTime); setTimeState(getFormattedRemainingTime(secondsLeft)); @@ -105,10 +116,10 @@ const TimerProvider = ({ dispatch(pingAttempt(pingInterval, workerUrl)); } - const keepTimerRunning = processTimeLeft(secondsLeft); - if (!keepTimerRunning) { - clearInterval(timerHandler); - timerHandler = null; + const killTimer = processTimeLeft(secondsLeft); + if (killTimer) { + clearInterval(timerRef.current); + timerRef.current = null; } }; @@ -117,18 +128,17 @@ const TimerProvider = ({ setTimeout(() => { ticker(); - // If the timer handler is not true it means that it was stopped in the first run. - if (timerHandler === true) { + // If the timer handler is not true at this point, it means that it was stopped in the first run. + // So we don't need to start the timer. + if (timerRef.current === true) { // After the first run, we start the ticker. - timerHandler = setInterval(ticker, 1000); + timerRef.current = setInterval(ticker, 1000); } }); return () => { - if (timerRef) { - clearInterval(timerHandler); - timerRef = null; - } + clearInterval(timerRef.current); + timerRef.current = null; }; }, [ timerEnds, diff --git a/src/timer/TimerProvider.test.jsx b/src/timer/TimerProvider.test.jsx new file mode 100644 index 00000000..0997867e --- /dev/null +++ b/src/timer/TimerProvider.test.jsx @@ -0,0 +1,236 @@ +import { useContext } from 'react'; +import { appendTimerEnd } from '../helpers'; +import TimerProvider, { TimerContext } from './TimerProvider'; +import { + render, screen, initializeTestStore, act, waitFor, +} from '../setupTest'; +import { Emitter, pollAttempt, pingAttempt } from '../data'; +import { + TIMER_IS_CRITICALLY_LOW, + TIMER_IS_LOW, + TIMER_LIMIT_REACHED, + TIMER_REACHED_NULL, +} from './events'; + +jest.mock('../data', () => ({ + Emitter: { emit: jest.fn() }, + pollAttempt: jest.fn(), + pingAttempt: jest.fn(), +})); + +const mockedDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn().mockImplementation(() => mockedDispatch), +})); + +const TestingComponent = () => { + const { timeState, getTimeString } = useContext(TimerContext); + const timeString = getTimeString(); + const timeStateString = JSON.stringify(timeState); + return (timeString ? ( + <> +
{timeString}
+
{timeStateString}
+ + ) : null); +}; + +const TestComponent = () => ( + + + +); + +const renderComponent = ({ remainingSeconds, timeLimitMins = 2 }) => { + const store = initializeTestStore({ + specialExams: { + activeAttempt: appendTimerEnd({ + time_remaining_seconds: remainingSeconds, + exam_started_poll_url: 'https://some-poll.endpoint', + desktop_application_js_url: 'https://desktop-application.js?url=42', + ping_interval: 10, + }), + exam: { + time_limit_mins: timeLimitMins, + }, + }, + }); + + const { unmount } = render(, { store }); + return unmount; +}; + +const testRefDate = (new Date('2024-01-01 01:00:00')).getTime(); + +describe('TimerProvider', () => { + let now = testRefDate; + + // This syncs up the reference date returned by Date.now() and the jest timers. + const advanceTime = (ms) => { + now += ms; + jest.advanceTimersToNextTimer(); + }; + + beforeAll(() => jest.useFakeTimers()); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + afterAll(() => jest.useRealTimers()); + + describe('when the remaining time is plenty', () => { + it('should render normally', async () => { + const unmount = renderComponent({ remainingSeconds: 60 }); + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + expect(screen.getByTestId('time-string')).toHaveTextContent('00:01:00'); + expect(screen.getByTestId('time-state')).toHaveTextContent(JSON.stringify({ + hours: 0, + minutes: 1, + seconds: 0, + })); + + await act(async () => { + advanceTime(1000); + await waitFor(() => expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:59')); + }); + + expect(screen.getByTestId('time-state')).toHaveTextContent(JSON.stringify({ + hours: 0, + minutes: 0, + seconds: 59, + })); + + expect(Emitter.emit).not.toHaveBeenCalled(); + + // No Poll calls in between + expect(pollAttempt).toHaveBeenCalledTimes(1); + + // No Ping attempts + expect(pingAttempt).not.toHaveBeenCalled(); + + unmount(); // Cleanup + }); + }); + + describe('when the remaining falls under the warning time', () => { + it('should emit TIMER_IS_LOW when the timer falls under the threshold (40%)', async () => { + const unmount = renderComponent({ remainingSeconds: 25 }); + + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:25'); + + expect(Emitter.emit).not.toHaveBeenCalled(); + expect(pingAttempt).not.toHaveBeenCalled(); + + // The next second should trigger the warning. + await act(async () => { + advanceTime(1000); + await waitFor(() => expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:24')); + }); + + expect(Emitter.emit).toHaveBeenCalledTimes(1); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_IS_LOW); + + unmount(); // Cleanup + }); + }); + + describe('when the remaining falls under the critical time', () => { + it('should emit TIMER_IS_LOW when the timer falls under the threshold (10%)', async () => { + const unmount = renderComponent({ remainingSeconds: 7 }); + + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:07'); + + // Low timer warning is called first render + expect(Emitter.emit).toHaveBeenCalledTimes(1); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_IS_LOW); + + // The next second should trigger the critical warning. + await act(async () => { + advanceTime(1000); + await waitFor(() => expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:06')); + }); + + expect(Emitter.emit).toHaveBeenCalledTimes(2); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_IS_CRITICALLY_LOW); + + unmount(); // Cleanup + }); + }); + + describe('when the timer reaches zero and there is a grace period', () => { + it('should emit TIMER_REACHED_NULL when the timer falls under the threshold (10%)', async () => { + const unmount = renderComponent({ remainingSeconds: 1 }); + + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:01'); + + // Critical timer warning is called first render + expect(Emitter.emit).toHaveBeenCalledTimes(1); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_IS_CRITICALLY_LOW); + + // The next second should trigger the critical warning. + await act(async () => { + advanceTime(1000); + await waitFor(() => expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:00')); + }); + + expect(Emitter.emit).toHaveBeenCalledTimes(2); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_REACHED_NULL); + + unmount(); // Cleanup + }); + }); + + describe('when the grace period ends', () => { + it('should emit TIMER_LIMIT_REACHED when the timer falls under the grace period (5 secs)', async () => { + const unmount = renderComponent({ remainingSeconds: -4 }); + + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:00'); + + // Timer is null is called first render + expect(Emitter.emit).toHaveBeenCalledTimes(1); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_REACHED_NULL); + + // The next second should kill the exam. + await act(async () => { + advanceTime(1000); + await waitFor(() => expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:00')); + }); + + expect(Emitter.emit).toHaveBeenCalledTimes(2); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_LIMIT_REACHED); + + // Lets just wait a couple more seconds and check that the timer was killed as well. + await act(async () => { + advanceTime(3000); + await waitFor(() => expect(screen.getByTestId('time-string')).toHaveTextContent('00:00:00')); + }); + + // Emitter should be exactly as before + expect(Emitter.emit).toHaveBeenCalledTimes(2); + expect(Emitter.emit).toHaveBeenCalledWith(TIMER_LIMIT_REACHED); + + unmount(); // Cleanup + }); + }); +});