Skip to content

Commit

Permalink
fix: [BD-26] Fix bug with end exam button and remove exam review text…
Browse files Browse the repository at this point in the history
… from submitted page (#35)

* fix: submit exam right away if user clicks end my exam button when timer reached 00:00

* fix: remove review exam text on submitted page

* tests: add new tests

* refactor: remove duplicate code from submitexam function, add helper function for submission to backend provider
  • Loading branch information
viktorrusakov authored Jul 6, 2021
1 parent 70c7ea9 commit 51b3905
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 40 deletions.
40 changes: 39 additions & 1 deletion src/data/redux.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,45 @@ describe('Data layer integration tests', () => {

const state = store.getState();
expect(loggingService.logError).toHaveBeenCalled();
expect(state.examState.apiErrorMsg).toBe('Failed to submit exam. No attempt id was found.');
expect(state.examState.apiErrorMsg).toBe('Failed to submit exam. No active attempt was found.');
});

it('Should submit exam and redirect to sequence if no exam attempt', async () => {
// this is a test for a case when user tries to click end my exam button
// from another section when timer reached 00:00, in which case exam
// should get submitted and user should get redirected to exam submitted page
const { location } = window;
delete window.location;
window.location = {
href: '',
};

axiosMock.onGet(fetchExamAttemptsDataUrl).replyOnce(200, { exam: {}, active_attempt: attempt });
axiosMock.onPut(updateAttemptStatusUrl).reply(200, { exam_attempt_id: submittedAttempt.attempt_id });

await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
const state = store.getState();
expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);

await executeThunk(thunks.submitExam(), store.dispatch, store.getState);
expect(axiosMock.history.put[0].url).toEqual(updateAttemptStatusUrl);
expect(window.location.href).toEqual(attempt.exam_url_path);

window.location = location;
});

it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(fetchExamAttemptsDataUrl).replyOnce(200, { exam: {}, active_attempt: attempt });
axiosMock.onPut(updateAttemptStatusUrl).networkError();

await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch);
let state = store.getState();
expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);

await executeThunk(thunks.submitExam(), store.dispatch, store.getState);
state = store.getState();
expect(state.examState.apiErrorMsg).toBe('Network Error');
expect(state.examState.activeAttempt.attempt_status).toBe(ExamStatus.STARTED);
});
});

Expand Down
39 changes: 28 additions & 11 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,27 +281,44 @@ export function resetExam() {
export function submitExam() {
return async (dispatch, getState) => {
const { exam, activeAttempt } = getState().examState;
const attemptId = exam.attempt.attempt_id;
const { desktop_application_js_url: workerUrl } = activeAttempt || {};
const useWorker = window.Worker && activeAttempt && workerUrl;

if (!attemptId) {
logError('Failed to submit exam. No attempt id.');
const handleBackendProviderSubmission = () => {
// if a backend provider is being used during the exam
// send it a message that exam is being submitted
if (useWorker) {
workerPromiseForEventNames(actionToMessageTypesMap.submit, workerUrl)()
.catch(() => handleAPIError(
{ message: 'Something has gone wrong submitting your exam. Please double-check that the application is running.' },
dispatch,
));
}
};

if (!activeAttempt) {
logError('Failed to submit exam. No active attempt.');
handleAPIError(
{ message: 'Failed to submit exam. No attempt id was found.' },
{ message: 'Failed to submit exam. No active attempt was found.' },
dispatch,
);
return;
}
await updateAttemptAfter(exam.course_id, exam.content_id, submitAttempt(attemptId))(dispatch);

if (useWorker) {
workerPromiseForEventNames(actionToMessageTypesMap.submit, workerUrl)()
.catch(() => handleAPIError(
{ message: 'Something has gone wrong submitting your exam. Please double-check that the application is running.' },
dispatch,
));
const { attempt_id: attemptId, exam_url_path: examUrl } = activeAttempt;
if (!exam.attempt || attemptId !== exam.attempt.attempt_id) {
try {
await submitAttempt(attemptId);
window.location.href = examUrl;
handleBackendProviderSubmission();
} catch (error) {
handleAPIError(error, dispatch);
}
return;
}

await updateAttemptAfter(exam.course_id, exam.content_id, submitAttempt(attemptId))(dispatch);
handleBackendProviderSubmission();
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/exam/Exam.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const Exam = ({ isTimeLimited, children }) => {
const {
isLoading, activeAttempt, showTimer, stopExam, exam,
expireExam, pollAttempt, apiErrorMsg, pingAttempt,
getVerificationData, getProctoringSettings,
getVerificationData, getProctoringSettings, submitExam,
} = state;

const { type: examType, id: examId } = exam || {};
Expand Down Expand Up @@ -59,6 +59,7 @@ const Exam = ({ isTimeLimited, children }) => {
<ExamTimerBlock
attempt={activeAttempt}
stopExamAttempt={stopExam}
submitExam={submitExam}
expireExamAttempt={expireExam}
pollExamAttempt={pollAttempt}
pingAttempt={pingAttempt}
Expand Down
39 changes: 15 additions & 24 deletions src/instructions/timed_exam/SubmittedTimedExamInstructions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,21 @@ const SubmittedTimedExamInstructions = () => {
const state = useContext(ExamStateContext);

return (
<>
<h3 className="h3" data-testid="exam.submittedExamInstructions.title">
{state.timeIsOver
? (
<FormattedMessage
id="exam.submittedExamInstructions.overtimeTitle"
defaultMessage="The time allotted for this exam has expired. Your exam has been submitted and any work you completed will be graded."
/>
)
: (
<FormattedMessage
id="exam.submittedExamInstructions.title"
defaultMessage="You have submitted your timed exam."
/>
)}
</h3>
<p>
<FormattedMessage
id="exam.submittedExamInstructions.text"
defaultMessage={'After the due date has passed, you can review the exam,'
+ ' but you cannot change your answers.'}
/>
</p>
</>
<h3 className="h3" data-testid="exam.submittedExamInstructions.title">
{state.timeIsOver
? (
<FormattedMessage
id="exam.submittedExamInstructions.overtimeTitle"
defaultMessage="The time allotted for this exam has expired. Your exam has been submitted and any work you completed will be graded."
/>
)
: (
<FormattedMessage
id="exam.submittedExamInstructions.title"
defaultMessage="You have submitted your timed exam."
/>
)}
</h3>
);
};

Expand Down
60 changes: 59 additions & 1 deletion src/timer/CountDownTimer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ jest.mock('../data/store', () => ({
describe('ExamTimerBlock', () => {
let attempt;
let store;
const stopExamAttempt = () => {};
const stopExamAttempt = jest.fn();
const expireExamAttempt = () => {};
const pollAttempt = () => {};
const submitAttempt = jest.fn();
submitAttempt.mockReturnValue(jest.fn());
stopExamAttempt.mockReturnValue(jest.fn());

beforeEach(async () => {
const preloadedState = {
Expand Down Expand Up @@ -172,4 +175,59 @@ describe('ExamTimerBlock', () => {
expect(screen.queryByText(/The timer on the right shows the time remaining in the exam./)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show more' })).toBeInTheDocument();
});

it('submits exam if time reached 00:00 and user clicks end my exam button', async () => {
const preloadedState = {
examState: {
isLoading: true,
timeIsOver: false,
activeAttempt: {
attempt_status: 'started',
exam_url_path: 'exam_url_path',
exam_display_name: 'exam name',
time_remaining_seconds: 1,
low_threshold_sec: 15,
critically_low_threshold_sec: 5,
exam_started_poll_url: '',
taking_as_proctored: false,
exam_type: 'a timed exam',
},
proctoringSettings: {},
exam: {},
},
};
const testStore = await initializeTestStore(preloadedState);
examStore.getState = store.testStore;
attempt = testStore.getState().examState.activeAttempt;

render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
);
await waitFor(() => expect(screen.getByText('00:00:00')).toBeInTheDocument());

fireEvent.click(screen.getByTestId('end-button', { name: 'Show more' }));
expect(submitAttempt).toHaveBeenCalledTimes(1);
});

it('stops exam if time has not reached 00:00 and user clicks end my exam button', async () => {
render(
<ExamTimerBlock
attempt={attempt}
stopExamAttempt={stopExamAttempt}
expireExamAttempt={expireExamAttempt}
pollExamAttempt={pollAttempt}
submitExam={submitAttempt}
/>,
);
await waitFor(() => expect(screen.getByText('00:00:09')).toBeInTheDocument());

fireEvent.click(screen.getByTestId('end-button'));
expect(stopExamAttempt).toHaveBeenCalledTimes(1);
});
});
21 changes: 19 additions & 2 deletions src/timer/ExamTimerBlock.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,49 @@ import {
TIMER_IS_CRITICALLY_LOW,
TIMER_IS_LOW,
TIMER_LIMIT_REACHED,
TIMER_REACHED_NULL,
} from './events';

/**
* Exam timer block component.
*/
const ExamTimerBlock = injectIntl(({
attempt, stopExamAttempt, expireExamAttempt, pollExamAttempt, intl, pingAttempt, allowEndExam,
attempt, stopExamAttempt, expireExamAttempt, pollExamAttempt,
intl, pingAttempt, submitExam, allowEndExam,
}) => {
const [isShowMore, showMore, showLess] = useToggle(false);
const [alertVariant, setAlertVariant] = useState('info');
const [timeReachedNull, setTimeReachedNull] = useState(false);

if (!attempt || !IS_STARTED_STATUS(attempt.attempt_status)) {
return null;
}

const onLowTime = () => setAlertVariant('warning');
const onCriticalLowTime = () => setAlertVariant('danger');
const onTimeReachedNull = () => setTimeReachedNull(true);

const handleEndExamClick = () => {
// if timer reached 00:00 submit exam right away
// instead of trying to move user to ready_to_submit page
if (timeReachedNull) {
submitExam();
} else {
stopExamAttempt();
}
};

useEffect(() => {
Emitter.once(TIMER_IS_LOW, onLowTime);
Emitter.once(TIMER_IS_CRITICALLY_LOW, onCriticalLowTime);
Emitter.once(TIMER_LIMIT_REACHED, expireExamAttempt);
Emitter.once(TIMER_REACHED_NULL, onTimeReachedNull);

return () => {
Emitter.off(TIMER_IS_LOW, onLowTime);
Emitter.off(TIMER_IS_CRITICALLY_LOW, onCriticalLowTime);
Emitter.off(TIMER_LIMIT_REACHED, expireExamAttempt);
Emitter.off(TIMER_REACHED_NULL, onTimeReachedNull);
};
}, []);

Expand Down Expand Up @@ -96,7 +112,7 @@ const ExamTimerBlock = injectIntl(({
{attempt.attempt_status !== ExamStatus.READY_TO_SUBMIT
&& allowEndExam
&& (
<Button className="mr-3" variant="outline-primary" onClick={stopExamAttempt}>
<Button data-testid="end-button" className="mr-3" variant="outline-primary" onClick={handleEndExamClick}>
<FormattedMessage
id="exam.examTimer.endExamBtn"
defaultMessage="End My Exam"
Expand Down Expand Up @@ -124,6 +140,7 @@ ExamTimerBlock.propTypes = {
}),
stopExamAttempt: PropTypes.func.isRequired,
expireExamAttempt: PropTypes.func.isRequired,
submitExam: PropTypes.func.isRequired,
allowEndExam: PropTypes.bool,
};

Expand Down

0 comments on commit 51b3905

Please sign in to comment.