diff --git a/kolibri/core/assets/src/state/modules/core/actions.js b/kolibri/core/assets/src/state/modules/core/actions.js index cec1791759c..8d7279e89f5 100644 --- a/kolibri/core/assets/src/state/modules/core/actions.js +++ b/kolibri/core/assets/src/state/modules/core/actions.js @@ -1,7 +1,4 @@ import debounce from 'lodash/debounce'; -import isNumber from 'lodash/isNumber'; -import isPlainObject from 'lodash/isPlainObject'; -import isUndefined from 'lodash/isUndefined'; import pick from 'lodash/pick'; import client from 'kolibri.client'; import logger from 'kolibri.lib.logging'; @@ -26,9 +23,6 @@ import { browser, os } from '../../../utils/browserInfo'; import errorCodes from './../../../disconnectionErrorCodes.js'; const logging = logger.getLogger(__filename); -const intervalTime = 5000; // Frequency at which time logging is updated -const progressThreshold = 0.4; // Update logs if user has reached 40% more progress -const timeThreshold = 120; // Update logs if 120 seconds have passed since last update /** * Vuex State Mappers @@ -270,312 +264,6 @@ export function getFacilityConfig(store, facilityId) { }); } -let lastElapsedTimeCheck; -let timeCheckIntervalTimer; - -function getNewTimeElapsed() { - // Timer has not been started - if (!lastElapsedTimeCheck) { - return 0; - } - const currentTime = new Date(); - const timeElapsed = currentTime - lastElapsedTimeCheck; - lastElapsedTimeCheck = currentTime; - // Some browsers entirely suspend code execution in background tabs, - // which can lead to unreliable timing if a tab has been in the background - // if the time elasped is significantly longer than the interval that we are - // checking this at, we should discard the result. - if (timeElapsed > intervalTime * 10) { - return 0; - } - // Return a time in seconds, rather than milliseconds. - return timeElapsed / 1000; -} - -function clearTrackingInterval() { - clearInterval(timeCheckIntervalTimer); - timeCheckIntervalTimer = null; - lastElapsedTimeCheck = null; -} - -/** - * Initialize a content session for progress tracking - * To be called on page load for content renderers - */ -export function initContentSession(store, { nodeId, lessonId, quizId } = {}) { - const data = {}; - if (!nodeId && !quizId) { - throw TypeError('Must define either nodeId or quizId'); - } - if ((nodeId || lessonId) && quizId) { - throw TypeError('quizId must be the only defined parameter if defined'); - } - let sessionStarted = false; - - if (quizId) { - sessionStarted = store.state.logging.context && store.state.logging.context.quiz_id === quizId; - data.quiz_id = quizId; - } - - if (nodeId) { - sessionStarted = store.state.logging.context && store.state.logging.context.node_id === nodeId; - data.node_id = nodeId; - if (lessonId) { - sessionStarted = - sessionStarted && - store.state.logging.context && - store.state.logging.context.lesson_id === lessonId; - data.lesson_id = lessonId; - } - } - - if (sessionStarted) { - return; - } - - // Always clear the logging state when we init the content session, - // to avoid state pollution. - store.commit('SET_EMPTY_LOGGING_STATE'); - // Clear any previous interval tracking that has been started - clearTrackingInterval(); - - return client({ - method: 'post', - url: urls['kolibri:core:trackprogress-list'](), - data: data, - }).then(response => { - store.commit('INITIALIZE_LOGGING_STATE', response.data); - }); -} - -function _zeroToOne(num) { - return Math.min(1, Math.max(num || 0, 0)); -} - -function makeSessionUpdateRequest(store, data) { - const wasComplete = store.state.logging.complete; - return client({ - method: 'put', - url: urls['kolibri:core:trackprogress-detail'](store.state.logging.session_id), - data, - }).then(response => { - if (response.data.attempts) { - for (let attempt of response.data.attempts) { - store.commit('UPDATE_ATTEMPT', attempt); - } - } - if (response.data.complete) { - store.commit('SET_COMPLETE'); - if (store.getters.isUserLoggedIn && !wasComplete) { - store.commit('INCREMENT_TOTAL_PROGRESS', 1); - } - } - return response.data; - }); -} - -const maxRetries = 5; - -// Function to delay rejection to allow delayed retry behaviour -function rejectDelay(reason, retryDelay = 5000) { - return new Promise(function(resolve, reject) { - setTimeout(reject.bind(null, reason), retryDelay); - }); -} - -// Start the savingPromise as a resolved promise -// so we can always just chain from this promise for subsequent saves. -let savingPromise = Promise.resolve(); - -let updateContentSessionResolveRejectStack = []; -let updateContentSessionTimeout; - -function immediatelyUpdateContentSession(store) { - // Once this timeout has been executed, we can reset the global timeout - // to null, as we are now actually invoking the debounced function. - updateContentSessionTimeout = null; - - const progress_delta = store.state.logging.progress_delta; - const time_spent_delta = store.state.logging.time_spent_delta; - const extra_fields = store.state.logging.extra_fields; - const extra_fields_dirty_bit = store.state.logging.extra_fields_dirty_bit; - const progress = store.state.logging.progress; - const unsavedInteractions = store.state.logging.unsavedInteractions; - - if ( - // Always update if there's an attempt that hasn't got an id yet, or if there have been - // at least 3 additional interactions. - (unsavedInteractions.length && - (unsavedInteractions.some(r => !r.id) || unsavedInteractions.length > 2)) || - progress_delta >= progressThreshold || - (progress_delta && progress >= 1) || - time_spent_delta >= timeThreshold || - extra_fields_dirty_bit - ) { - // Clear the temporary state that we've - // picked up to save to the backend. - store.commit('LOGGING_SAVING'); - const data = {}; - if (progress_delta) { - data.progress_delta = progress_delta; - } - if (time_spent_delta) { - data.time_spent_delta = time_spent_delta; - } - if (unsavedInteractions.length) { - data.interactions = unsavedInteractions; - } - if (extra_fields_dirty_bit) { - data.extra_fields = extra_fields; - } - // Don't try to make a new save until the previous save - // has completed. - savingPromise = savingPromise.then(() => { - // Create an initial rejection so that we can chain consistently - // in the retry loop. - let attempt = Promise.reject({ response: { status: 503 } }); - for (var i = 0; i < maxRetries; i++) { - // Catch any previous error and then try to make the session update again - attempt = attempt - .catch(err => { - if (err && err.response && err.response.status === 503) { - return makeSessionUpdateRequest(store, data); - } - return Promise.reject(err); - }) - .catch(err => { - // Only try to handle 503 status codes here, as otherwise we might be continually - // retrying the server when it is rejecting the request for valid reasons. - if (err && err.response && err.response.status === 503) { - // Defer to the server's Retry-After header if it is set. - const retryAfter = (err.response.headers || {})['retry-after']; - // retry-after header is in seconds, we need a value in milliseconds. - return rejectDelay(err, retryAfter ? retryAfter * 1000 : retryAfter); - } - return Promise.reject(err); - }); - } - return attempt.catch(err => store.dispatch('handleApiError', err)); - }); - } - return savingPromise - .then(result => { - // If it is successful call all of the resolve functions that we have stored - // from all the Promises that have been returned while this specific debounce - // has been active. - for (let [resolve] of updateContentSessionResolveRejectStack) { - resolve(result); - } - // Reset the stack for resolve/reject functions, so that future invocations - // do not call these now consumed functions. - updateContentSessionResolveRejectStack = []; - }) - .catch(err => { - // If there is an error call reject for all previously returned promises. - for (let [, reject] of updateContentSessionResolveRejectStack) { - reject(err); - } - // Likewise reset the stack. - updateContentSessionResolveRejectStack = []; - }); -} - -// Set the debounce artificially short in tests to prevent slowdowns. -const updateContentSessionDebounceTime = process.env.NODE_ENV === 'test' ? 1 : 2000; - -/** - * Update a content session for progress tracking - */ -export function updateContentSession( - store, - { progressDelta, progress, contentState, interaction, immediate = false } = {} -) { - if (store.state.logging.session_id === null) { - throw ReferenceError('Cannot update a content session before one has been initialized'); - } - if (!isUndefined(progressDelta) && !isUndefined(progress)) { - throw TypeError('Must only specify either progressDelta or progress'); - } - if (!isUndefined(progress)) { - if (!isNumber(progress)) { - throw TypeError('progress must be a number'); - } - progress = _zeroToOne(progress); - store.commit('SET_LOGGING_PROGRESS', progress); - } - if (!isUndefined(progressDelta)) { - if (!isNumber(progressDelta)) { - throw TypeError('progressDelta must be a number'); - } - progressDelta = _zeroToOne(progressDelta); - store.commit('ADD_LOGGING_PROGRESS', progressDelta); - } - if (!isUndefined(contentState)) { - if (!isPlainObject(contentState)) { - throw TypeError('contentState must be an object'); - } - store.commit('SET_LOGGING_CONTENT_STATE', contentState); - } - if (!isUndefined(interaction)) { - if (!isPlainObject(interaction)) { - throw TypeError('interaction must be an object'); - } - store.commit('ADD_UNSAVED_INTERACTION', interaction); - store.commit('UPDATE_ATTEMPT', interaction); - } - // Reset the elapsed time in the timer - const elapsedTime = getNewTimeElapsed(); - // Discard the time that has passed if the page is not visible. - if (store.state.pageVisible && elapsedTime) { - /* Update the logging state with new timing information */ - store.commit('UPDATE_LOGGING_TIME', elapsedTime); - } - - immediate = (!isUndefined(interaction) && !interaction.id) || immediate; - // Logic for promise returning debounce vendored and modified from: - // https://github.com/sindresorhus/p-debounce/blob/main/index.js - // Return a promise that will consistently resolve when this debounced - // function invocation is completed. - return new Promise((resolve, reject) => { - // Clear any current timeouts, so that this invocation takes precedence - // Any subsequent calls will then also revoke this timeout. - clearTimeout(updateContentSessionTimeout); - // Add the resolve and reject handlers for this promise to the stack here. - updateContentSessionResolveRejectStack.push([resolve, reject]); - if (immediate) { - // If immediate invocation is required immediately call the handler - // rather than using a timeout delay. - immediatelyUpdateContentSession(store); - } else { - // Otherwise update the timeout to this invocation. - updateContentSessionTimeout = setTimeout( - () => immediatelyUpdateContentSession(store), - updateContentSessionDebounceTime - ); - } - }); -} - -/** - * Start interval timer and set start time - * @param {int} interval - */ -export function startTrackingProgress(store) { - timeCheckIntervalTimer = setInterval(() => { - updateContentSession(store); - }, intervalTime); - lastElapsedTimeCheck = new Date(); -} - -/** - * Stop interval timer and update latest times - * Must be called after startTrackingProgress - */ -export function stopTrackingProgress(store) { - clearTrackingInterval(); - updateContentSession(store, { immediate: true }); -} - export function setChannelInfo(store) { return ChannelResource.fetchCollection({ getParams: { available: true } }).then( channelsData => { diff --git a/kolibri/core/assets/src/state/modules/core/index.js b/kolibri/core/assets/src/state/modules/core/index.js index a40da088158..59076b4ed2c 100644 --- a/kolibri/core/assets/src/state/modules/core/index.js +++ b/kolibri/core/assets/src/state/modules/core/index.js @@ -1,5 +1,4 @@ import connectionModule from '../connection'; -import loggingModule from '../logging'; import sessionModule from '../session'; import snackbarModule from '../snackbar'; import * as getters from './getters'; @@ -32,7 +31,6 @@ export default { mutations, modules: { connection: connectionModule, - logging: loggingModule, session: sessionModule, snackbar: snackbarModule, }, diff --git a/kolibri/core/assets/src/state/modules/logging.js b/kolibri/core/assets/src/state/modules/logging.js deleted file mode 100644 index e8ac770f458..00000000000 --- a/kolibri/core/assets/src/state/modules/logging.js +++ /dev/null @@ -1,140 +0,0 @@ -import Vue from 'kolibri.lib.vue'; -import fromPairs from 'lodash/fromPairs'; -import { diff } from 'deep-object-diff'; - -function valOrNull(val) { - return typeof val !== 'undefined' ? val : null; -} - -function threeDecimalPlaceRoundup(num) { - if (num) { - return Math.ceil(num * 1000) / 1000; - } - return num; -} - -// Items to only update on an -// already existing attempt if -// replace is set to true. -// We use an object rather than -// an array for easy lookup. -const replaceBlocklist = { - correct: true, - answer: true, - simple_answer: true, - replace: true, -}; - -export default { - state: () => ({ - complete: null, - progress: null, - progress_delta: null, - last_saved_progress: null, - time_spent: null, - time_spent_delta: null, - session_id: null, - extra_fields: null, - extra_fields_dirty_bit: null, - mastery_criterion: null, - totalattempts: null, - pastattempts: null, - pastattemptMap: null, - // Array of as yet unsaved interactions - unsavedInteractions: null, - context: null, - }), - mutations: { - SET_EMPTY_LOGGING_STATE(state) { - for (let key in state) { - state[key] = null; - } - }, - INITIALIZE_LOGGING_STATE(state, data) { - state.context = valOrNull(data.context); - state.complete = valOrNull(data.complete); - state.progress = threeDecimalPlaceRoundup(valOrNull(data.progress)); - state.progress_delta = 0; - state.time_spent = valOrNull(data.time_spent); - state.time_spent_delta = 0; - state.session_id = valOrNull(data.session_id); - state.extra_fields = valOrNull(data.extra_fields); - state.mastery_criterion = valOrNull(data.mastery_criterion); - state.pastattempts = valOrNull(data.pastattempts); - state.pastattemptMap = data.pastattempts - ? fromPairs(data.pastattempts.map(a => [a.id, a])) - : null; - state.totalattempts = valOrNull(data.totalattempts); - state.unsavedInteractions = []; - }, - ADD_UNSAVED_INTERACTION(state, interaction) { - state.unsavedInteractions.push(interaction); - if (!interaction.id) { - const unsavedInteraction = state.pastattempts.find( - a => !a.id && a.item === interaction.item - ); - if (unsavedInteraction) { - for (let key in interaction) { - Vue.set(unsavedInteraction, key, interaction[key]); - } - } else { - state.pastattempts.unshift(interaction); - state.totalattempts += 1; - } - } - }, - UPDATE_ATTEMPT(state, interaction) { - // We never store replace into the store. - const blocklist = interaction.replace ? { replace: true } : replaceBlocklist; - if (interaction.id) { - if (!state.pastattemptMap[interaction.id]) { - const nowSavedInteraction = state.pastattempts.find( - a => !a.id && a.item === interaction.item - ); - for (let key in interaction) { - Vue.set(nowSavedInteraction, key, interaction[key]); - } - Vue.set(state.pastattemptMap, nowSavedInteraction.id, nowSavedInteraction); - state.totalattempts += 1; - } else { - for (let key in interaction) { - if (!blocklist[key]) { - Vue.set(state.pastattemptMap[interaction.id], key, interaction[key]); - } - } - } - } - }, - UPDATE_LOGGING_TIME(state, timeDelta) { - state.time_spent = state.time_spent + threeDecimalPlaceRoundup(timeDelta); - state.time_spent_delta = threeDecimalPlaceRoundup(state.time_spent_delta + timeDelta); - }, - SET_LOGGING_CONTENT_STATE(state, contentState) { - const delta = diff(state.extra_fields, { ...state.extra_fields, contentState }); - state.extra_fields.contentState = contentState; - state.extra_fields_dirty_bit = - state.extra_fields_dirty_bit || Boolean(Object.keys(delta).length); - }, - SET_LOGGING_PROGRESS(state, progress) { - progress = threeDecimalPlaceRoundup(progress); - if (state.progress < progress) { - state.progress_delta = threeDecimalPlaceRoundup(progress - state.progress); - state.progress = progress; - } - }, - ADD_LOGGING_PROGRESS(state, progressDelta) { - progressDelta = threeDecimalPlaceRoundup(progressDelta); - state.progress_delta = threeDecimalPlaceRoundup(state.progress_delta + progressDelta); - state.progress = Math.min(threeDecimalPlaceRoundup(state.progress + progressDelta), 1); - }, - LOGGING_SAVING(state) { - state.progress_delta = 0; - state.time_spent_delta = 0; - state.extra_fields_dirty_bit = false; - state.unsavedInteractions = []; - }, - SET_COMPLETE(state) { - state.complete = true; - }, - }, -}; diff --git a/kolibri/core/assets/test/state/store.spec.js b/kolibri/core/assets/test/state/store.spec.js index 44b9bd9544b..7507013f862 100644 --- a/kolibri/core/assets/test/state/store.spec.js +++ b/kolibri/core/assets/test/state/store.spec.js @@ -76,802 +76,4 @@ describe('Vuex store/actions for core module', () => { expect(error).toEqual(constants.LoginErrors.INVALID_CREDENTIALS); }); }); - describe('initContentSession', () => { - it('should throw an error if no context provided', async () => { - const store = makeStore(); - try { - await store.dispatch('initContentSession', {}); - } catch (error) { - expect(error).toEqual(new TypeError('Must define either nodeId or quizId')); - } - }); - it('should throw an error if only lessonId provided', async () => { - const store = makeStore(); - try { - await store.dispatch('initContentSession', { lessonId: 'test_lesson' }); - } catch (error) { - expect(error).toEqual(new TypeError('Must define either nodeId or quizId')); - } - }); - it('should throw an error if quizId and nodeId provided', async () => { - const store = makeStore(); - try { - await store.dispatch('initContentSession', { quizId: 'test_quiz', nodeId: 'test_node' }); - } catch (error) { - expect(error).toEqual( - new TypeError('quizId must be the only defined parameter if defined') - ); - } - }); - it('should throw an error if quizId and lessonId provided', async () => { - const store = makeStore(); - try { - await store.dispatch('initContentSession', { - quizId: 'test_quiz', - lessonId: 'test_lesson', - }); - } catch (error) { - expect(error).toEqual( - new TypeError('quizId must be the only defined parameter if defined') - ); - } - }); - it('should not set a lessonId if the lessonId is a falsey value', async () => { - const store = makeStore(); - const node_id = 'test_node_id'; - const lesson_id = null; - client.__setPayload({ - context: { node_id, lesson_id }, - }); - await store.dispatch('initContentSession', { nodeId: node_id, lessonId: lesson_id }); - expect(client.mock.calls[0][0].data).toEqual({ node_id: node_id }); - }); - it('should not set a nodeId if the nodeId is a falsey value', async () => { - const store = makeStore(); - const node_id = null; - const quiz_id = 'test-quiz-id'; - client.__setPayload({ - context: { node_id, quiz_id }, - }); - await store.dispatch('initContentSession', { nodeId: node_id, quizId: quiz_id }); - expect(client.mock.calls[0][0].data).toEqual({ quiz_id: quiz_id }); - }); - it('should set the logging state with the return data from the client', async () => { - const store = makeStore(); - const session_id = 'test_session_id'; - const node_id = 'test_node_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = { extra: true }; - client.__setPayload({ - session_id, - context: { node_id }, - progress, - time_spent, - extra_fields, - complete: false, - }); - await store.dispatch('initContentSession', { nodeId: node_id }); - expect(store.state.core.logging.session_id).toEqual(session_id); - expect(store.state.core.logging.context.node_id).toEqual(node_id); - expect(store.state.core.logging.progress).toEqual(progress); - expect(store.state.core.logging.time_spent).toEqual(time_spent); - expect(store.state.core.logging.extra_fields).toEqual(extra_fields); - expect(store.state.core.logging.complete).toEqual(false); - }); - it('should not make a backend request when the session for node_id is already active', async () => { - const store = makeStore(); - const session_id = 'test_session_id'; - const node_id = 'test_node_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = { extra: true }; - client.__setPayload({ - session_id, - context: { node_id }, - progress, - time_spent, - extra_fields, - complete: false, - }); - await store.dispatch('initContentSession', { nodeId: node_id }); - client.__reset(); - await store.dispatch('initContentSession', { nodeId: node_id }); - expect(client).not.toHaveBeenCalled(); - }); - it('should not make a backend request when the session for lesson_id and node_id is already active', async () => { - const store = makeStore(); - const session_id = 'test_session_id'; - const node_id = 'test_node_id'; - const lesson_id = 'test_lesson_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = { extra: true }; - client.__setPayload({ - session_id, - context: { node_id, lesson_id }, - progress, - time_spent, - extra_fields, - complete: false, - }); - await store.dispatch('initContentSession', { nodeId: node_id, lessonId: lesson_id }); - client.__reset(); - await store.dispatch('initContentSession', { nodeId: node_id, lessonId: lesson_id }); - expect(client).not.toHaveBeenCalled(); - }); - it('should not make a backend request when the session for quiz_id is already active', async () => { - const store = makeStore(); - const session_id = 'test_session_id'; - const quiz_id = 'test_quiz_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = { extra: true }; - client.__setPayload({ - session_id, - context: { quiz_id }, - progress, - time_spent, - extra_fields, - complete: false, - }); - await store.dispatch('initContentSession', { quizId: quiz_id }); - client.__reset(); - await store.dispatch('initContentSession', { quizId: quiz_id }); - expect(client).not.toHaveBeenCalled(); - }); - it('should set the logging state with the return data for an assessment from the client', async () => { - const store = makeStore(); - const session_id = 'test_session_id'; - const node_id = 'test_node_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = { extra: true }; - const mastery_criterion = { type: 'm_of_n', m: 5, n: 7 }; - const pastattempts = [ - { - id: 'attemptlog_id', - correct: 1, - complete: true, - hinted: false, - error: false, - item: 'item_identifier', - answer: { response: 'respond to this' }, - time_spent: 10, - }, - ]; - client.__setPayload({ - session_id, - context: { node_id }, - progress, - time_spent, - extra_fields, - complete: false, - mastery_criterion, - pastattempts, - totalattempts: 1, - }); - await store.dispatch('initContentSession', { nodeId: node_id }); - expect(store.state.core.logging.session_id).toEqual(session_id); - expect(store.state.core.logging.context.node_id).toEqual(node_id); - expect(store.state.core.logging.progress).toEqual(progress); - expect(store.state.core.logging.time_spent).toEqual(time_spent); - expect(store.state.core.logging.extra_fields).toEqual(extra_fields); - expect(store.state.core.logging.complete).toEqual(false); - expect(store.state.core.logging.mastery_criterion).toEqual(mastery_criterion); - expect(store.state.core.logging.pastattempts).toEqual(pastattempts); - expect(store.state.core.logging.totalattempts).toEqual(1); - }); - it('should clear any pre-existing logging state', async () => { - const store = makeStore(); - const session_id = 'test_session_id'; - const node_id = 'test_node_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = { extra: true }; - store.commit('INITIALIZE_LOGGING_STATE', { - session_id, - context: { node_id }, - progress, - time_spent, - extra_fields, - complete: false, - }); - client.__setPayload({}); - await store.dispatch('initContentSession', { nodeId: 'another_node' }); - expect(store.state.core.logging.session_id).toBeNull(); - expect(store.state.core.logging.context).toBeNull(); - expect(store.state.core.logging.progress).toBeNull(); - expect(store.state.core.logging.time_spent).toBeNull(); - expect(store.state.core.logging.extra_fields).toBeNull(); - expect(store.state.core.logging.complete).toBeNull(); - }); - }); - describe('updateContentSession', () => { - async function initStore() { - const store = makeStore(); - const session_id = 'test_session_id'; - const node_id = 'test_node_id'; - const progress = 0.5; - const time_spent = 15; - const extra_fields = {}; - client.__setPayload({ - session_id, - context: { node_id }, - progress, - time_spent, - extra_fields, - complete: false, - pastattempts: [], - mastery_criterion: { type: 'm_of_n', m: 5, n: 7 }, - totalattempts: 0, - }); - await store.dispatch('initContentSession', { nodeId: node_id }); - client.__reset(); - return store; - } - it('should throw an error if called before a content session has been initialized', async () => { - const store = makeStore(); - try { - await store.dispatch('updateContentSession', {}); - } catch (error) { - expect(error).toEqual( - new ReferenceError('Cannot update a content session before one has been initialized') - ); - } - }); - it('should throw an error if called with both progress and progressDelta', async () => { - const store = await initStore(); - try { - await store.dispatch('updateContentSession', { progress: 1, progressDelta: 1 }); - } catch (error) { - expect(error).toEqual(new TypeError('Must only specify either progressDelta or progress')); - } - }); - it('should throw an error if called with non-numeric progress', async () => { - const store = await initStore(); - try { - await store.dispatch('updateContentSession', { progress: 'number' }); - } catch (error) { - expect(error).toEqual(new TypeError('progress must be a number')); - } - }); - it('should throw an error if called with non-numeric progressDelta', async () => { - const store = await initStore(); - try { - await store.dispatch('updateContentSession', { progressDelta: 'number' }); - } catch (error) { - expect(error).toEqual(new TypeError('progressDelta must be a number')); - } - }); - it('should throw an error if called with non-plain object contentState', async () => { - const store = await initStore(); - try { - await store.dispatch('updateContentSession', { contentState: 'notanobject' }); - } catch (error) { - expect(error).toEqual(new TypeError('contentState must be an object')); - } - }); - it('should throw an error if called with non-plain object interaction', async () => { - const store = await initStore(); - try { - await store.dispatch('updateContentSession', { interaction: 'notanobject' }); - } catch (error) { - expect(error).toEqual(new TypeError('interaction must be an object')); - } - }); - it('should not make a request to the backend if no arguments have been passed', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', {}); - expect(client).not.toHaveBeenCalled(); - }); - it('should make a request to the backend if any changes have been passed', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { contentState: { test: 'test' } }); - expect(client).toHaveBeenCalled(); - }); - it('should update complete if the backend returns complete', async () => { - const store = await initStore(); - client.__setPayload({ - complete: true, - }); - await store.dispatch('updateContentSession', { contentState: { test: 'test' } }); - expect(store.state.core.logging.complete).toBe(true); - }); - it('should not update total progress if the backend returns complete and was not complete and user is not logged in', async () => { - const store = await initStore(); - client.__setPayload({ - complete: true, - }); - store.commit('SET_TOTAL_PROGRESS', 0); - await store.dispatch('updateContentSession', { contentState: { test: 'test' } }); - expect(store.state.core.totalProgress).toEqual(0); - }); - it('should update total progress if the backend returns complete and was not complete and user is logged in', async () => { - const store = await initStore(); - store.commit('CORE_SET_SESSION', { kind: ['learner'] }); - store.commit('SET_TOTAL_PROGRESS', 0); - client.__setPayload({ - complete: true, - }); - await store.dispatch('updateContentSession', { contentState: { test: 'test' } }); - expect(store.state.core.totalProgress).toEqual(1); - }); - it('should not update total progress if the backend returns complete and was not complete', async () => { - const store = await initStore(); - store.commit('CORE_SET_SESSION', { kind: ['learner'] }); - store.commit('SET_TOTAL_PROGRESS', 0); - store.commit('SET_COMPLETE'); - client.__setPayload({ - complete: true, - }); - await store.dispatch('updateContentSession', { contentState: { test: 'test' } }); - expect(store.state.core.totalProgress).toEqual(0); - }); - it('should update progress and progress_delta if progress is updated under threshold', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progress: 0.6 }); - expect(store.state.core.logging.progress).toEqual(0.6); - expect(store.state.core.logging.progress_delta).toEqual(0.1); - expect(client).not.toHaveBeenCalled(); - }); - it('should update progress and store progress_delta if progress is updated over threshold', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progress: 1 }); - expect(store.state.core.logging.progress).toEqual(1); - expect(client.mock.calls[0][0].data.progress_delta).toEqual(0.5); - }); - it('should max progress and store progress_delta if progress is updated over threshold and over max value', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progress: 2 }); - expect(store.state.core.logging.progress).toEqual(1); - expect(client.mock.calls[0][0].data.progress_delta).toEqual(0.5); - }); - it('should not update progress and store progress_delta if progress is updated under current value', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progress: 0.4 }); - expect(store.state.core.logging.progress).toEqual(0.5); - expect(store.state.core.logging.progress_delta).toEqual(0); - expect(client).not.toHaveBeenCalled(); - }); - it('should update progress and progress_delta if progressDelta is updated under threshold', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progressDelta: 0.1 }); - expect(store.state.core.logging.progress).toEqual(0.6); - expect(store.state.core.logging.progress_delta).toEqual(0.1); - expect(client).not.toHaveBeenCalled(); - }); - it('should update progress and store progress_delta if progressDelta is updated over threshold', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progressDelta: 0.5 }); - expect(store.state.core.logging.progress).toEqual(1); - expect(client.mock.calls[0][0].data.progress_delta).toEqual(0.5); - }); - it('should max progress and store progress_delta if progressDelta is updated over threshold and over max value', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { progressDelta: 1.5 }); - expect(store.state.core.logging.progress).toEqual(1); - // Will store the maximum possible value for progress_delta which is 1, - // even though current progress can only increase by 0.5 - expect(client.mock.calls[0][0].data.progress_delta).toEqual(1); - }); - it('should update extra_fields and store if contentState is updated', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { contentState: { newState: 0.2 } }); - expect(store.state.core.logging.extra_fields).toEqual({ contentState: { newState: 0.2 } }); - expect(client).toHaveBeenCalled(); - expect(client.mock.calls[0][0].data.extra_fields).toEqual({ - contentState: { newState: 0.2 }, - }); - }); - it('should update extra_fields and but not store to backend if not an update', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { - contentState: { statements: [{ test: 'statement' }] }, - }); - expect(store.state.core.logging.extra_fields).toEqual({ - contentState: { statements: [{ test: 'statement' }] }, - }); - expect(client).toHaveBeenCalled(); - expect(client.mock.calls[0][0].data.extra_fields).toEqual({ - contentState: { statements: [{ test: 'statement' }] }, - }); - await store.dispatch('updateContentSession', { - contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, - }); - expect(store.state.core.logging.extra_fields).toEqual({ - contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, - }); - expect(client).toHaveBeenCalled(); - expect(client.mock.calls[1][0].data.extra_fields).toEqual({ - contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, - }); - client.__reset(); - await store.dispatch('updateContentSession', { - contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, - }); - expect(store.state.core.logging.extra_fields).toEqual({ - contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, - }); - expect(client).not.toHaveBeenCalled(); - }); - it('should update pastattempts and store if interaction is passed without an id', async () => { - const store = await initStore(); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - }); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }); - // No attempt is returned from the backend, so should not update the past attempts map, - // as no id for map. - expect(store.state.core.logging.pastattemptMap).toEqual({}); - expect(client).toHaveBeenCalled(); - expect(client.mock.calls[0][0].data.interactions).toEqual([ - { item: 'testitem', answer: { response: 'answer' }, correct: 1, complete: true }, - ]); - }); - it('should update pastattempts and map if passed without an id and backend returns id', async () => { - const store = await initStore(); - client.__setPayload({ - attempts: [ - { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - ], - }); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - }); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }); - expect(store.state.core.logging.pastattemptMap).toEqual({ - testid: { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - }); - expect(client).toHaveBeenCalled(); - // The calls are not isolated, so updates to the object also affect the calls - // as they are just references to the source object. - expect(client.mock.calls[0][0].data.interactions).toEqual([ - { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - ]); - }); - it('should update pastattempts and map if passed without an id and backend returns id and additional interactions happen', async () => { - const store = await initStore(); - client.__setPayload({ - attempts: [ - { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - ], - }); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - }); - client.__reset(); - await store.dispatch('updateContentSession', { - interaction: { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - }, - }); - expect(store.state.core.logging.pastattempts).toHaveLength(1); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - }); - expect(store.state.core.logging.pastattemptMap).toEqual({ - testid: { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - }, - }); - expect(client).not.toHaveBeenCalled(); - await store.dispatch('updateContentSession', { - interaction: { id: 'testid', item: 'testitem', error: true }, - }); - expect(store.state.core.logging.pastattempts).toHaveLength(1); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - error: true, - }); - expect(store.state.core.logging.pastattemptMap).toEqual({ - testid: { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - error: true, - }, - }); - expect(client).not.toHaveBeenCalled(); - }); - it('should not overwrite correct, answer or simple_answer if not passed with the replace flag', async () => { - const store = await initStore(); - client.__setPayload({ - attempts: [ - { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - ], - }); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem', - answer: { response: 'answer' }, - simple_answer: 'nah', - correct: 1, - complete: true, - }, - }); - client.__reset(); - await store.dispatch('updateContentSession', { - interaction: { - id: 'testid', - item: 'testitem', - answer: { response: 'not an answer' }, - simple_answer: 'yeah', - correct: 0, - complete: true, - hinted: true, - }, - }); - expect(store.state.core.logging.pastattempts).toHaveLength(1); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - simple_answer: 'nah', - correct: 1, - complete: true, - hinted: true, - }); - expect(store.state.core.logging.pastattemptMap).toEqual({ - testid: { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - simple_answer: 'nah', - correct: 1, - complete: true, - hinted: true, - }, - }); - }); - it('should multiple unrelated interactions without overwriting', async () => { - const store = await initStore(); - client.__setPayload({ - attempts: [ - { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - ], - }); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - }); - client.__reset(); - client.__setPayload({ - attempts: [ - { - id: 'testid1', - item: 'testitem1', - answer: { response: 'answer' }, - correct: 0, - complete: false, - }, - ], - }); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem1', - answer: { response: 'answer' }, - correct: 0, - complete: false, - }, - }); - client.__reset(); - client.__setPayload({ - attempts: [ - { - id: 'testid2', - item: 'testitem2', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }, - ], - }); - await store.dispatch('updateContentSession', { - interaction: { - item: 'testitem2', - answer: { response: 'answer' }, - correct: 1, - complete: true, - error: true, - }, - }); - client.__reset(); - expect(store.state.core.logging.pastattempts).toHaveLength(3); - expect(store.state.core.logging.pastattempts[2]).toEqual({ - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - }); - expect(store.state.core.logging.pastattempts[1]).toEqual({ - id: 'testid1', - item: 'testitem1', - answer: { response: 'answer' }, - correct: 0, - complete: false, - }); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - id: 'testid2', - item: 'testitem2', - answer: { response: 'answer' }, - correct: 1, - complete: true, - error: true, - }); - expect(Object.keys(store.state.core.logging.pastattemptMap)).toHaveLength(3); - await store.dispatch('updateContentSession', { - interaction: { - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - }, - }); - await store.dispatch('updateContentSession', { - interaction: { - id: 'testid1', - item: 'testitem1', - answer: { response: 'answer' }, - correct: 0, - complete: true, - }, - }); - await store.dispatch('updateContentSession', { - interaction: { - id: 'testid2', - item: 'testitem2', - answer: { response: 'answer' }, - replace: true, - correct: 0, - complete: true, - }, - }); - expect(store.state.core.logging.pastattempts).toHaveLength(3); - expect(store.state.core.logging.pastattempts[2]).toEqual({ - id: 'testid', - item: 'testitem', - answer: { response: 'answer' }, - correct: 1, - complete: true, - hinted: true, - }); - expect(store.state.core.logging.pastattempts[1]).toEqual({ - id: 'testid1', - item: 'testitem1', - answer: { response: 'answer' }, - correct: 0, - complete: true, - }); - expect(store.state.core.logging.pastattempts[0]).toEqual({ - id: 'testid2', - item: 'testitem2', - answer: { response: 'answer' }, - correct: 0, - complete: true, - error: true, - }); - }); - it('should debounce requests', async () => { - const store = await initStore(); - store.dispatch('updateContentSession', { progress: 1 }); - store.dispatch('updateContentSession', { contentState: { yes: 'no' } }); - store.dispatch('updateContentSession', { progressDelta: 1 }); - store.dispatch('updateContentSession', { progress: 1 }); - store.dispatch('updateContentSession', { contentState: { yes: 'no' } }); - store.dispatch('updateContentSession', { progressDelta: 1 }); - await store.dispatch('updateContentSession', { progress: 1 }); - expect(client).toHaveBeenCalledTimes(1); - }); - it('should retry 5 times if it receives a 503', async () => { - const store = await initStore(); - const error = { - response: { - status: 503, - headers: { - 'retry-after': 0.001, - }, - }, - }; - client.mockImplementation(() => { - return Promise.reject(error); - }); - await expect(store.dispatch('updateContentSession', { progress: 1 })).rejects.toMatchObject( - error - ); - expect(client).toHaveBeenCalledTimes(5); - }); - }); }); diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js new file mode 100644 index 00000000000..84adf5d0e82 --- /dev/null +++ b/kolibri/plugins/learn/assets/src/composables/__tests__/useProgressTracking.spec.js @@ -0,0 +1,841 @@ +import { get } from '@vueuse/core'; +import client from 'kolibri.client'; +import { coreStoreFactory as makeStore } from 'kolibri.coreVue.vuex.store'; +import useProgressTracking from '../useProgressTracking'; + +jest.mock('kolibri.urls'); +jest.mock('kolibri.client'); + +function setUp() { + const store = makeStore(); + return { store, ...useProgressTracking(store) }; +} + +describe('useProgressTracking composable', () => { + describe('initContentSession', () => { + it('should throw an error if no context provided', async () => { + const { initContentSession } = setUp(); + try { + await initContentSession({}); + } catch (error) { + expect(error).toEqual(new TypeError('Must define either nodeId or quizId')); + } + }); + it('should throw an error if only lessonId provided', async () => { + const { initContentSession } = setUp(); + try { + await initContentSession({ lessonId: 'test_lesson' }); + } catch (error) { + expect(error).toEqual(new TypeError('Must define either nodeId or quizId')); + } + }); + it('should throw an error if quizId and nodeId provided', async () => { + const { initContentSession } = setUp(); + try { + await initContentSession({ quizId: 'test_quiz', nodeId: 'test_node' }); + } catch (error) { + expect(error).toEqual( + new TypeError('quizId must be the only defined parameter if defined') + ); + } + }); + it('should throw an error if quizId and lessonId provided', async () => { + const { initContentSession } = setUp(); + try { + await initContentSession({ + quizId: 'test_quiz', + lessonId: 'test_lesson', + }); + } catch (error) { + expect(error).toEqual( + new TypeError('quizId must be the only defined parameter if defined') + ); + } + }); + it('should not set a lessonId if the lessonId is a falsey value', async () => { + const { initContentSession } = setUp(); + const node_id = 'test_node_id'; + const lesson_id = null; + client.__setPayload({ + context: { node_id, lesson_id }, + }); + await initContentSession({ nodeId: node_id, lessonId: lesson_id }); + expect(client.mock.calls[0][0].data).toEqual({ node_id: node_id }); + }); + it('should not set a nodeId if the nodeId is a falsey value', async () => { + const { initContentSession } = setUp(); + const node_id = null; + const quiz_id = 'test-quiz-id'; + client.__setPayload({ + context: { node_id, quiz_id }, + }); + await initContentSession({ nodeId: node_id, quizId: quiz_id }); + expect(client.mock.calls[0][0].data).toEqual({ quiz_id: quiz_id }); + }); + it('should set the logging state with the return data from the client', async () => { + const { + initContentSession, + session_id, + context, + progress, + time_spent, + extra_fields, + complete, + } = setUp(); + const payload = { + session_id: 'test_session_id', + context: { node_id: 'test_node_id' }, + progress: 0.5, + time_spent: 15, + extra_fields: { extra: true }, + complete: false, + }; + client.__setPayload(payload); + await initContentSession({ nodeId: payload.context.node_id }); + expect(get(session_id)).toEqual(payload.session_id); + expect(get(context).node_id).toEqual(payload.context.node_id); + expect(get(progress)).toEqual(payload.progress); + expect(get(time_spent)).toEqual(payload.time_spent); + expect(get(extra_fields)).toEqual(payload.extra_fields); + expect(get(complete)).toEqual(payload.complete); + }); + it('should not make a backend request when the session for node_id is already active', async () => { + const { initContentSession } = setUp(); + const session_id = 'test_session_id'; + const node_id = 'test_node_id'; + const progress = 0.5; + const time_spent = 15; + const extra_fields = { extra: true }; + client.__setPayload({ + session_id, + context: { node_id }, + progress, + time_spent, + extra_fields, + complete: false, + }); + await initContentSession({ nodeId: node_id }); + client.__reset(); + await initContentSession({ nodeId: node_id }); + expect(client).not.toHaveBeenCalled(); + }); + it('should not make a backend request when the session for lesson_id and node_id is already active', async () => { + const { initContentSession } = setUp(); + const session_id = 'test_session_id'; + const node_id = 'test_node_id'; + const lesson_id = 'test_lesson_id'; + const progress = 0.5; + const time_spent = 15; + const extra_fields = { extra: true }; + client.__setPayload({ + session_id, + context: { node_id, lesson_id }, + progress, + time_spent, + extra_fields, + complete: false, + }); + await initContentSession({ nodeId: node_id, lessonId: lesson_id }); + client.__reset(); + await initContentSession({ nodeId: node_id, lessonId: lesson_id }); + expect(client).not.toHaveBeenCalled(); + }); + it('should not make a backend request when the session for quiz_id is already active', async () => { + const { initContentSession } = setUp(); + const session_id = 'test_session_id'; + const quiz_id = 'test_quiz_id'; + const progress = 0.5; + const time_spent = 15; + const extra_fields = { extra: true }; + client.__setPayload({ + session_id, + context: { quiz_id }, + progress, + time_spent, + extra_fields, + complete: false, + }); + await initContentSession({ quizId: quiz_id }); + client.__reset(); + await initContentSession({ quizId: quiz_id }); + expect(client).not.toHaveBeenCalled(); + }); + it('should set the logging state with the return data for an assessment from the client', async () => { + const { + initContentSession, + session_id, + context, + progress, + time_spent, + extra_fields, + complete, + mastery_criterion, + pastattempts, + totalattempts, + } = setUp(); + const payload = { + session_id: 'test_session_id', + context: { node_id: 'test_node_id' }, + progress: 0.5, + time_spent: 15, + extra_fields: { extra: true }, + mastery_criterion: { type: 'm_of_n', m: 5, n: 7 }, + pastattempts: [ + { + id: 'attemptlog_id', + correct: 1, + complete: true, + hinted: false, + error: false, + item: 'item_identifier', + answer: { response: 'respond to this' }, + time_spent: 10, + }, + ], + totalattempts: 1, + complete: false, + }; + client.__setPayload(payload); + await initContentSession({ nodeId: payload.context.node_id }); + expect(get(session_id)).toEqual(payload.session_id); + expect(get(context).node_id).toEqual(payload.context.node_id); + expect(get(progress)).toEqual(payload.progress); + expect(get(time_spent)).toEqual(payload.time_spent); + expect(get(extra_fields)).toEqual(payload.extra_fields); + expect(get(complete)).toEqual(payload.complete); + expect(get(mastery_criterion)).toEqual(payload.mastery_criterion); + expect(get(pastattempts)).toEqual(payload.pastattempts); + expect(get(totalattempts)).toEqual(payload.totalattempts); + }); + it('should retry 5 times if it receives a 503', async () => { + const { initContentSession } = setUp(); + const error = { + response: { + status: 503, + headers: { + 'retry-after': 0.001, + }, + }, + }; + client.mockClear(); + client.mockImplementation(() => { + return Promise.reject(error); + }); + await expect(initContentSession({ nodeId: 'test' })).rejects.toMatchObject(error); + expect(client).toHaveBeenCalledTimes(5); + }); + }); + describe('updateContentSession', () => { + async function initStore(data = {}) { + const all = setUp(); + const session_id = 'test_session_id'; + const node_id = 'test_node_id'; + const progress = 0.5; + const time_spent = 15; + const extra_fields = {}; + const payload = { + session_id, + context: { node_id }, + progress, + time_spent, + extra_fields, + complete: false, + pastattempts: [], + mastery_criterion: { type: 'm_of_n', m: 5, n: 7 }, + totalattempts: 0, + }; + Object.assign(payload, data); + client.__setPayload(payload); + await all.initContentSession({ nodeId: node_id }); + client.__reset(); + return all; + } + it('should throw an error if called before a content session has been initialized', async () => { + const { updateContentSession } = setUp(); + try { + await updateContentSession({}); + } catch (error) { + expect(error).toEqual( + new ReferenceError('Cannot update a content session before one has been initialized') + ); + } + }); + it('should throw an error if called with both progress and progressDelta', async () => { + const { updateContentSession } = await initStore(); + try { + await updateContentSession({ progress: 1, progressDelta: 1 }); + } catch (error) { + expect(error).toEqual(new TypeError('Must only specify either progressDelta or progress')); + } + }); + it('should throw an error if called with non-numeric progress', async () => { + const { updateContentSession } = await initStore(); + try { + await updateContentSession({ progress: 'number' }); + } catch (error) { + expect(error).toEqual(new TypeError('progress must be a number')); + } + }); + it('should throw an error if called with non-numeric progressDelta', async () => { + const { updateContentSession } = await initStore(); + try { + await updateContentSession({ progressDelta: 'number' }); + } catch (error) { + expect(error).toEqual(new TypeError('progressDelta must be a number')); + } + }); + it('should throw an error if called with non-plain object contentState', async () => { + const { updateContentSession } = await initStore(); + try { + await updateContentSession({ contentState: 'notanobject' }); + } catch (error) { + expect(error).toEqual(new TypeError('contentState must be an object')); + } + }); + it('should throw an error if called with non-plain object interaction', async () => { + const { updateContentSession } = await initStore(); + try { + await updateContentSession({ interaction: 'notanobject' }); + } catch (error) { + expect(error).toEqual(new TypeError('interaction must be an object')); + } + }); + it('should not make a request to the backend if no arguments have been passed', async () => { + const { updateContentSession } = await initStore(); + await updateContentSession({}); + expect(client).not.toHaveBeenCalled(); + }); + it('should make a request to the backend if any changes have been passed', async () => { + const { updateContentSession } = await initStore(); + await updateContentSession({ contentState: { test: 'test' } }); + expect(client).toHaveBeenCalled(); + }); + it('should update complete if the backend returns complete', async () => { + const { updateContentSession, complete } = await initStore(); + client.__setPayload({ + complete: true, + }); + await updateContentSession({ contentState: { test: 'test' } }); + expect(get(complete)).toBe(true); + }); + it('should not update total progress if the backend returns complete and was not complete and user is not logged in', async () => { + const { updateContentSession, store } = await initStore(); + client.__setPayload({ + complete: true, + }); + store.commit('SET_TOTAL_PROGRESS', 0); + await updateContentSession({ contentState: { test: 'test' } }); + expect(store.state.core.totalProgress).toEqual(0); + }); + it('should update total progress if the backend returns complete and was not complete and user is logged in', async () => { + const { updateContentSession, store } = await initStore(); + store.commit('CORE_SET_SESSION', { kind: ['learner'] }); + store.commit('SET_TOTAL_PROGRESS', 0); + client.__setPayload({ + complete: true, + }); + await updateContentSession({ contentState: { test: 'test' } }); + expect(store.state.core.totalProgress).toEqual(1); + }); + it('should not update total progress if the backend returns complete and was already complete', async () => { + const { updateContentSession, store } = await initStore({ complete: true }); + store.commit('CORE_SET_SESSION', { kind: ['learner'] }); + store.commit('SET_TOTAL_PROGRESS', 0); + client.__setPayload({ + complete: true, + }); + await updateContentSession({ contentState: { test: 'test' } }); + expect(store.state.core.totalProgress).toEqual(0); + }); + it('should update progress and progress_delta if progress is updated under threshold', async () => { + const { updateContentSession, progress, progress_delta } = await initStore(); + await updateContentSession({ progress: 0.6 }); + expect(get(progress)).toEqual(0.6); + expect(get(progress_delta)).toEqual(0.1); + expect(client).not.toHaveBeenCalled(); + }); + it('should update progress and store progress_delta if progress is updated over threshold', async () => { + const { updateContentSession, progress } = await initStore(); + await updateContentSession({ progress: 1 }); + expect(get(progress)).toEqual(1); + expect(client.mock.calls[0][0].data.progress_delta).toEqual(0.5); + }); + it('should max progress and store progress_delta if progress is updated over threshold and over max value', async () => { + const { updateContentSession, progress } = await initStore(); + await updateContentSession({ progress: 2 }); + expect(get(progress)).toEqual(1); + expect(client.mock.calls[0][0].data.progress_delta).toEqual(0.5); + }); + it('should not update progress and store progress_delta if progress is updated under current value', async () => { + const { updateContentSession, progress, progress_delta } = await initStore(); + await updateContentSession({ progress: 0.4 }); + expect(get(progress)).toEqual(0.5); + expect(get(progress_delta)).toEqual(0); + expect(client).not.toHaveBeenCalled(); + }); + it('should update progress and progress_delta if progressDelta is updated under threshold', async () => { + const { updateContentSession, progress, progress_delta } = await initStore(); + await updateContentSession({ progressDelta: 0.1 }); + expect(get(progress)).toEqual(0.6); + expect(get(progress_delta)).toEqual(0.1); + expect(client).not.toHaveBeenCalled(); + }); + it('should update progress and store progress_delta if progressDelta is updated over threshold', async () => { + const { updateContentSession, progress } = await initStore(); + await updateContentSession({ progressDelta: 0.5 }); + expect(get(progress)).toEqual(1); + expect(client.mock.calls[0][0].data.progress_delta).toEqual(0.5); + }); + it('should max progress and store progress_delta if progressDelta is updated over threshold and over max value', async () => { + const { updateContentSession, progress } = await initStore(); + await updateContentSession({ progressDelta: 1.5 }); + expect(get(progress)).toEqual(1); + // Will store the maximum possible value for progress_delta which is 1, + // even though current progress can only increase by 0.5 + expect(client.mock.calls[0][0].data.progress_delta).toEqual(1); + }); + it('should update extra_fields and store if contentState is updated', async () => { + const { updateContentSession, extra_fields } = await initStore(); + await updateContentSession({ contentState: { newState: 0.2 } }); + expect(get(extra_fields)).toEqual({ contentState: { newState: 0.2 } }); + expect(client).toHaveBeenCalled(); + expect(client.mock.calls[0][0].data.extra_fields).toEqual({ + contentState: { newState: 0.2 }, + }); + }); + it('should update extra_fields and but not store to backend if not an update', async () => { + const { updateContentSession, extra_fields } = await initStore(); + await updateContentSession({ + contentState: { statements: [{ test: 'statement' }] }, + }); + expect(get(extra_fields)).toEqual({ + contentState: { statements: [{ test: 'statement' }] }, + }); + expect(client).toHaveBeenCalled(); + expect(client.mock.calls[0][0].data.extra_fields).toEqual({ + contentState: { statements: [{ test: 'statement' }] }, + }); + await updateContentSession({ + contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, + }); + expect(get(extra_fields)).toEqual({ + contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, + }); + expect(client).toHaveBeenCalled(); + expect(client.mock.calls[1][0].data.extra_fields).toEqual({ + contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, + }); + client.__reset(); + await updateContentSession({ + contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, + }); + expect(get(extra_fields)).toEqual({ + contentState: { statements: [{ test: 'statement' }, { test2: 'statement2' }] }, + }); + expect(client).not.toHaveBeenCalled(); + }); + it('should update pastattempts and store if interaction is passed without an id', async () => { + const { updateContentSession, pastattempts, pastattemptMap } = await initStore(); + await updateContentSession({ + interaction: { + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + }); + expect(get(pastattempts)[0]).toEqual({ + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }); + // No attempt is returned from the backend, so should not update the past attempts map, + // as no id for map. + expect(get(pastattemptMap)).toEqual({}); + expect(client).toHaveBeenCalled(); + expect(client.mock.calls[0][0].data.interactions).toEqual([ + { item: 'testitem', answer: { response: 'answer' }, correct: 1, complete: true }, + ]); + }); + it('should update pastattempts and map if passed without an id and backend returns id', async () => { + const { updateContentSession, pastattempts, pastattemptMap } = await initStore(); + client.__setPayload({ + attempts: [ + { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ], + }); + await updateContentSession({ + interaction: { + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + }); + expect(get(pastattempts)[0]).toEqual({ + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }); + expect(get(pastattemptMap)).toEqual({ + testid: { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + }); + expect(client).toHaveBeenCalled(); + expect(client.mock.calls[0][0].data.interactions).toEqual([ + { + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ]); + }); + it('should update pastattempts and map if passed without an id and backend returns id and additional interactions happen', async () => { + const { updateContentSession, pastattempts, pastattemptMap } = await initStore(); + client.__setPayload({ + attempts: [ + { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ], + }); + const interaction1 = { + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }; + await updateContentSession({ + interaction: interaction1, + }); + // Interaction without an id so gets saved to the backend. + expect(client).toHaveBeenCalled(); + client.__reset(); + const interaction2 = { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + hinted: true, + }; + await updateContentSession({ + interaction: interaction2, + }); + expect(get(pastattempts)).toHaveLength(1); + expect(get(pastattempts)[0]).toEqual(interaction2); + expect(get(pastattemptMap)).toEqual({ + testid: { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + hinted: true, + }, + }); + expect(client).not.toHaveBeenCalled(); + const interaction3 = { id: 'testid', item: 'testitem', error: true }; + await updateContentSession({ + interaction: interaction3, + }); + const interaction4 = { id: 'testid', item: 'testitem', error: true }; + await updateContentSession({ + interaction: interaction4, + }); + expect(get(pastattempts)).toHaveLength(1); + expect(get(pastattempts)[0]).toEqual({ + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + hinted: true, + error: true, + }); + expect(get(pastattemptMap)).toEqual({ + testid: { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + hinted: true, + error: true, + }, + }); + expect(client.mock.calls[0][0].data.interactions).toEqual([ + interaction2, + interaction3, + interaction4, + ]); + }); + it('should not overwrite correct, answer or simple_answer if not passed with the replace flag', async () => { + const { updateContentSession, pastattempts, pastattemptMap } = await initStore(); + client.__setPayload({ + attempts: [ + { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ], + }); + await updateContentSession({ + interaction: { + item: 'testitem', + answer: { response: 'answer' }, + simple_answer: 'nah', + correct: 1, + complete: true, + }, + }); + client.__reset(); + await updateContentSession({ + interaction: { + id: 'testid', + item: 'testitem', + answer: { response: 'not an answer' }, + simple_answer: 'yeah', + correct: 0, + complete: true, + hinted: true, + }, + }); + expect(get(pastattempts)).toHaveLength(1); + expect(get(pastattempts)[0]).toEqual({ + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + simple_answer: 'nah', + correct: 1, + complete: true, + hinted: true, + }); + expect(get(pastattemptMap)).toEqual({ + testid: { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + simple_answer: 'nah', + correct: 1, + complete: true, + hinted: true, + }, + }); + }); + it('should clear unsaved_interactions when successfully saved', async () => { + const { updateContentSession, unsaved_interactions } = await initStore(); + client.__setPayload({ + attempts: [ + { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ], + }); + await updateContentSession({ + interaction: { + item: 'testitem', + answer: { response: 'answer' }, + simple_answer: 'nah', + correct: 1, + complete: true, + }, + }); + expect(get(unsaved_interactions)).toHaveLength(0); + }); + it('should multiple unrelated interactions without overwriting', async () => { + const { updateContentSession, pastattempts, pastattemptMap } = await initStore(); + client.__setPayload({ + attempts: [ + { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ], + }); + await updateContentSession({ + interaction: { + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + }); + client.__reset(); + client.__setPayload({ + attempts: [ + { + id: 'testid1', + item: 'testitem1', + answer: { response: 'answer' }, + correct: 0, + complete: false, + }, + ], + }); + await updateContentSession({ + interaction: { + item: 'testitem1', + answer: { response: 'answer' }, + correct: 0, + complete: false, + }, + }); + client.__reset(); + client.__setPayload({ + attempts: [ + { + id: 'testid2', + item: 'testitem2', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }, + ], + }); + await updateContentSession({ + interaction: { + item: 'testitem2', + answer: { response: 'answer' }, + correct: 1, + complete: true, + error: true, + }, + }); + client.__reset(); + expect(get(pastattempts)).toHaveLength(3); + expect(get(pastattempts)[2]).toEqual({ + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + }); + expect(get(pastattempts)[1]).toEqual({ + id: 'testid1', + item: 'testitem1', + answer: { response: 'answer' }, + correct: 0, + complete: false, + }); + expect(get(pastattempts)[0]).toEqual({ + id: 'testid2', + item: 'testitem2', + answer: { response: 'answer' }, + correct: 1, + complete: true, + error: true, + }); + expect(Object.keys(get(pastattemptMap))).toHaveLength(3); + await updateContentSession({ + interaction: { + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + hinted: true, + }, + }); + await updateContentSession({ + interaction: { + id: 'testid1', + item: 'testitem1', + answer: { response: 'answer' }, + correct: 0, + complete: true, + }, + }); + await updateContentSession({ + interaction: { + id: 'testid2', + item: 'testitem2', + answer: { response: 'answer' }, + replace: true, + correct: 0, + complete: true, + }, + }); + expect(get(pastattempts)).toHaveLength(3); + expect(get(pastattempts)[2]).toEqual({ + id: 'testid', + item: 'testitem', + answer: { response: 'answer' }, + correct: 1, + complete: true, + hinted: true, + }); + expect(get(pastattempts)[1]).toEqual({ + id: 'testid1', + item: 'testitem1', + answer: { response: 'answer' }, + correct: 0, + complete: true, + }); + expect(get(pastattempts)[0]).toEqual({ + id: 'testid2', + item: 'testitem2', + answer: { response: 'answer' }, + correct: 0, + complete: true, + error: true, + }); + }); + it('should debounce requests', async () => { + const { updateContentSession } = await initStore(); + updateContentSession({ progress: 1 }); + updateContentSession({ contentState: { yes: 'no' } }); + updateContentSession({ progressDelta: 1 }); + updateContentSession({ progress: 1 }); + updateContentSession({ contentState: { yes: 'no' } }); + updateContentSession({ progressDelta: 1 }); + await updateContentSession({ progress: 1 }); + expect(client).toHaveBeenCalledTimes(1); + }); + it('should retry 5 times if it receives a 503', async () => { + const { updateContentSession } = await initStore(); + const error = { + response: { + status: 503, + headers: { + 'retry-after': 0.001, + }, + }, + }; + client.mockImplementation(() => { + return Promise.reject(error); + }); + await expect(updateContentSession({ progress: 1 })).rejects.toMatchObject(error); + expect(client).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js b/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js new file mode 100644 index 00000000000..a6ae196ae02 --- /dev/null +++ b/kolibri/plugins/learn/assets/src/composables/useProgressTracking.js @@ -0,0 +1,485 @@ +/** + * A composable function containing logic related to tracking + * progress through resources + * All data exposed by this function belong to a current learner. + */ + +import { ref, reactive, getCurrentInstance, onBeforeUnmount } from 'kolibri.lib.vueCompositionApi'; +import { get, set } from '@vueuse/core'; +import fromPairs from 'lodash/fromPairs'; +import isNumber from 'lodash/isNumber'; +import isPlainObject from 'lodash/isPlainObject'; +import isUndefined from 'lodash/isUndefined'; +import { diff } from 'deep-object-diff'; +import client from 'kolibri.client'; +import logger from 'kolibri.lib.logging'; +import urls from 'kolibri.urls'; + +const logging = logger.getLogger(__filename); + +const intervalTime = 5000; // Frequency at which time logging is updated +const progressThreshold = 0.4; // Update logs if user has reached 40% more progress +const timeThreshold = 120; // Update logs if 120 seconds have passed since last update +const maxRetries = 5; + +const noSessionErrorText = 'Cannot update a content session before one has been initialized'; + +// Set the debounce artificially short in tests to prevent slowdowns. +const updateContentSessionDebounceTime = process.env.NODE_ENV === 'test' ? 1 : 2000; + +function valOrNull(val) { + return typeof val !== 'undefined' ? val : null; +} + +function _zeroToOne(num) { + return Math.min(1, Math.max(num || 0, 0)); +} + +function threeDecimalPlaceRoundup(num) { + if (num) { + return Math.ceil(num * 1000) / 1000; + } + return num; +} + +// Function to delay rejection to allow delayed retry behaviour +function rejectDelay(reason, retryDelay = 5000) { + return new Promise(function(resolve, reject) { + setTimeout(reject.bind(null, reason), retryDelay); + }); +} + +// Items to only update on an +// already existing attempt if +// replace is set to true. +// We use an object rather than +// an array for easy lookup. +const replaceBlocklist = { + correct: true, + answer: true, + simple_answer: true, + replace: true, +}; + +export default function useProgressTracking(store) { + store = store || getCurrentInstance().proxy.$store; + const complete = ref(null); + const progress_state = ref(null); + const progress_delta = ref(null); + const time_spent = ref(null); + const time_spent_delta = ref(null); + const session_id = ref(null); + const extra_fields = reactive({}); + const extra_fields_dirty_bit = ref(null); + const mastery_criterion = ref(null); + const totalattempts = ref(null); + const pastattempts = reactive([]); + const pastattemptMap = reactive({}); + // Array of as yet unsaved interactions + const unsaved_interactions = reactive([]); + const context = ref(null); + + let lastElapsedTimeCheck; + let timeCheckIntervalTimer; + + function getNewTimeElapsed() { + // Timer has not been started + if (!lastElapsedTimeCheck) { + return 0; + } + const currentTime = new Date(); + const timeElapsed = currentTime - lastElapsedTimeCheck; + lastElapsedTimeCheck = currentTime; + // Some browsers entirely suspend code execution in background tabs, + // which can lead to unreliable timing if a tab has been in the background + // if the time elasped is significantly longer than the interval that we are + // checking this at, we should discard the measured time elapsed here as erroneous, + // and just say that no time has elapsed at all. + if (timeElapsed > intervalTime * 10) { + return 0; + } + // Return a time in seconds, rather than milliseconds. + return timeElapsed / 1000; + } + + function clearTrackingInterval() { + clearInterval(timeCheckIntervalTimer); + timeCheckIntervalTimer = null; + lastElapsedTimeCheck = null; + } + + function makeRequestWithRetry(requestFunction, ...args) { + // Create an initial rejection so that we can chain consistently + // in the retry loop. + let attempt = Promise.reject({ response: { status: 503 } }); + for (var i = 0; i < maxRetries; i++) { + // Catch any previous error and then try to make the session update again + attempt = attempt + .catch(err => { + if (err && err.response && err.response.status === 503) { + return requestFunction(...args); + } + return Promise.reject(err); + }) + .catch(err => { + // Only try to handle 503 status codes here, as otherwise we might be continually + // retrying the server when it is rejecting the request for valid reasons. + if (err && err.response && err.response.status === 503) { + // Defer to the server's Retry-After header if it is set. + const retryAfter = (err.response.headers || {})['retry-after']; + // retry-after header is in seconds, we need a value in milliseconds. + return rejectDelay(err, retryAfter ? retryAfter * 1000 : retryAfter); + } + return Promise.reject(err); + }); + } + return attempt; + } + + function _makeInitContentSessionRequest(data) { + return client({ + method: 'post', + url: urls['kolibri:core:trackprogress-list'](), + data: data, + }).then(response => { + const data = response.data; + set(context, valOrNull(data.context)); + set(complete, valOrNull(data.complete)); + set(progress_state, threeDecimalPlaceRoundup(valOrNull(data.progress))); + set(progress_delta, 0); + set(time_spent, valOrNull(data.time_spent)); + set(time_spent_delta, 0); + set(session_id, valOrNull(data.session_id)); + Object.assign(extra_fields, data.extra_fields || {}); + set(mastery_criterion, valOrNull(data.mastery_criterion)); + pastattempts.push(...(data.pastattempts || [])); + Object.assign( + pastattemptMap, + data.pastattempts ? fromPairs(data.pastattempts.map(a => [a.id, a])) : {} + ); + set(totalattempts, valOrNull(data.totalattempts)); + set(unsaved_interactions, []); + }); + } + + /** + * Initialize a content session for progress tracking + * To be called on page load for content renderers + */ + function initContentSession({ nodeId, lessonId, quizId } = {}) { + const data = {}; + if (!nodeId && !quizId) { + throw TypeError('Must define either nodeId or quizId'); + } + if ((nodeId || lessonId) && quizId) { + throw TypeError('quizId must be the only defined parameter if defined'); + } + let sessionStarted = false; + + if (quizId) { + sessionStarted = get(context) && get(context).quiz_id === quizId; + data.quiz_id = quizId; + } + + if (nodeId) { + sessionStarted = get(context) && get(context).node_id === nodeId; + data.node_id = nodeId; + if (lessonId) { + sessionStarted = sessionStarted && get(context) && get(context).lesson_id === lessonId; + data.lesson_id = lessonId; + } + } + + if (sessionStarted) { + return; + } + + return makeRequestWithRetry(_makeInitContentSessionRequest, data); + } + + function updateAttempt(interaction) { + // We never store replace + const blocklist = interaction.replace ? { replace: true } : replaceBlocklist; + if (interaction.id) { + if (!pastattemptMap[interaction.id]) { + const nowSavedInteraction = get(pastattempts).find( + a => !a.id && a.item === interaction.item + ); + Object.assign(nowSavedInteraction, interaction); + pastattemptMap[nowSavedInteraction.id] = nowSavedInteraction; + set(totalattempts, get(totalattempts) + 1); + } else { + for (let key in interaction) { + if (!blocklist[key]) { + pastattemptMap[interaction.id][key] = interaction[key]; + } + } + } + } + } + + function makeSessionUpdateRequest(data) { + const wasComplete = get(complete); + return client({ + method: 'put', + url: urls['kolibri:core:trackprogress-detail'](get(session_id)), + data, + }).then(response => { + if (response.data.attempts) { + for (let attempt of response.data.attempts) { + updateAttempt(attempt); + } + } + if (response.data.complete) { + set(complete, true); + if (store.getters.isUserLoggedIn && !wasComplete) { + store.commit('INCREMENT_TOTAL_PROGRESS', 1); + } + } + return response.data; + }); + } + + // Start the savingPromise as a resolved promise + // so we can always just chain from this promise for subsequent saves. + let savingPromise = Promise.resolve(); + + let updateContentSessionResolveRejectStack = []; + let updateContentSessionTimeout; + let forceSessionUpdate = false; + + function loggingSaving() { + set(progress_delta, 0); + set(time_spent_delta, 0); + set(extra_fields_dirty_bit, false); + // Do this to reactively clear the array + unsaved_interactions.splice(0); + forceSessionUpdate = false; + } + + function immediatelyUpdateContentSession() { + // Once this timeout has been executed, we can reset the global timeout + // to null, as we are now actually invoking the debounced function. + updateContentSessionTimeout = null; + + const progressDelta = get(progress_delta); + const timeSpentDelta = get(time_spent_delta); + const extraFieldsChanged = get(extra_fields_dirty_bit); + const progress = get(progress_state); + const unsavedInteractions = JSON.parse(JSON.stringify(unsaved_interactions)); + const extraFields = JSON.parse(JSON.stringify(extra_fields)); + + if ( + // Always update if there's an attempt that hasn't got an id yet, or if there have been + // at least 3 additional interactions. + (unsavedInteractions.length && + (unsavedInteractions.some(r => !r.id) || unsavedInteractions.length > 2)) || + progressDelta >= progressThreshold || + (progressDelta && progress >= 1) || + timeSpentDelta >= timeThreshold || + extraFieldsChanged || + forceSessionUpdate + ) { + // Clear the temporary state that we've + // picked up to save to the backend. + loggingSaving(); + const data = {}; + if (progressDelta) { + data.progress_delta = progressDelta; + } + if (timeSpentDelta) { + data.time_spent_delta = timeSpentDelta; + } + if (unsavedInteractions.length) { + data.interactions = unsavedInteractions; + } + if (extraFieldsChanged) { + data.extra_fields = extraFields; + } + // Don't try to make a new save until the previous save + // has completed. + savingPromise = savingPromise.then(() => { + return makeRequestWithRetry(makeSessionUpdateRequest, data); + }); + } + return savingPromise + .then(result => { + // If it is successful call all of the resolve functions that we have stored + // from all the Promises that have been returned while this specific debounce + // has been active. + for (let [resolve] of updateContentSessionResolveRejectStack) { + resolve(result); + } + // Reset the stack for resolve/reject functions, so that future invocations + // do not call these now consumed functions. + updateContentSessionResolveRejectStack = []; + }) + .catch(err => { + // If there is an error call reject for all previously returned promises. + for (let [, reject] of updateContentSessionResolveRejectStack) { + reject(err); + } + // Likewise reset the stack. + updateContentSessionResolveRejectStack = []; + }); + } + + /** + * Update a content session for progress tracking + */ + function updateContentSession({ + progressDelta, + progress, + contentState, + interaction, + immediate = false, + // Whether to update regardless of any conditions. + // Used to ensure state is always saved when a session closes. + force = false, + } = {}) { + if (get(session_id) === null) { + throw ReferenceError(noSessionErrorText); + } + if (!isUndefined(progressDelta) && !isUndefined(progress)) { + throw TypeError('Must only specify either progressDelta or progress'); + } + if (!isUndefined(progress)) { + if (!isNumber(progress)) { + throw TypeError('progress must be a number'); + } + progress = _zeroToOne(progress); + progress = threeDecimalPlaceRoundup(progress); + if (get(progress_state) < progress) { + set(progress_delta, threeDecimalPlaceRoundup(progress - get(progress_state))); + set(progress_state, progress); + } + } + if (!isUndefined(progressDelta)) { + if (!isNumber(progressDelta)) { + throw TypeError('progressDelta must be a number'); + } + progressDelta = _zeroToOne(progressDelta); + progressDelta = threeDecimalPlaceRoundup(progressDelta); + set(progress_delta, threeDecimalPlaceRoundup(get(progress_delta) + progressDelta)); + set( + progress_state, + Math.min(threeDecimalPlaceRoundup(get(progress_state) + progressDelta), 1) + ); + } + if (!isUndefined(contentState)) { + if (!isPlainObject(contentState)) { + throw TypeError('contentState must be an object'); + } + const delta = diff(extra_fields, { ...extra_fields, contentState }); + const changed = Boolean(Object.keys(delta).length); + if (changed) { + extra_fields.contentState = contentState; + set(extra_fields_dirty_bit, true); + } + } + if (!isUndefined(interaction)) { + if (!isPlainObject(interaction)) { + throw TypeError('interaction must be an object'); + } + unsaved_interactions.push(interaction); + if (!interaction.id) { + const unsavedInteraction = get(pastattempts).find( + a => !a.id && a.item === interaction.item + ); + if (unsavedInteraction) { + for (let key in interaction) { + set(unsavedInteraction, key, interaction[key]); + } + } else { + pastattempts.unshift(interaction); + set(totalattempts, get(totalattempts) + 1); + } + } + updateAttempt(interaction); + } + // Reset the elapsed time in the timer + const elapsedTime = getNewTimeElapsed(); + // Discard the time that has passed if the page is not visible. + if (store.state.pageVisible && elapsedTime) { + /* Update the logging state with new timing information */ + set(time_spent, get(time_spent) + threeDecimalPlaceRoundup(elapsedTime)); + set(time_spent_delta, threeDecimalPlaceRoundup(get(time_spent_delta) + elapsedTime)); + } + + immediate = (!isUndefined(interaction) && !interaction.id) || immediate; + forceSessionUpdate = forceSessionUpdate || force; + // Logic for promise returning debounce vendored and modified from: + // https://github.com/sindresorhus/p-debounce/blob/main/index.js + // Return a promise that will consistently resolve when this debounced + // function invocation is completed. + return new Promise((resolve, reject) => { + // Clear any current timeouts, so that this invocation takes precedence + // Any subsequent calls will then also revoke this timeout. + clearTimeout(updateContentSessionTimeout); + // Add the resolve and reject handlers for this promise to the stack here. + updateContentSessionResolveRejectStack.push([resolve, reject]); + if (immediate) { + // If immediate invocation is required immediately call the handler + // rather than using a timeout delay. + immediatelyUpdateContentSession(); + } else { + // Otherwise update the timeout to this invocation. + updateContentSessionTimeout = setTimeout( + immediatelyUpdateContentSession, + updateContentSessionDebounceTime + ); + } + }); + } + + /** + * Start interval timer and set start time + * @param {int} interval + */ + function startTrackingProgress() { + timeCheckIntervalTimer = setInterval(updateContentSession, intervalTime); + lastElapsedTimeCheck = new Date(); + } + + /** + * Stop interval timer and update latest times + * Must be called after startTrackingProgress + */ + function stopTrackingProgress() { + clearTrackingInterval(); + try { + updateContentSession({ immediate: true, force: true }).catch(err => { + logging.debug(err); + }); + } catch (e) { + if (e instanceof ReferenceError && e.message === noSessionErrorText) { + logging.debug( + 'Tried to stop tracking progress when no content session had been initialized' + ); + } else { + throw e; + } + } + } + + onBeforeUnmount(stopTrackingProgress); + + return { + initContentSession, + updateContentSession, + startTrackingProgress, + stopTrackingProgress, + session_id, + context, + progress: progress_state, + progress_delta, + time_spent, + extra_fields, + complete, + totalattempts, + pastattempts, + pastattemptMap, + mastery_criterion, + unsaved_interactions, + }; +} diff --git a/kolibri/plugins/learn/assets/src/modules/coreLearn/utils.js b/kolibri/plugins/learn/assets/src/modules/coreLearn/utils.js index b996b83ceff..67536af6457 100644 --- a/kolibri/plugins/learn/assets/src/modules/coreLearn/utils.js +++ b/kolibri/plugins/learn/assets/src/modules/coreLearn/utils.js @@ -1,5 +1,4 @@ import { get } from '@vueuse/core'; -import { ContentNodeProgressResource } from 'kolibri.resources'; import { ContentNodeKinds } from 'kolibri.coreVue.vuex.constants'; import { assessmentMetaDataState } from 'kolibri.coreVue.vuex.mappers'; import { getContentNodeThumbnail } from 'kolibri.utils.contentNode'; @@ -34,18 +33,3 @@ export function _collectionState(data) { item.kind === ContentNodeKinds.TOPICS ? normalizeContentNode(item) : contentState(item) ); } - -/** - * Cache utility functions - * - * These methods are used to manipulate client side cache to reduce requests - */ - -export function updateContentNodeProgress(nodeId, progressFraction) { - /* - * Update the progress_fraction directly on the model object, so as to prevent having - * to cache bust the model (and hence the entire collection), because some progress was - * made on this ContentNode. - */ - ContentNodeProgressResource.getModel(nodeId).set({ progress_fraction: progressFraction }); -} diff --git a/kolibri/plugins/learn/assets/src/modules/examViewer/handlers.js b/kolibri/plugins/learn/assets/src/modules/examViewer/handlers.js index 4e68be0ddbd..a514ce481f2 100644 --- a/kolibri/plugins/learn/assets/src/modules/examViewer/handlers.js +++ b/kolibri/plugins/learn/assets/src/modules/examViewer/handlers.js @@ -2,7 +2,6 @@ import { ContentNodeResource, ClassroomResource, ExamResource } from 'kolibri.re import samePageCheckGenerator from 'kolibri.utils.samePageCheckGenerator'; import { convertExamQuestionSources } from 'kolibri.utils.exams'; import ConditionalPromise from 'kolibri.lib.conditionalPromise'; -import router from 'kolibri.coreVue.router'; import shuffled from 'kolibri.utils.shuffled'; import { ClassesPageNames } from '../../constants'; import { contentState } from '../coreLearn/utils'; @@ -16,7 +15,6 @@ export function showExam(store, params, alreadyOnQuiz) { store.commit('SET_PAGE_NAME', ClassesPageNames.EXAM_VIEWER); const userId = store.getters.currentUserId; - const examParams = { user: userId, exam: examId }; if (!userId) { store.commit('CORE_SET_ERROR', 'You must be logged in as a learner to view this page'); @@ -25,28 +23,13 @@ export function showExam(store, params, alreadyOnQuiz) { const promises = [ ClassroomResource.fetchModel({ id: classId }), ExamResource.fetchModel({ id: examId }), - store - .dispatch('initContentSession', { quizId: examId }) - .catch(err => (err.response.status === 403 ? true : Promise.reject(err))), store.dispatch('setAndCheckChannels'), ]; ConditionalPromise.all(promises).only( samePageCheckGenerator(store), - ([classroom, exam, closed]) => { + ([classroom, exam]) => { store.commit('classAssignments/SET_CURRENT_CLASSROOM', classroom); - if (closed) { - // If exam is closed, then redirect to route for the report - return router.replace({ - name: ClassesPageNames.EXAM_REPORT_VIEWER, - params: { - ...examParams, - questionNumber: 0, - questionInteraction: 0, - }, - }); - } - let contentPromise; if (exam.question_sources.length) { contentPromise = ContentNodeResource.fetchCollection({ diff --git a/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue b/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue index 4baf4d53fc8..948984907fc 100644 --- a/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue +++ b/kolibri/plugins/learn/assets/src/views/AssessmentWrapper/index.vue @@ -138,7 +138,7 @@ oriented data synchronization.