UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

706 lines (599 loc) • 21.9 kB
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {v4 as uuid} from 'uuid' import update from 'immutability-helper' import compact from 'lodash/compact' import EditController from '../editController' const firstBlankId = uuid() const secondBlankId = uuid() const changeItemStateStub = vi.fn() const props = { changeItemState: changeItemStateStub, interactionData: { stemItems: [ {id: uuid(), position: 1, type: 'text', value: 'Columbus sailed in '}, {id: uuid(), position: 2, type: 'blank', blankId: firstBlankId}, {id: uuid(), position: 3, type: 'text', value: ' and found '}, {id: uuid(), position: 4, type: 'blank', blankId: secondBlankId}, {id: uuid(), position: 5, type: 'text', value: '.'}, ], blanks: [ { id: firstBlankId, answerType: 'openEntry', }, { id: secondBlankId, answerType: 'dropdown', choices: [ {id: 'choice1', itemBody: 'America', position: 1}, {id: 'choice2', itemBody: 'Asia', position: 2}, {id: 'choice3', itemBody: 'Africa', position: 3}, ], }, ], }, scoringData: { value: [ { id: firstBlankId, scoringAlgorithm: 'TextRegex', scoringData: {value: '.*1492', blankText: '1492'}, }, { id: secondBlankId, scoringAlgorithm: 'Equivalence', scoringData: {blankText: 'America', value: 'choice1'}, }, ], }, } const selectedStemItem = { id: uuid(), type: 'text', value: 'stem item', position: 1, } describe('FITB Edit Controller', () => { let editController beforeEach(() => { editController = new EditController(props) }) afterEach(() => { changeItemStateStub.mockReset() }) describe('#sortedStemItems', () => { it('sorts the stem items by their position', () => { const props = { interactionData: { stemItems: [ {id: uuid(), position: 3}, {id: uuid(), position: 1}, {id: uuid(), position: 2}, ], }, } const sortedStemItems = new EditController(props).sortedStemItems() expect(sortedStemItems[0].position).toBe(1) expect(sortedStemItems[1].position).toBe(2) expect(sortedStemItems[2].position).toBe(3) }) }) describe('#textForStemItem', () => { const blankId = '123' const dangerousText = '<script> console.log("hey") </script>' const defaultProps = { scoringData: { value: [ { id: blankId, scoringData: { blankText: dangerousText, }, }, ], }, } const controller = new EditController(defaultProps) it('type not blank returns the stemItem value', () => { const stemItem = {type: 'text', value: dangerousText} expect(controller.textForStemItem(stemItem)).toEqual(dangerousText) }) it('type blank returns scoringData blankText', () => { const stemItem = {type: 'blank', blankId} expect(controller.textForStemItem(stemItem)).toEqual(dangerousText) }) }) describe('#blanks', () => { it('returns the blanks list', () => { const blanks = new EditController(props).blanks() expect(blanks).toHaveLength(props.interactionData.blanks.length) }) }) describe('#stemItems', () => { it('returns the stem items list', () => { const stemItems = new EditController(props).stemItems() expect(stemItems).toHaveLength(props.interactionData.stemItems.length) }) }) describe('#sortedBlanks', () => { it('sorts the blanks by their position in the stem', () => { const sortedBlanks = new EditController(props).sortedBlanks() expect(sortedBlanks[0].id).toBe(firstBlankId) expect(sortedBlanks[1].id).toBe(secondBlankId) }) }) describe('#onDestroyBlank', () => { beforeEach(() => { editController.changeItemState = vi.fn() }) it('ensures the first text stem item does not start with an empty space', () => { const newProps = update(props, { interactionData: { stemItems: { 0: {value: {$set: ' '}}, }, }, }) editController.props = newProps const item = newProps.interactionData.stemItems[1] const event = { preventDefault: () => {}, stopPropagation: () => {}, } editController.onDestroyBlank(item, event) const newItems = editController.changeItemState.mock.calls[0][0].interactionData.stemItems expect(newItems[0].value).toBe('1492 and found') }) it('ensures the blank ID is passed to changeItemState', () => { const item = props.interactionData.stemItems[1] const event = { preventDefault: () => {}, stopPropagation: () => {}, } editController.onDestroyBlank(item, event) const blankId = editController.changeItemState.mock.calls[0][1] expect(blankId).toBe(item.blankId) }) }) describe('#updateBlank', () => { it('removes the choices from a blank', () => { const blankId = secondBlankId const modificatons = {choices: null} editController.updateBlank(blankId, modificatons) const interactionData = changeItemStateStub.mock.calls[0][0].interactionData expect(interactionData.blanks[1].choices).toBeUndefined() }) }) describe('#onStemChange', () => { beforeEach(() => { editController.updateInteractionData = vi.fn() }) it('prevents empty text if there are multiple stem items', () => { const stemItem1 = { type: 'blank', id: 'id1', position: 1, } const stemItem2 = { type: 'text', id: 'id2', position: 2, value: ' ', } editController.props = { interactionData: { stemItems: [stemItem1, stemItem2], }, } editController.onStemChange(stemItem2, {target: {innerText: ''}}) expect(editController.updateInteractionData.mock.calls[0][0]).toEqual({ stemItems: [stemItem1, stemItem2], }) }) it('does not prevent empty text if there is only one stem item', () => { const stemItem = { type: 'text', id: 'id1', position: 1, value: ' ', } editController.props = { interactionData: { stemItems: [stemItem], }, } editController.onStemChange(stemItem, {target: {innerText: ''}}) expect(editController.updateInteractionData.mock.calls[0][0]).toEqual({ stemItems: [{type: 'text', id: 'id1', position: 1, value: ''}], }) }) }) describe('#onCreateBlank', () => { const selectionStub = { getRangeAt: i => ({ startOffset: 0, endOffset: 20, }), focusNode: { id: props.interactionData.stemItems[2].id, }, removeAllRanges: () => {}, toString: () => 'test text', } beforeEach(() => vi.spyOn(window, 'getSelection').mockReturnValue(selectionStub)) afterEach(() => vi.restoreAllMocks()) it('creates a new blank in the scoringData', () => { const newProps = update(props, { interactionData: { stemItems: { 2: {value: {$set: 'test text'}}, }, }, }) editController.props = newProps editController.onCreateBlank() const scoringData = changeItemStateStub.mock.calls[0][0].scoringData.value const newScoringDataBlank = scoringData.find( blank => blank.scoringData.blankText === 'test text', ) expect(newScoringDataBlank).toBeDefined() }) it('removes the old stem item from the stemItems array', () => { const newProps = update(props, { interactionData: { stemItems: { 2: {value: {$set: 'test text'}}, }, }, }) editController.props = newProps editController.onCreateBlank() const stemItems = changeItemStateStub.mock.calls[0][0].interactionData.stemItems const expectedUndefined = stemItems.find(si => si.id === 'stemitem-3') expect(expectedUndefined).toBeUndefined() }) it('adds the new blank to the stem items array in the correct position', () => { const newProps = update(props, { interactionData: { stemItems: { 2: {value: {$set: 'test text'}}, }, }, }) editController.props = newProps editController.onCreateBlank() const stemItems = changeItemStateStub.mock.calls[0][0].interactionData.stemItems const newBlank = stemItems.find(si => si.position === 4) expect(newBlank.type).toBe('blank') }) it('adds a space text stem item if the blank created had no text between an adjacent blank', () => { const newProps = update(props, { interactionData: { stemItems: { 2: {value: {$set: 'test text'}}, }, }, }) editController.props = newProps editController.onCreateBlank() const stemItems = changeItemStateStub.mock.calls[0][0].interactionData.stemItems const beforeNewBlank = stemItems.find(si => si.position === 3) const afterNewBlank = stemItems.find(si => si.position === 5) expect(beforeNewBlank.type).toBe('text') expect(beforeNewBlank.value).toBe(' ') expect(afterNewBlank.type).toBe('text') expect(afterNewBlank.value).toBe(' ') }) it('blank and scoringData order matches stem item order', () => { // creating blank in middle due to selectionStub above editController.onCreateBlank() const newBlanks = changeItemStateStub.mock.calls[0][0].interactionData.blanks const stemItems = changeItemStateStub.mock.calls[0][0].interactionData.stemItems const scoringDataVal = changeItemStateStub.mock.calls[0][0].scoringData.value const sortedBlankIds = compact(stemItems.map(si => si.blankId)) const blankIds = newBlanks.map(b => b.id) const scoringDataIds = scoringDataVal.map(scoringDatum => scoringDatum.id) expect(sortedBlankIds).toEqual(blankIds) expect(scoringDataIds).toEqual(blankIds) }) }) describe('#changeItemState', () => { describe('#shuffle properties', () => { it('changes a shuffle rule when blank answerType changes', () => { const newBlanks = props.interactionData.blanks.slice() newBlanks.splice(1, 1, { id: secondBlankId, answerType: 'openEntry', }) const modifications = { interactionData: { blanks: newBlanks, }, } editController.changeItemState(modifications) const blankRules = changeItemStateStub.mock.calls[0][0].properties.shuffleRules.blanks expect(blankRules.children).toEqual({ 0: {children: null}, 1: {children: null}, }) }) it('adds a shuffle rule when a blank is added', () => { const withNewBlank = props.interactionData.blanks.concat([ {id: 'someId', answerType: 'dropdown'}, ]) const modifications = { interactionData: { blanks: withNewBlank, }, } editController.changeItemState(modifications) const blankRules = changeItemStateStub.mock.calls[0][0].properties.shuffleRules.blanks expect(blankRules.children).toEqual({ 0: {children: null}, 1: {children: {choices: {shuffled: true}}}, 2: {children: {choices: {shuffled: true}}}, }) }) it('removes a shuffle rule ', () => { const withSingleBlank = props.interactionData.blanks.slice(0, 1) const modifications = { interactionData: { blanks: withSingleBlank, }, } editController.props.properties = { shuffleRules: { blanks: { children: { 0: {children: null}, 1: {children: null}, }, }, }, } editController.changeItemState(modifications) const blankRules = changeItemStateStub.mock.calls[0][0].properties.shuffleRules.blanks expect(blankRules.children).toEqual({0: {children: null}}) }) }) }) describe('#__getUnselectedStartStemFromSelection', () => { const selectedString = 'my selected stem item string' it('returns a text stemItem if there are unselected chars before the selection', () => { const range = {startOffset: 3} const newStemItem = editController.__getUnselectedStartStemFromSelection( selectedString, range, selectedStemItem, ) expect(newStemItem.type).toBe('text') expect(newStemItem.value).toBe('my ') }) it(`returns a text stemItem containig the unselected chars before the selection if there is a partial word selection`, () => { const range = {startOffset: 8} const newStemItem = editController.__getUnselectedStartStemFromSelection( selectedString, range, selectedStemItem, ) expect(newStemItem.type).toBe('text') expect(newStemItem.value).toBe('my selec') }) it(`returns a text stemItem + a blank space if the selection first char is a blank space and there are unselected chars before the selection`, () => { const range = {startOffset: 2} const newStemItem = editController.__getUnselectedStartStemFromSelection( selectedString, range, selectedStemItem, ) expect(newStemItem.type).toBe('text') expect(newStemItem.value).toBe('my ') }) it(`returns a text stemItem with value=" " if the selection first char is a blank space and there aren't unselected chars before the selection`, () => { const range = {startOffset: 0} const selectedStemItemAux = Object.assign({}, selectedStemItem, { value: ' my stem item starting with a blank space', }) const newStemItem = editController.__getUnselectedStartStemFromSelection( selectedStemItemAux.value, range, selectedStemItemAux, ) expect(newStemItem.type).toBe('text') expect(newStemItem.value).toBe(' ') }) it('returns null if there are not unselected chars before the selection', () => { const range = {startOffset: 0} const newStemItem = editController.__getUnselectedStartStemFromSelection( selectedString, range, selectedStemItem, ) expect(newStemItem).toBeUndefined() }) }) describe('#__getUnselectedEndStemFromSelection', () => { it(`returns text stemItem containing the unselected chars if there are unselected chars after the selection`, () => { const range = {startOffset: 0, toString: () => 'stem'} const endStemItem = editController.__getUnselectedEndStemFromSelection( selectedStemItem.value, range, selectedStemItem, ) expect(endStemItem.value).toBe(' item') }) it(`returns a text stemItem containig the unselected chars after the selection if there is a partial word selection`, () => { const range = {startOffset: 0, toString: () => 'stem i'} const newStemItem = editController.__getUnselectedEndStemFromSelection( selectedStemItem.value, range, selectedStemItem, ) expect(newStemItem.type).toBe('text') expect(newStemItem.value).toBe('tem') }) it(`returns a text stemItem containing a blank space + unselected chars if there are unselected chars after the selection and the selection last char is a blank space`, () => { const range = {startOffset: 0, toString: () => 'stem '} const endStemItem = editController.__getUnselectedEndStemFromSelection( selectedStemItem.value, range, selectedStemItem, ) expect(endStemItem.value).toBe(' item') }) it(`returns a text stem with value=' ' if there are not unselected chars after the selection and the selection ends with a blank space`, () => { const range = {startOffset: 0, toString: () => 'ends with a '} const selectedStemItemValue = 'ends with a ' const endStemItem = editController.__getUnselectedEndStemFromSelection( selectedStemItemValue, range, selectedStemItem, ) expect(endStemItem.value).toBe(' ') }) it(`returns undefined if there are not unselected chars after the selection and the selection doesn't end with a blank space`, () => { const range = {startOffset: 0, toString: () => 'ends with a'} const selectedStemItemValue = 'ends with a' const endStemItem = editController.__getUnselectedEndStemFromSelection( selectedStemItemValue, range, selectedStemItem, ) expect(endStemItem).toBeUndefined() }) }) describe('#__updateStemItems', () => { it('adds a text stem items if the updated items would otherwise begin with a blank', () => { const blankId = uuid() const selection = { getRangeAt: () => { return { startOffset: 0, endOffset: 20, } }, focusNode: { id: props.interactionData.stemItems[0].id, }, } const updatedStemItems = editController.__updateStemItems(blankId, selection) expect(updatedStemItems[0].type).toBe('text') expect(updatedStemItems[0].value).toBe(' ') expect(updatedStemItems[1].type).toBe('blank') }) it('adds a text stem item on the stemItems list if there are unselected chars before the selection', () => { const targetStemItem = props.interactionData.stemItems[2] const blankId = uuid() const selection = { getRangeAt: () => { return { startOffset: 3, endOffset: targetStemItem.value.length, } }, focusNode: { id: targetStemItem.id, }, } const updatedStemItems = editController.__updateStemItems(blankId, selection) // the new selection item and the blank space stemItem created because the selection ends with a blank space expect(updatedStemItems).toHaveLength(props.interactionData.stemItems.length + 2) }) it('adds a text stem item on the stemItems list if there are unselected chars after the selection', () => { const blankId = uuid() const selection = { getRangeAt: () => { return { startOffset: 0, endOffset: 3, } }, focusNode: { id: props.interactionData.stemItems[0].id, }, } const updatedStemItems = editController.__updateStemItems(blankId, selection) expect(updatedStemItems).toHaveLength(props.interactionData.stemItems.length + 2) }) it(`adds two text stem items on the stemItems list if there are unselected chars before and after the selection`, () => { const targetStemItem = props.interactionData.stemItems[0] const blankId = uuid() const selection = { getRangeAt: () => { return { startOffset: 2, endOffset: 4, } }, focusNode: { id: targetStemItem.id, }, } const updatedStemItems = editController.__updateStemItems(blankId, selection) expect(updatedStemItems).toHaveLength(props.interactionData.stemItems.length + 2) }) it(`returns a type='blank' stem item replacing the target text item if there are neither unselected chars after nor before the selection`, () => { const targetStemItem = props.interactionData.stemItems[4] const blankId = uuid() const selection = { getRangeAt: () => { return { startOffset: 0, endOffset: targetStemItem.value.length, } }, focusNode: { id: targetStemItem.id, }, } const updatedStemItems = editController.__updateStemItems(blankId, selection) expect(updatedStemItems).toHaveLength(props.interactionData.stemItems.length + 1) expect(updatedStemItems[4].type).toBe('blank') expect(updatedStemItems[5].type).toBe('text') expect(updatedStemItems[5].value).toBe('') }) }) describe('#__mergeStemItems', () => { it('merges left and right stem items with the middle stem item when the middle stem item is removed', () => { const removedStemItem = props.interactionData.stemItems[1] const stemItems = props.interactionData.stemItems const stemItemA = props.interactionData.stemItems[0] const stemItemB = props.interactionData.stemItems[2] const updatedStemItems = editController.__mergeStemItems( removedStemItem, stemItems, stemItemA, stemItemB, ) expect(updatedStemItems).toHaveLength(props.interactionData.stemItems.length - 2) expect(updatedStemItems[2].value).toBe('Columbus sailed in 1492 and found') }) it('merges the current stem item with the previous stem item when the last blank is removed', () => { const stemItems = [ {id: uuid(), position: 1, type: 'text', value: 'Columbus sailed in '}, {id: uuid(), position: 2, type: 'blank', blankId: firstBlankId}, ] const removedStemItem = stemItems[1] const stemItemA = stemItems[0] const stemItemB = null const updatedStemItems = editController.__mergeStemItems( removedStemItem, stemItems, stemItemA, stemItemB, ) expect(updatedStemItems).toHaveLength(stemItems.length - 1) expect(updatedStemItems[0].value).toBe('Columbus sailed in 1492') }) }) })