From d4bfcc3d94d66d0a9892718abf145bbf4790fc9a Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Wed, 22 Mar 2023 10:57:48 -0400 Subject: [PATCH] feat: calculate timer low/critical cutoff in UI (#93) --- src/core/OuterExamTimer.test.jsx | 4 ++- src/data/__factories__/attempt.factory.js | 2 -- src/data/__snapshots__/redux.test.jsx.snap | 22 ------------ src/data/slice.js | 2 -- src/timer/CountDownTimer.test.jsx | 42 +++++++++++----------- src/timer/ExamTimerBlock.jsx | 2 -- src/timer/TimerProvider.jsx | 19 +++++----- 7 files changed, 34 insertions(+), 59 deletions(-) diff --git a/src/core/OuterExamTimer.test.jsx b/src/core/OuterExamTimer.test.jsx index 92fdbe81..fb6984c2 100644 --- a/src/core/OuterExamTimer.test.jsx +++ b/src/core/OuterExamTimer.test.jsx @@ -30,7 +30,9 @@ describe('OuterExamTimer', () => { store.getState = () => ({ examState: { activeAttempt: attempt, - exam: {}, + exam: { + time_limit_mins: 60, + }, }, }); diff --git a/src/data/__factories__/attempt.factory.js b/src/data/__factories__/attempt.factory.js index 7090d000..885a9b49 100644 --- a/src/data/__factories__/attempt.factory.js +++ b/src/data/__factories__/attempt.factory.js @@ -10,8 +10,6 @@ Factory.define('attempt') exam_display_name: 'timed', exam_url_path: 'http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123', time_remaining_seconds: 1799.9, - low_threshold_sec: 360, - critically_low_threshold_sec: 90, course_id: 'course-v1:test+special+exam', accessibility_time_string: 'you have 30 minutes remaining', exam_started_poll_url: '/api/edx_proctoring/v1/proctored_exam/attempt/1', diff --git a/src/data/__snapshots__/redux.test.jsx.snap b/src/data/__snapshots__/redux.test.jsx.snap index 2a62833a..da34117e 100644 --- a/src/data/__snapshots__/redux.test.jsx.snap +++ b/src/data/__snapshots__/redux.test.jsx.snap @@ -9,14 +9,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -31,14 +29,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -97,14 +93,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -144,14 +138,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -166,14 +158,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -232,14 +222,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -320,14 +308,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -342,14 +328,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -370,14 +354,12 @@ Object { "attempt_id": 2, "attempt_status": "created", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -434,14 +416,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, @@ -456,14 +436,12 @@ Object { "attempt_id": 1, "attempt_status": "started", "course_id": "course-v1:test+special+exam", - "critically_low_threshold_sec": 90, "desktop_application_js_url": "", "exam_display_name": "timed", "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", "exam_type": "a timed exam", "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", "in_timed_exam": true, - "low_threshold_sec": 360, "software_download_url": "", "taking_as_proctored": false, "time_remaining_seconds": 1799.9, diff --git a/src/data/slice.js b/src/data/slice.js index ce7ab151..e724e343 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -51,8 +51,6 @@ export const examSlice = createSlice({ exam_display_name: '', exam_url_path: '', time_remaining_seconds: null, - low_threshold_sec: null, - critically_low_threshold_sec: null, course_id: '', attempt_id: null, accessibility_time_string: '', diff --git a/src/timer/CountDownTimer.test.jsx b/src/timer/CountDownTimer.test.jsx index 974d955b..458ee4d6 100644 --- a/src/timer/CountDownTimer.test.jsx +++ b/src/timer/CountDownTimer.test.jsx @@ -29,15 +29,15 @@ describe('ExamTimerBlock', () => { attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', - time_remaining_seconds: 10, - low_threshold_sec: 15, - critically_low_threshold_sec: 5, + time_remaining_seconds: 24, exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', }, proctoringSettings: {}, - exam: {}, + exam: { + time_limit_mins: 2, + }, }, }; store = await initializeTestStore(preloadedState); @@ -97,7 +97,7 @@ describe('ExamTimerBlock', () => { submitExam={submitAttempt} />, ); - await waitFor(() => expect(screen.getByText('00:00:09')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); expect(screen.getByRole('alert')).toHaveClass('alert-warning'); }); @@ -110,15 +110,15 @@ describe('ExamTimerBlock', () => { attempt_status: 'started', exam_url_path: 'exam_url_path', exam_display_name: 'exam name', - time_remaining_seconds: 5, - low_threshold_sec: 15, - critically_low_threshold_sec: 5, + time_remaining_seconds: 6, exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', }, proctoringSettings: {}, - exam: {}, + exam: { + time_limit_mins: 2, + }, }, }; const testStore = await initializeTestStore(preloadedState); @@ -133,7 +133,7 @@ describe('ExamTimerBlock', () => { submitExam={submitAttempt} />, ); - await waitFor(() => expect(screen.getByText('00:00:04')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('00:00:05')).toBeInTheDocument()); expect(screen.getByRole('alert')).toHaveClass('alert-danger'); }); @@ -147,17 +147,17 @@ describe('ExamTimerBlock', () => { submitExam={submitAttempt} />, ); - await waitFor(() => expect(screen.getByText('00:00:09')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByLabelText('Hide Timer')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('hide-timer')); expect(screen.getByLabelText('Show Timer')).toBeInTheDocument(); - expect(screen.queryByText(/00:00:0/)).not.toBeInTheDocument(); + expect(screen.queryByText(/00:00:2/)).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('show-timer')); expect(screen.getByLabelText('Hide Timer')).toBeInTheDocument(); - expect(screen.queryByText(/00:00:0/)).toBeInTheDocument(); + expect(screen.queryByText(/00:00:2/)).toBeInTheDocument(); }); it('toggles long text visibility on show more/less', async () => { @@ -170,7 +170,7 @@ describe('ExamTimerBlock', () => { submitExam={submitAttempt} />, ); - await waitFor(() => expect(screen.getByText('00:00:09')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); expect(screen.getByRole('alert')).toBeInTheDocument(); fireEvent.click(screen.getByText('Show more')); @@ -192,14 +192,14 @@ describe('ExamTimerBlock', () => { 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: {}, + exam: { + time_limit_mins: 30, + }, }, }; const testStore = await initializeTestStore(preloadedState); @@ -231,7 +231,7 @@ describe('ExamTimerBlock', () => { submitExam={submitAttempt} />, ); - await waitFor(() => expect(screen.getByText('00:00:09')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('00:00:23')).toBeInTheDocument()); fireEvent.click(screen.getByTestId('end-button')); expect(stopExamAttempt).toHaveBeenCalledTimes(1); @@ -247,14 +247,14 @@ describe('ExamTimerBlock', () => { exam_url_path: 'exam_url_path', exam_display_name: 'exam name', time_remaining_seconds: 240, - low_threshold_sec: 15, - critically_low_threshold_sec: 5, exam_started_poll_url: '', taking_as_proctored: false, exam_type: 'a timed exam', }, proctoringSettings: {}, - exam: {}, + exam: { + time_limit_mins: 30, + }, }, }; let testStore = await initializeTestStore(preloadedState); diff --git a/src/timer/ExamTimerBlock.jsx b/src/timer/ExamTimerBlock.jsx index 917b82f0..27d1b68f 100644 --- a/src/timer/ExamTimerBlock.jsx +++ b/src/timer/ExamTimerBlock.jsx @@ -136,8 +136,6 @@ ExamTimerBlock.propTypes = { exam_url_path: PropTypes.string.isRequired, exam_display_name: PropTypes.string.isRequired, time_remaining_seconds: PropTypes.number.isRequired, - low_threshold_sec: PropTypes.number.isRequired, - critically_low_threshold_sec: PropTypes.number.isRequired, }), stopExamAttempt: PropTypes.func.isRequired, expireExamAttempt: PropTypes.func.isRequired, diff --git a/src/timer/TimerProvider.jsx b/src/timer/TimerProvider.jsx index 3e1bfd05..6149a321 100644 --- a/src/timer/TimerProvider.jsx +++ b/src/timer/TimerProvider.jsx @@ -13,12 +13,14 @@ import { withExamStore } from '../hocs'; /* give an extra 5 seconds where the timer holds at 00:00 before page refreshes */ const GRACE_PERIOD_SECS = 5; const POLL_INTERVAL = 60; +const TIME_LIMIT_CRITICAL_PCT = 0.05; +const TIME_LIMIT_LOW_PCT = 0.2; export const TimerContext = React.createContext({}); const mapStateToProps = (state) => { - const { activeAttempt } = state.examState; - return { attempt: activeAttempt }; + const { activeAttempt, exam } = state.examState; + return { attempt: activeAttempt, timeLimitMins: exam.time_limit_mins }; }; const getFormattedRemainingTime = (timeLeft) => ({ @@ -28,7 +30,7 @@ const getFormattedRemainingTime = (timeLeft) => ({ }); const TimerServiceProvider = ({ - children, attempt, pollHandler, pingHandler, + children, attempt, timeLimitMins, pollHandler, pingHandler, }) => { const [timeState, setTimeState] = useState({}); const [limitReached, setLimitReached] = useToggle(false); @@ -36,10 +38,10 @@ const TimerServiceProvider = ({ desktop_application_js_url: workerUrl, ping_interval: pingInterval, time_remaining_seconds: timeRemaining, - critically_low_threshold_sec: criticalLowTime, - low_threshold_sec: lowTime, } = 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 getTimeString = () => Object.values(timeState).map( @@ -58,9 +60,9 @@ const TimerServiceProvider = ({ }; const processTimeLeft = (timer, secondsLeft) => { - if (secondsLeft <= criticalLowTime) { + if (secondsLeft <= CRITICAL_LOW_TIME) { Emitter.emit(TIMER_IS_CRITICALLY_LOW); - } else if (secondsLeft <= lowTime) { + } else if (secondsLeft <= LOW_TIME) { Emitter.emit(TIMER_IS_LOW); } // Used to hide continue exam button on submit exam pages. @@ -115,14 +117,13 @@ const TimerServiceProvider = ({ TimerServiceProvider.propTypes = { attempt: PropTypes.shape({ time_remaining_seconds: PropTypes.number.isRequired, - critically_low_threshold_sec: PropTypes.number.isRequired, - low_threshold_sec: PropTypes.number.isRequired, exam_started_poll_url: PropTypes.string, desktop_application_js_url: PropTypes.string, ping_interval: PropTypes.number, taking_as_proctored: PropTypes.bool, attempt_status: PropTypes.string.isRequired, }).isRequired, + timeLimitMins: PropTypes.number.isRequired, children: PropTypes.element.isRequired, pollHandler: PropTypes.func, pingHandler: PropTypes.func,