Skip to content

Commit

Permalink
chore: partial progress on TimerProvider coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
rijuma committed Feb 29, 2024
1 parent abeb7a4 commit 118b52c
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 38 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -89,4 +90,4 @@
"rosie": "2.0.1",
"semantic-release": "^20.1.3"
}
}
}
4 changes: 1 addition & 3 deletions src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
76 changes: 43 additions & 33 deletions src/timer/TimerProvider.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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;

Check warning on line 61 in src/timer/TimerProvider.jsx

View check run for this annotation

Codecov / codecov/patch

src/timer/TimerProvider.jsx#L61

Added line #L61 was not covered by tests
}
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));
Expand All @@ -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;
}
};

Expand All @@ -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,
Expand Down
236 changes: 236 additions & 0 deletions src/timer/TimerProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<>
<div data-testid="time-string">{timeString}</div>
<pre data-testid="time-state">{timeStateString}</pre>
</>
) : null);
};

const TestComponent = () => (
<TimerProvider>
<TestingComponent />
</TimerProvider>
);

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(<TestComponent />, { 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
});
});
});

0 comments on commit 118b52c

Please sign in to comment.