Skip to content

Commit

Permalink
feat: [BD-26] feat: add functionality for taking exam without proctor…
Browse files Browse the repository at this point in the history
…ing (#13)

* feat: [OeX_Proctoring-144, OeX_Proctoring-182] add test coverage for all timed and proctored instructions (#21)

* feat: add test coverage for all timed and proctored instructions

Co-authored-by: Ihor Romaniuk <[email protected]>

* feat: add functionality for taking exam without proctoring

* feat: add tests for the skip proctored exam functionality

* refactor: move skip proctored exam button to its own file

* tests: add additional tests for verification page

* docs: add docstring to updateAttemptAfter function

Co-authored-by: Ihor Romaniuk <[email protected]>
Co-authored-by: Viktor Rusakov <[email protected]>
  • Loading branch information
3 people authored Jun 9, 2021
1 parent 2b9effb commit ef7110b
Show file tree
Hide file tree
Showing 19 changed files with 648 additions and 81 deletions.
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export const VerificationStatus = Object.freeze({
MUST_REVERIFY: 'must_reverify',
APPROVED: 'approved',
EXPIRED: 'expired',
NONE: 'none',
});
1 change: 1 addition & 0 deletions src/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
getProctoringSettings,
startExam,
startProctoringExam,
skipProctoringExam,
stopExam,
continueExam,
submitExam,
Expand Down
30 changes: 26 additions & 4 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,22 @@ function handleAPIError(error, dispatch) {
dispatch(setApiError({ errorMsg: message || detail }));
}

function updateAttemptAfter(courseId, sequenceId, promise = null, noLoading = false) {
/**
* Fetch attempt data and update exam state after performing another action if it is provided.
* It is assumed that action somehow modifies attempt in the backend, that's why the state needs
* to be updated.
* @param courseId - id of a course
* @param sequenceId - id of a sequence
* @param promiseToBeResolvedFirst - a promise that should get resolved before fetching attempt data
* @param noLoading - if set to false shows spinner while executing the function
*/
function updateAttemptAfter(courseId, sequenceId, promiseToBeResolvedFirst = null, noLoading = false) {
return async (dispatch) => {
if (!noLoading) { dispatch(setIsLoading({ isLoading: true })); }
if (promise) {
if (promiseToBeResolvedFirst) {
try {
const data = await promise;
if (!data || !data.exam_attempt_id) {
const response = await promiseToBeResolvedFirst;
if (!response || !response.exam_attempt_id) {
if (!noLoading) { dispatch(setIsLoading({ isLoading: false })); }
return;
}
Expand Down Expand Up @@ -123,6 +132,19 @@ export function startProctoringExam() {
};
}

export function skipProctoringExam() {
return async (dispatch, getState) => {
const { exam } = getState().examState;
if (!exam.id) {
logError('Failed to skip proctored exam. No exam id.');
return;
}
await updateAttemptAfter(
exam.course_id, exam.content_id, createExamAttempt(exam.id, true, false),
)(dispatch);
};
}

/**
* Poll exam active attempt status.
* @param url - poll attempt url
Expand Down
107 changes: 107 additions & 0 deletions src/instructions/Instructions.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '@testing-library/jest-dom';
import React from 'react';
import { fireEvent } from '@testing-library/dom';
import Instructions from './index';
import { store, getExamAttemptsData, startExam } from '../data';
import { render, screen } from '../setupTest';
Expand Down Expand Up @@ -84,6 +85,7 @@ describe('SequenceExamWrapper', () => {
},
activeAttempt: {},
exam: {
allow_proctoring_opt_out: true,
is_proctored: true,
time_limit_mins: 30,
attempt: {},
Expand All @@ -109,6 +111,12 @@ describe('SequenceExamWrapper', () => {
);

expect(getByTestId('failed-prerequisites')).toBeInTheDocument();
fireEvent.click(getByTestId('start-exam-without-proctoring-button'));
expect(getByTestId('proctored-exam-instructions-title'))
.toHaveTextContent('Are you sure you want to take this exam without proctoring?');
fireEvent.click(getByTestId('skip-cancel-exam-button'));
expect(getByTestId('start-exam-without-proctoring-button'))
.toHaveTextContent('Take this exam without proctoring.');
});

it('Shows pending prerequisites page if user has failed prerequisites for the exam', () => {
Expand All @@ -121,6 +129,7 @@ describe('SequenceExamWrapper', () => {
},
activeAttempt: {},
exam: {
allow_proctoring_opt_out: false,
is_proctored: true,
time_limit_mins: 30,
attempt: {},
Expand All @@ -145,7 +154,9 @@ describe('SequenceExamWrapper', () => {
{ store },
);

const skipProctoredExamButton = screen.queryByText('Take this exam without proctoring.');
expect(getByTestId('pending-prerequisites')).toBeInTheDocument();
expect(skipProctoredExamButton).toBeNull();
});

it('Instructions for error status', () => {
Expand Down Expand Up @@ -217,4 +228,100 @@ describe('SequenceExamWrapper', () => {
expect(screen.getByText('Your exam is ready to be resumed.')).toBeInTheDocument();
expect(screen.getByTestId('start-exam-button')).toHaveTextContent('Continue to my proctored exam.');
});

it('Instructions for ready to submit status', () => {
store.getState = () => ({
examState: {
isLoading: false,
timeIsOver: false,
verification: {
status: 'none',
can_verify: true,
},
activeAttempt: {
attempt_status: 'ready_to_submit',
},
exam: {
time_limit_mins: 30,
attempt: {
attempt_status: 'ready_to_submit',
},
},
},
});

const { getByTestId } = render(
<ExamStateProvider>
<Instructions>
<div>Sequence</div>
</Instructions>
</ExamStateProvider>,
{ store },
);
expect(getByTestId('exam-instructions-title')).toHaveTextContent('Are you sure that you want to submit your timed exam?');
});

it('Instructions for submitted status', () => {
store.getState = () => ({
examState: {
isLoading: false,
timeIsOver: false,
verification: {
status: 'none',
can_verify: true,
},
activeAttempt: {
attempt_status: 'submitted',
},
exam: {
time_limit_mins: 30,
attempt: {
attempt_status: 'submitted',
},
},
},
});

const { getByTestId } = render(
<ExamStateProvider>
<Instructions>
<div>Sequence</div>
</Instructions>
</ExamStateProvider>,
{ store },
);
expect(getByTestId('exam.submittedExamInstructions.title')).toHaveTextContent('You have submitted your timed exam.');
});

it('Instructions when exam time is over', () => {
store.getState = () => ({
examState: {
isLoading: false,
timeIsOver: true,
verification: {
status: 'none',
can_verify: true,
},
activeAttempt: {
attempt_status: 'submitted',
},
exam: {
time_limit_mins: 30,
attempt: {
attempt_status: 'submitted',
},
},
},
});

const { getByTestId } = render(
<ExamStateProvider>
<Instructions>
<div>Sequence</div>
</Instructions>
</ExamStateProvider>,
{ store },
);
expect(getByTestId('exam.submittedExamInstructions.title')).toHaveTextContent('The time allotted for this exam has expired.');
});
});
16 changes: 11 additions & 5 deletions src/instructions/index.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import StartExamInstructions from './StartExamInstructions';
import SubmitExamInstructions from './SubmitExamInstructions';
Expand All @@ -14,6 +14,7 @@ import {
DownloadSoftwareProctoredExamInstructions,
ReadyToStartProctoredExamInstructions,
PrerequisitesProctoredExamInstructions,
SkipProctoredExamInstruction,
} from './proctored_exam';
import { isEmpty } from '../helpers';
import { ExamStatus, VerificationStatus } from '../constants';
Expand All @@ -26,6 +27,8 @@ const Instructions = ({ children }) => {
const prerequisitesPassed = prerequisitesData ? prerequisitesData.are_prerequisites_satisifed : true;
let verificationStatus = verification.status || '';
const { verification_url: verificationUrl } = attempt || {};
const [skipProctoring, toggleSkipProctoring] = useState(false);
const toggleSkipProctoredExam = () => toggleSkipProctoring(!skipProctoring);

// The API does not explicitly return 'expired' status, so we have to check manually.
// expires attribute is returned only for approved status, so it is safe to do this
Expand All @@ -38,9 +41,12 @@ const Instructions = ({ children }) => {
case isEmpty(attempt):
// eslint-disable-next-line no-nested-ternary
return isProctored
? prerequisitesPassed
? <EntranceProctoredExamInstructions />
: <PrerequisitesProctoredExamInstructions />
// eslint-disable-next-line no-nested-ternary
? skipProctoring
? <SkipProctoredExamInstruction cancelSkipProctoredExam={toggleSkipProctoredExam} />
: prerequisitesPassed
? <EntranceProctoredExamInstructions skipProctoredExam={toggleSkipProctoredExam} />
: <PrerequisitesProctoredExamInstructions skipProctoredExam={toggleSkipProctoredExam} />
: <StartExamInstructions />;
case attempt.attempt_status === ExamStatus.CREATED:
return verificationStatus === VerificationStatus.APPROVED
Expand All @@ -65,7 +71,7 @@ const Instructions = ({ children }) => {
case attempt.attempt_status === ExamStatus.ERROR:
return <ErrorProctoredExamInstructions />;
case attempt.attempt_status === ExamStatus.READY_TO_RESUME:
return <EntranceProctoredExamInstructions />;
return <EntranceProctoredExamInstructions skipProctoredExam={toggleSkipProctoredExam} />;
default:
return children;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, Container } from '@edx/paragon';
import { ExamStatus } from '../../constants';
import ExamStateContext from '../../context';
import Footer from './Footer';
import SkipProctoredExamButton from './SkipProctoredExamButton';

const EntranceProctoredExamInstructions = () => {
const EntranceProctoredExamInstructions = ({ skipProctoredExam }) => {
const state = useContext(ExamStateContext);
const { exam, startProctoringExam } = state;
const { attempt } = exam || {};
const { attempt, allow_proctoring_opt_out: allowProctoringOptOut } = exam || {};
const { total_time: totalTime = 0 } = attempt;

return (
<div>
<Container className="border py-5 mb-4">
{ exam.attempt.attempt_status === ExamStatus.READY_TO_RESUME ? (
<div>
<div className="h3" data-testid="exam-instructions-title">
<div className="h3" data-testid="proctored-exam-instructions-title">
<FormattedMessage
id="exam.ReadyToResumeProctoredExamInstructions.title"
defaultMessage="Your exam is ready to be resumed."
Expand All @@ -31,14 +33,12 @@ const EntranceProctoredExamInstructions = () => {
</p>
</div>
) : (
<p>
<div className="h3" data-testid="exam-instructions-title">
<FormattedMessage
id="exam.EntranceProctoredExamInstructions.title"
defaultMessage="This exam is proctored"
/>
</div>
</p>
<div className="h3" data-testid="proctored-exam-instructions-title">
<FormattedMessage
id="exam.EntranceProctoredExamInstructions.title"
defaultMessage="This exam is proctored"
/>
</div>
)}
<p>
<FormattedMessage
Expand All @@ -65,22 +65,15 @@ const EntranceProctoredExamInstructions = () => {
/>
</Button>
</p>
<p className="mt-4 pl-md-4 mb-0">
<Button
data-testid="start-exam-without-proctoring-button"
variant="outline-secondary"
onClick={() => {}}
>
<FormattedMessage
id="exam.startExamInstructions.startExamButtonText"
defaultMessage="Take this exam without proctoring."
/>
</Button>
</p>
{allowProctoringOptOut && <SkipProctoredExamButton handleClick={skipProctoredExam} />}
</Container>
<Footer />
</div>
);
};

EntranceProctoredExamInstructions.propTypes = {
skipProctoredExam: PropTypes.func.isRequired,
};

export default EntranceProctoredExamInstructions;
Loading

0 comments on commit ef7110b

Please sign in to comment.