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 ? ( + <> +
{timeStateString}+ > + ) : null); +}; + +const TestComponent = () => ( +