From 0f644205e18701e4dd0be76524e6248202796be2 Mon Sep 17 00:00:00 2001 From: Marcos Rigoli Date: Mon, 4 Mar 2024 11:14:33 -0300 Subject: [PATCH] Fixed exam remaining time display offset (#140) * fix: Fixed exam remaining time display offset * fix: Removed unused time_remaining_seconds reference in TimerProvider * fix: Renamed a couple of things and removed unneeded check * fix: Updated tests and useCallback issue * chore: Added snapshots updates * fix: Fixed snapshot issue involving dates * chore: partial progress on TimerProvider coverage * chore: 100% coverage on TimerProvider.jsx * chore: Fixed son NIT comments --- package.json | 3 +- src/data/__snapshots__/redux.test.jsx.snap | 7 + src/data/redux.test.jsx | 29 ++- src/data/slice.js | 5 +- src/data/thunks.js | 3 + src/helpers.js | 17 ++ src/timer/CountDownTimer.test.jsx | 69 +++-- src/timer/TimerProvider.jsx | 128 ++++++--- src/timer/TimerProvider.test.jsx | 289 +++++++++++++++++++++ 9 files changed, 475 insertions(+), 75 deletions(-) create mode 100644 src/timer/TimerProvider.test.jsx diff --git a/package.json b/package.json index efa6ce1e..dfa19f6b 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": { diff --git a/src/data/__snapshots__/redux.test.jsx.snap b/src/data/__snapshots__/redux.test.jsx.snap index 24344fd5..b20f2915 100644 --- a/src/data/__snapshots__/redux.test.jsx.snap +++ b/src/data/__snapshots__/redux.test.jsx.snap @@ -24,6 +24,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", }, "allowProctoringOptOut": false, @@ -108,6 +109,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", }, "allowProctoringOptOut": false, @@ -201,6 +203,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", } `; @@ -396,6 +399,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", } `; @@ -415,6 +419,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", } `; @@ -434,6 +439,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", } `; @@ -453,6 +459,7 @@ Object { "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, + "timer_ends": Any, "total_time": "30 minutes", } `; diff --git a/src/data/redux.test.jsx b/src/data/redux.test.jsx index 9293f7c2..ba86b702 100644 --- a/src/data/redux.test.jsx +++ b/src/data/redux.test.jsx @@ -53,6 +53,20 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); }; + // This is a shorthand to check snapshots with an asymmetric matcher every time. + const expectSpecialExamAttemptToMatchSnapshot = (data) => expect(data).toMatchSnapshot({ + timer_ends: expect.any(String), + }); + + // This is a shorthand to check snapshots with an asymmetric matcher every time. + const expectStoreToMatchSnapshot = (data) => expect(data).toMatchSnapshot({ + specialExams: { + activeAttempt: { + timer_ends: expect.any(String), + }, + }, + }); + beforeEach(() => { initializeTestConfig(); windowSpy = jest.spyOn(window, 'window', 'get'); @@ -81,7 +95,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); const state = store.getState(); - expect(state).toMatchSnapshot(); + expectStoreToMatchSnapshot(state); }); it('Should translate total time correctly', async () => { @@ -216,7 +230,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState); state = store.getState(); - expect(state.specialExams.activeAttempt).toMatchSnapshot(); + expectSpecialExamAttemptToMatchSnapshot(state.specialExams.activeAttempt); expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ exam_id: exam.id, start_clock: 'true', @@ -233,7 +247,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState); const state = store.getState(); - expect(state.specialExams.activeAttempt).toMatchSnapshot(); + expectSpecialExamAttemptToMatchSnapshot(state.specialExams.activeAttempt); expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ exam_id: exam.id, start_clock: 'true', @@ -758,7 +772,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.startProctoredExam(), store.dispatch, store.getState); state = store.getState(); - expect(state.specialExams.activeAttempt).toMatchSnapshot(); + expectSpecialExamAttemptToMatchSnapshot(state.specialExams.activeAttempt); }); }); @@ -773,7 +787,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.startProctoredExam(), store.dispatch, store.getState); state = store.getState(); - expect(state.specialExams.activeAttempt).toMatchSnapshot(); + expectSpecialExamAttemptToMatchSnapshot(state.specialExams.activeAttempt); }); it('Should fail to fetch if no exam id', async () => { @@ -942,7 +956,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.pollAttempt(attempt.exam_started_poll_url), store.dispatch, store.getState); const state = store.getState(); - expect(state.specialExams.activeAttempt).toMatchSnapshot(); + expectSpecialExamAttemptToMatchSnapshot(state.specialExams.activeAttempt); }); describe('pollAttempt api called directly', () => { @@ -1015,8 +1029,7 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.getLatestAttemptData(courseId), store.dispatch); const state = store.getState(); - expect(state) - .toMatchSnapshot(); + expectStoreToMatchSnapshot(state); }); }); diff --git a/src/data/slice.js b/src/data/slice.js index 0b633dcc..5d27d333 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -1,4 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; +import { appendTimerEnd } from '../helpers'; /* eslint-disable no-param-reassign */ export const examSlice = createSlice({ @@ -76,10 +77,10 @@ export const examSlice = createSlice({ }, setExamState: (state, { payload }) => { state.exam = payload.exam; - state.activeAttempt = payload.activeAttempt; + state.activeAttempt = appendTimerEnd(payload.activeAttempt); }, setActiveAttempt: (state, { payload }) => { - state.activeAttempt = payload.activeAttempt; + state.activeAttempt = appendTimerEnd(payload.activeAttempt); state.apiErrorMsg = ''; }, setProctoringSettings: (state, { payload }) => { diff --git a/src/data/thunks.js b/src/data/thunks.js index b9c1230b..8a5e39f0 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -272,6 +272,9 @@ export function pollAttempt(url) { try { const data = await pollExamAttempt(url); + if (!data) { + throw new Error('Poll Exam failed to fetch.'); + } const updatedAttempt = { ...currentAttempt, time_remaining_seconds: data.time_remaining_seconds, diff --git a/src/helpers.js b/src/helpers.js index 1e3424f2..3d42ba45 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -36,3 +36,20 @@ export const generateHumanizedTime = (timeRemainingSeconds) => { } return remainingTime; }; + +// The only information we get on the remaining time on the active exam attempt +// from the endpoint is the remaining seconds. We need to have a fixed time reference +// on the time limit to be able to calculate the remaining time accurately. +export const appendTimerEnd = (activeAttempt) => { + if (!activeAttempt?.time_remaining_seconds) { + return activeAttempt; + } + + const timerEnds = new Date(Date.now() + activeAttempt.time_remaining_seconds * 1000); + const updatedActiveAttempt = { + ...activeAttempt, + timer_ends: timerEnds.toISOString(), + }; + + return updatedActiveAttempt; +}; diff --git a/src/timer/CountDownTimer.test.jsx b/src/timer/CountDownTimer.test.jsx index fd8afb8d..28f71ff9 100644 --- a/src/timer/CountDownTimer.test.jsx +++ b/src/timer/CountDownTimer.test.jsx @@ -2,9 +2,10 @@ import React from 'react'; import { waitFor } from '@testing-library/dom'; import { ExamTimerBlock } from './index'; import { - render, screen, initializeTestStore, fireEvent, + render, screen, initializeTestStore, fireEvent, act, } from '../setupTest'; import { stopExam, submitExam } from '../data'; +import { appendTimerEnd } from '../helpers'; // We do a partial mock to avoid mocking out other exported values (e.g. the store and the Emitter). jest.mock('../data', () => { @@ -29,7 +30,7 @@ describe('ExamTimerBlock', () => { specialExams: { isLoading: true, timeIsOver: false, - activeAttempt: { + activeAttempt: appendTimerEnd({ attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', @@ -37,7 +38,7 @@ describe('ExamTimerBlock', () => { exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', - }, + }), proctoringSettings: {}, exam: { time_limit_mins: 2, @@ -53,7 +54,9 @@ describe('ExamTimerBlock', () => { , ); - expect(screen.getByRole('alert')).toBeInTheDocument(); + await act(async () => { + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + }); expect(screen.getByText(attempt.exam_display_name)).toBeInTheDocument(); expect(screen.getByText('Show more')).toBeInTheDocument(); expect(screen.getAllByRole('button').length).toEqual(1); @@ -82,7 +85,9 @@ describe('ExamTimerBlock', () => { render( , ); - await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + }); expect(screen.getByRole('alert')).toHaveClass('alert-warning'); }); @@ -91,7 +96,7 @@ describe('ExamTimerBlock', () => { specialExams: { isLoading: true, timeIsOver: false, - activeAttempt: { + activeAttempt: appendTimerEnd({ attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', @@ -99,7 +104,7 @@ describe('ExamTimerBlock', () => { exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', - }, + }), proctoringSettings: {}, exam: { time_limit_mins: 2, @@ -111,7 +116,9 @@ describe('ExamTimerBlock', () => { render( , ); - await waitFor(() => expect(screen.getByText('00:00:05')).toBeInTheDocument()); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:05')).toBeInTheDocument()); + }); expect(screen.getByRole('alert')).toHaveClass('alert-danger'); }); @@ -119,7 +126,9 @@ describe('ExamTimerBlock', () => { render( , ); - await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + }); expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByLabelText('Hide Timer')).toBeInTheDocument(); @@ -136,7 +145,9 @@ describe('ExamTimerBlock', () => { render( , ); - await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + }); expect(screen.getByRole('alert')).toBeInTheDocument(); fireEvent.click(screen.getByText('Show more')); @@ -153,7 +164,7 @@ describe('ExamTimerBlock', () => { specialExams: { isLoading: true, timeIsOver: false, - activeAttempt: { + activeAttempt: appendTimerEnd({ attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', @@ -161,7 +172,7 @@ describe('ExamTimerBlock', () => { exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', - }, + }), proctoringSettings: {}, exam: { time_limit_mins: 30, @@ -174,9 +185,11 @@ describe('ExamTimerBlock', () => { render( , ); - await waitFor(() => expect(screen.getByText('00:00:00')).toBeInTheDocument()); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:00')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('end-button', { name: 'Show more' })); + }); - fireEvent.click(screen.getByTestId('end-button', { name: 'Show more' })); expect(submitExam).toHaveBeenCalledTimes(1); }); @@ -184,9 +197,12 @@ describe('ExamTimerBlock', () => { render( , ); - await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); - fireEvent.click(screen.getByTestId('end-button')); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('end-button')); + }); + expect(stopExam).toHaveBeenCalledTimes(1); }); @@ -195,7 +211,7 @@ describe('ExamTimerBlock', () => { specialExams: { isLoading: true, timeIsOver: false, - activeAttempt: { + activeAttempt: appendTimerEnd({ attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', @@ -203,7 +219,7 @@ describe('ExamTimerBlock', () => { exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', - }, + }), proctoringSettings: {}, exam: { time_limit_mins: 30, @@ -215,12 +231,15 @@ describe('ExamTimerBlock', () => { const { rerender } = render( , ); - await waitFor(() => expect(screen.getByText('00:03:59')).toBeInTheDocument()); - preloadedState.specialExams.activeAttempt = { + await act(async () => { + await waitFor(() => expect(screen.getByText('00:03:59')).toBeInTheDocument()); + }); + + preloadedState.specialExams.activeAttempt = appendTimerEnd({ ...attempt, time_remaining_seconds: 20, - }; + }); testStore = initializeTestStore(preloadedState); const updatedAttempt = testStore.getState().specialExams.activeAttempt; @@ -230,7 +249,9 @@ describe('ExamTimerBlock', () => { , ); - await waitFor(() => expect(screen.getByText('00:00:19')).toBeInTheDocument()); + await act(async () => { + await waitFor(() => expect(screen.getByText('00:00:19')).toBeInTheDocument()); + }); }); const timesToTest = { @@ -251,7 +272,7 @@ describe('ExamTimerBlock', () => { specialExams: { isLoading: true, timeIsOver: false, - activeAttempt: { + activeAttempt: appendTimerEnd({ attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', @@ -259,7 +280,7 @@ describe('ExamTimerBlock', () => { exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', - }, + }), proctoringSettings: {}, exam: { time_limit_mins: 30, diff --git a/src/timer/TimerProvider.jsx b/src/timer/TimerProvider.jsx index fa284a36..10438b54 100644 --- a/src/timer/TimerProvider.jsx +++ b/src/timer/TimerProvider.jsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } 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, @@ -15,6 +16,7 @@ const GRACE_PERIOD_SECS = 5; const POLL_INTERVAL = 60; const TIME_LIMIT_CRITICAL_PCT = 0.05; const TIME_LIMIT_LOW_PCT = 0.2; +const LIMIT = GRACE_PERIOD_SECS ? 0 - GRACE_PERIOD_SECS : 0; export const TimerContext = React.createContext({}); @@ -28,20 +30,15 @@ const TimerProvider = ({ children, }) => { const { activeAttempt: attempt, exam } = useSelector(state => state.specialExams); - const { time_limit_mins: timeLimitMins } = exam; const [timeState, setTimeState] = useState({}); - const [limitReached, setLimitReached] = useToggle(false); + const lastSignal = useRef(null); + const dispatch = useDispatch(); + const { time_limit_mins: timeLimitMins } = exam; const { desktop_application_js_url: workerUrl, ping_interval: pingInterval, - time_remaining_seconds: timeRemaining, + timer_ends: timerEnds, } = attempt; - const LIMIT = GRACE_PERIOD_SECS ? 0 - GRACE_PERIOD_SECS : 0; - const CRITICAL_LOW_TIME = timeLimitMins * 60 * TIME_LIMIT_CRITICAL_PCT; - const LOW_TIME = timeLimitMins * 60 * TIME_LIMIT_LOW_PCT; - let liveInterval = null; - - const dispatch = useDispatch(); const getTimeString = () => Object.values(timeState).map( item => { @@ -52,40 +49,66 @@ const TimerProvider = ({ }, ).join(':'); - const pollExam = () => { + const pollExam = useCallback(() => { // poll url may be null if this is an LTI exam dispatch(pollAttempt(attempt.exam_started_poll_url)); - }; + }, [attempt.exam_started_poll_url, dispatch]); - const processTimeLeft = (timer, secondsLeft) => { - if (secondsLeft <= CRITICAL_LOW_TIME) { - Emitter.emit(TIMER_IS_CRITICALLY_LOW); - } else if (secondsLeft <= LOW_TIME) { - Emitter.emit(TIMER_IS_LOW); + const processTimeLeft = useCallback((secondsLeft) => { + const emit = (signal) => { + // This prevents spamming + if (lastSignal.current === signal) { + 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 <= 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) { - clearInterval(timer); - setLimitReached(); - Emitter.emit(TIMER_LIMIT_REACHED); + + if (secondsLeft <= criticalLowTime) { + emit(TIMER_IS_CRITICALLY_LOW); + return false; + } + + if (secondsLeft <= lowTime) { + emit(TIMER_IS_LOW); + return false; } - }; + + return false; + }, [ + timeLimitMins, + ]); useEffect(() => { - let timerTick = 0; - let secondsLeft = Math.floor(timeRemaining); - // eslint-disable-next-line react-hooks/exhaustive-deps - liveInterval = setInterval(() => { - secondsLeft -= 1; - timerTick += 1; + const timerRef = { current: true }; + let timerTick = -1; + const deadline = new Date(timerEnds); + + const ticker = () => { + timerTick++; + const now = Date.now(); + const remainingTime = (deadline.getTime() - now) / 1000; + const secondsLeft = Math.floor(remainingTime); + setTimeState(getFormattedRemainingTime(secondsLeft)); - processTimeLeft(liveInterval, secondsLeft); // no polling during grace period + if (timerTick % POLL_INTERVAL === 0 && secondsLeft >= 0) { pollExam(); } @@ -93,14 +116,39 @@ const TimerProvider = ({ if (workerUrl && timerTick % pingInterval === pingInterval / 2) { dispatch(pingAttempt(pingInterval, workerUrl)); } - }, 1000); - return () => { - if (liveInterval) { - clearInterval(liveInterval); - liveInterval = null; + + const killTimer = processTimeLeft(secondsLeft); + if (killTimer) { + clearInterval(timerRef.current); + timerRef.current = null; } }; - }, [timeRemaining, dispatch]); + + // We delay the first ticker execution to give time for the emmiter + // subscribers to hook up, otherwise immediate emissions will miss their purpose. + setTimeout(() => { + ticker(); + + // 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. + timerRef.current = setInterval(ticker, 1000); + } + }); + + return () => { + clearInterval(timerRef.current); + timerRef.current = null; + }; + }, [ + timerEnds, + pingInterval, + workerUrl, + processTimeLeft, + pollExam, + dispatch, + ]); return ( // eslint-disable-next-line react/jsx-no-constructed-context-values diff --git a/src/timer/TimerProvider.test.jsx b/src/timer/TimerProvider.test.jsx new file mode 100644 index 00000000..67416545 --- /dev/null +++ b/src/timer/TimerProvider.test.jsx @@ -0,0 +1,289 @@ +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, pingIntervalSeconds = undefined }) => { + 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: pingIntervalSeconds, + }), + 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 awaitSeconds = async (seconds = 1) => { + now += 1000 * seconds; + jest.advanceTimersToNextTimer(seconds); // Proc any remaining call. + }; + + beforeAll(() => jest.useFakeTimers('modern')); + + 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 () => { + // Since the first update is delayed untill the children are rendered, we need to + // wait on it to validate the update. + 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 () => { + awaitSeconds(1); + 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 times', () => { + 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 () => { + awaitSeconds(1); + 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. + }); + + 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 () => { + awaitSeconds(1); + 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. + }); + + 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 () => { + awaitSeconds(1); + 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. + }); + + 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 () => { + awaitSeconds(1); + 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 () => { + awaitSeconds(3); + 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. + }); + }); + + describe('when the poll interval is reached (1 minute)', () => { + it('should call poll attempt each time', async () => { + const unmount = renderComponent({ remainingSeconds: 120 }); + + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + + // A first poll attempt on render. + expect(pollAttempt).toHaveBeenCalledTimes(1); + + await act(async () => awaitSeconds(60)); + + // A 2nd poll attempt should fire. + expect(pollAttempt).toHaveBeenCalledTimes(2); + + await act(async () => awaitSeconds(60)); + + // A 3rd one, just in case. + expect(pollAttempt).toHaveBeenCalledTimes(3); + + unmount(); // Cleanup. + }); + }); + + describe('when the ping interval is reached', () => { + it('should ping first at half the time, then the full delay onwards', async () => { + const unmount = renderComponent({ remainingSeconds: 120, timeLimitMins: 10, pingIntervalSeconds: 10 }); + + await act(async () => { + await waitFor(() => expect(screen.getByTestId('time-string')).toBeInTheDocument()); + }); + + // No pings so far. + expect(pingAttempt).not.toHaveBeenCalled(); + + await act(async () => awaitSeconds(5)); + + // A ping poll attempt should fire. + expect(pingAttempt).toHaveBeenCalledTimes(1); + + // Then one ping after 10. + await act(async () => awaitSeconds(10)); + + // A ping poll attempt should fire. + expect(pingAttempt).toHaveBeenCalledTimes(2); + + // Let's round it up on 6.. + await act(async () => awaitSeconds(40)); + + // A ping poll attempt should fire. + expect(pingAttempt).toHaveBeenCalledTimes(6); + + unmount(); // Cleanup. + }); + }); +});