UNPKG

@coorpacademy/progression-engine

Version:

522 lines (517 loc) 17.5 kB
import _get from "lodash/fp/get"; import _pick from "lodash/fp/pick"; import _omit from "lodash/fp/omit"; import test from 'ava'; import updateState from '../update-state'; import { getConfig } from '../config'; import { firstStateReview, wrongAnswersAfterLastStepStateReview } from '../compute-next-step/test/fixtures/states'; import { stateForFirstSlide, stateForSecondSlide, extraLifeProgressionState } from './fixtures/states'; const engine = { ref: 'microlearning', version: '1' }; const config = getConfig(engine); function contentResourceViewedAction(contentType, contentRef, lessonRef) { return Object.freeze({ type: 'resource', payload: { resource: { ref: lessonRef, type: 'video', version: '1' }, content: { ref: contentRef, type: contentType, version: '1' } } }); } const wrongAnswerAction = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.2', type: 'slide' }, nextContent: { ref: '1.A1.1', type: 'slide' }, isCorrect: false, godMode: false, instructions: null } }); const extraLifeAcceptedAction = Object.freeze({ type: 'extraLifeAccepted', payload: { content: { type: 'node', ref: 'extraLife' }, nextContent: { ref: '1.A1.1', type: 'slide' }, instructions: null } }); // test('should return a valid state when there are no actions', t => { // const content: Content = Object.freeze({ // type: 'chapter', // ref: '1.A1', // version: '1.0.0' // }); // const initialContent: Content = Object.freeze({ // type: 'slide', // ref: '1.A1.1' // }); // const progression = createProgression(engine, content, {livesDisabled: false}, initialContent); // const state = updateState(engine, progression.state, []); // t.is(state.lives, 1); // t.is(state.stars, 0); // t.true(state.isCorrect); // t.is(state.content, undefined); // t.deepEqual(state.nextContent, initialContent); // t.deepEqual(state.slides, []); // t.deepEqual(state.requestedClues, []); // t.deepEqual(state.viewedResources, []); // t.deepEqual(state.step, {current: 1}); // }); test('should return a valid state when there are no actions and state is empty', t => { // $FlowFixMe const state = updateState(getConfig(engine), {}, []); t.is(state.lives, 1); t.is(state.stars, 0); t.true(state.isCorrect); t.is(state.content, undefined); t.is(state.nextContent, undefined); t.deepEqual(state.slides, []); t.deepEqual(state.requestedClues, []); t.deepEqual(state.viewedResources, []); t.deepEqual(state.step, { current: 1 }); }); test('should update state when answering the first question correctly', t => { const state = Object.freeze(stateForFirstSlide); const action = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.1', type: 'slide' }, nextContent: { ref: '1.A1.2', type: 'slide' }, isCorrect: true, godMode: false, instructions: null } }); const pickUnchangedFields = _pick(['lives', 'requestedClues', 'viewedResources']); const newState = updateState(config, state, [action]); t.true(newState.isCorrect, '`isCorrect` should reflect the `isCorrect` field from the action payload'); t.deepEqual(newState.slides, ['1.A1.1'], 'answered slide should have been stored in `slides`'); t.deepEqual(newState.step, { current: 2 }, 'step progression is wrong'); t.is(newState.stars, 4, 'step progression is wrong'); t.deepEqual(newState.content, state.nextContent, '`content` should be updated to be the previous `nextContent`'); t.deepEqual(newState.nextContent, action.payload.nextContent, "`nextContent` should be updated to be the action's `nextContent`"); t.deepEqual(pickUnchangedFields(newState), pickUnchangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should update state when answering the another question correctly', t => { const state = Object.freeze(stateForSecondSlide); const action = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.2', type: 'slide' }, nextContent: { ref: '1.A1.1', type: 'slide' }, isCorrect: true, godMode: false, instructions: null } }); const pickUnchangedFields = _pick(['lives', 'requestedClues', 'viewedResources']); const newState = updateState(config, state, [action]); t.true(newState.isCorrect, '`isCorrect` should reflect the `isCorrect` field from the action payload'); t.deepEqual(newState.slides, ['1.A1.4', '1.A1.2'], 'answered slide should have been stored in `slides`'); t.deepEqual(newState.step, { current: 3 }, 'step progression is wrong'); t.is(newState.stars, 8, 'step progression is wrong'); t.deepEqual(newState.allAnswers, [{ slideRef: '1.A1.4', isCorrect: true, answer: ['bar'] }, { slideRef: '1.A1.2', isCorrect: true, answer: ['foo'] }], 'answer should have been stored in `allAnswers`'); t.deepEqual(newState.content, state.nextContent, '`content` should be updated to be the previous `nextContent`'); t.deepEqual(newState.nextContent, action.payload.nextContent, "`nextContent` should be updated to be the action's `nextContent`"); t.deepEqual(pickUnchangedFields(newState), pickUnchangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should update state when answering a question incorrectly', t => { const state = Object.freeze(stateForSecondSlide); const pickUnchangedFields = _pick(['stars', 'requestedClues', 'viewedResources']); const newState = updateState(config, state, [wrongAnswerAction]); t.is(newState.hasViewedAResourceAtThisStep, false); t.true(newState.lives === 0, '`lives` should have been decremented'); t.false(newState.isCorrect, '`isCorrect` should reflect the `isCorrect` field from the action payload'); t.deepEqual(newState.step, { current: 3 }, 'step progression is wrong'); t.deepEqual(newState.slides, ['1.A1.4', '1.A1.2'], 'answered slide should have been stored in `slides`'); t.deepEqual(newState.content, state.nextContent, '`content` should be updated to be the previous `nextContent`'); t.deepEqual(newState.nextContent, wrongAnswerAction.payload.nextContent, "`nextContent` should be updated to be the action's `nextContent`"); t.deepEqual(pickUnchangedFields(newState), pickUnchangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should set livesDisabled to false when it is undefined', t => { const actions = [wrongAnswerAction]; const stateUndefined = updateState(config, Object.freeze({ ...stateForSecondSlide, livesDisabled: undefined }), actions); const stateFalse = updateState(config, Object.freeze({ ...stateForSecondSlide, livesDisabled: false }), actions); t.false(stateUndefined.livesDisabled); t.deepEqual(stateUndefined, stateFalse); }); test('should not decrement lives when answering a question incorrectly when lives are disabled', t => { const state = Object.freeze(stateForSecondSlide); const newState = updateState({ ...config, livesDisabled: true }, state, [wrongAnswerAction]); t.is(newState.lives, 1, '`lives` should not have been decremented'); t.true(newState.livesDisabled); }); test('should update state when asking for a clue', t => { const state = Object.freeze(stateForSecondSlide); const action = Object.freeze({ type: 'clue', payload: { content: { ref: '1.A1.2', type: 'slide' } } }); const pickUnchangedFields = _pick(['content', 'nextContent', 'lives', 'slides', 'isCorrect', 'step']); const newState = updateState(config, state, [action]); t.is(newState.stars, 3); t.deepEqual(newState.requestedClues, ['1.A1.2']); t.deepEqual(pickUnchangedFields(newState), pickUnchangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should update stars once when actions has several AskClueAction for the same content', t => { const state = Object.freeze(stateForSecondSlide); const action = Object.freeze({ type: 'clue', payload: { content: { ref: '1.A1.2', type: 'slide' } } }); const omitChangedFields = _omit(['requestedClues', 'stars']); const newState = updateState(config, state, [action, action]); t.is(newState.stars, 3); t.deepEqual(newState.requestedClues, ['1.A1.2']); t.deepEqual(omitChangedFields(newState), omitChangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should update stars after viewing a resource', t => { const state = Object.freeze(stateForFirstSlide); const omitChangedFields = _omit(['viewedResources', 'stars', 'hasViewedAResourceAtThisStep']); const newState = updateState(config, state, [contentResourceViewedAction('chapter', '1.A1', 'lesson_1')]); t.is(newState.stars, 4); t.deepEqual(newState.viewedResources, [{ type: 'chapter', ref: '1.A1', resources: ['lesson_1'] }]); t.deepEqual(omitChangedFields(newState), omitChangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should update stars after viewing a resource (with different number of stars)', t => { const state = Object.freeze(stateForFirstSlide); const configWithDifferentStars = { ...config, starsPerResourceViewed: 5 }; const omitChangedFields = _omit(['viewedResources', 'stars', 'hasViewedAResourceAtThisStep']); const newState = updateState(configWithDifferentStars, state, [contentResourceViewedAction('chapter', '1.A1', 'lesson_1')]); t.is(newState.stars, 5); t.deepEqual(newState.viewedResources, [{ type: 'chapter', ref: '1.A1', resources: ['lesson_1'] }]); t.deepEqual(omitChangedFields(newState), omitChangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should only count stars for viewing a resource once for every chapter even if there are multiple resource viewing actions', t => { const state = Object.freeze(stateForFirstSlide); const omitChangedFields = _omit(['viewedResources', 'stars', 'hasViewedAResourceAtThisStep']); const newState = updateState(config, state, [contentResourceViewedAction('chapter', '1.A1', 'lesson_1'), contentResourceViewedAction('chapter', '1.A1', 'lesson_2'), contentResourceViewedAction('chapter', '1.A1', 'lesson_1')]); t.is(newState.stars, 4); t.deepEqual(newState.viewedResources, [{ type: 'chapter', ref: '1.A1', resources: ['lesson_1', 'lesson_2'] }]); t.deepEqual(omitChangedFields(newState), omitChangedFields(state), 'Some fields that should not have been touched have been modified'); }); test('should count stars for viewing resources multiple times as long as they are for different chapters', t => { const state = Object.freeze(stateForFirstSlide); const omitChangedFields = _omit(['viewedResources', 'stars', 'hasViewedAResourceAtThisStep']); const newState = updateState(config, state, [contentResourceViewedAction('chapter', '1.A1', 'lesson_1'), contentResourceViewedAction('chapter', '1.A1', 'lesson_1'), contentResourceViewedAction('chapter', '1.A2', 'lesson_1')]); t.is(newState.stars, 8); t.deepEqual(newState.viewedResources, [{ type: 'chapter', ref: '1.A1', resources: ['lesson_1'] }, { type: 'chapter', ref: '1.A2', resources: ['lesson_1'] }]); t.deepEqual(omitChangedFields(newState), omitChangedFields(state), 'Some fields that should not have been touched have been modified'); }); test("should throw if the state's nextContent is not the same as the action's content", t => { const state = Object.freeze(stateForSecondSlide); const action = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.200', type: 'slide' }, nextContent: { ref: '1.A1.1', type: 'slide' }, isCorrect: true, godMode: false, instructions: null } }); t.throws(() => updateState(config, state, [action]), { message: 'The content of the progression state does not match the answer action: state.nextContent: {"ref":"1.A1.2","type":"slide"} | action.payload.content: {"ref":"1.A1.200","type":"slide"}' }); }); test('should add one life when using extra life', t => { const state = Object.freeze(extraLifeProgressionState); const newState = updateState(config, state, [extraLifeAcceptedAction]); t.is(newState.lives, 1); t.is(newState.remainingLifeRequests, 0); t.is(newState.nextContent.type, 'slide'); t.is(_get('content.ref', newState), 'extraLife'); t.is(newState.hasViewedAResourceAtThisStep, false); }); test('should not add a life when accepting an extraLife when lives are disabled', t => { const state = Object.freeze(extraLifeProgressionState); const newState = updateState({ ...config, livesDisabled: true }, state, [extraLifeAcceptedAction]); t.is(newState.lives, 0, '`lives` should not have been incremented'); t.true(newState.livesDisabled); t.is(newState.hasViewedAResourceAtThisStep, false); }); test('should go to failure when refusing extra life', t => { const state = Object.freeze(extraLifeProgressionState); const action = Object.freeze({ type: 'extraLifeRefused', payload: { content: { type: 'node', ref: 'extraLife' }, nextContent: { ref: 'failExitNode', type: 'failure' } } }); const newState = updateState(config, state, [action]); t.is(newState.lives, 0); t.is(newState.remainingLifeRequests, 1); t.is(newState.nextContent.type, 'failure'); t.is(_get('content.ref', newState), 'extraLife'); }); test('should update step when answering a question', t => { const state = Object.freeze({ ...stateForSecondSlide, chapters: ['1.A1', '1.A2'], step: { current: 2 } }); const action = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.2', type: 'slide' }, nextContent: { ref: '1.A1.1', type: 'slide' }, isCorrect: true, godMode: false, instructions: null } }); const newState = updateState(config, state, [action]); t.deepEqual(newState.step, { current: 3 }, 'step progression is wrong'); t.is(newState.hasViewedAResourceAtThisStep, false); }); test('should update pendingSlides when answer is wrong', t => { const configReview = getConfig({ ref: 'review', version: '1' }); const action = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.1', type: 'slide' }, nextContent: { ref: '1.A1.2', type: 'slide' }, isCorrect: false, godMode: false, instructions: null } }); const newState = updateState(configReview, firstStateReview, [action]); t.deepEqual(newState, { content: { ref: '1.A1.1', type: 'slide' }, nextContent: { ref: '1.A1.2', type: 'slide' }, lives: 0, livesDisabled: true, stars: 0, slides: ['1.A1.1'], requestedClues: [], viewedResources: [], step: { current: 2 }, isCorrect: false, remainingLifeRequests: 0, hasViewedAResourceAtThisStep: false, allAnswers: [{ slideRef: '1.A1.1', isCorrect: false, answer: ['foo'] }], variables: {}, pendingSlides: ['1.A1.1'] }); }); test('should update pendingSlides when a pending slide if finally correctly answered', t => { const configReview = getConfig({ ref: 'review', version: '1' }); const action = Object.freeze({ type: 'answer', payload: { answer: ['foo'], content: { ref: '1.A1.2', type: 'slide' }, nextContent: { ref: '1.A1.4', type: 'slide' }, isCorrect: true, godMode: false, instructions: null } }); const newState = updateState(configReview, wrongAnswersAfterLastStepStateReview, [action]); t.deepEqual(newState, { content: { ref: '1.A1.2', type: 'slide' }, nextContent: { ref: '1.A1.4', type: 'slide' }, lives: 0, livesDisabled: true, stars: 24, slides: ['1.A1.1', '1.A1.2', '1.A1.3', '1.A1.4', '1.A1.5', '1.A1.2'], requestedClues: [], viewedResources: [], step: { current: 7 }, isCorrect: true, remainingLifeRequests: 0, hasViewedAResourceAtThisStep: false, allAnswers: [{ slideRef: '1.A1.1', isCorrect: true, answer: ['foo', 'bar'] }, { slideRef: '1.A1.2', isCorrect: false, answer: ['foo'] }, { slideRef: '1.A1.3', isCorrect: true, answer: ['foo'] }, { slideRef: '1.A1.4', isCorrect: false, answer: ['foo'] }, { slideRef: '1.A1.5', isCorrect: false, answer: ['foo'] }, { slideRef: '1.A1.2', isCorrect: true, answer: ['foo'] }], variables: {}, pendingSlides: ['1.A1.4', '1.A1.5'] }); }); //# sourceMappingURL=update-state.js.map