@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
706 lines (599 loc) • 21.9 kB
JavaScript
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')
})
})
})