@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
446 lines (352 loc) • 15.7 kB
JavaScript
import {vi} from 'vitest'
import React from 'react'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import runAxeCheck from '@instructure/ui-axe-check'
import HotSpotTake from '../index'
const handleResponseUpdateStub = vi.fn()
const props = {
itemBody: 'item body',
interactionData: {
imageUrl: 'https://i.ytimg.com/vi/LgkAebhJ7iE/maxresdefault.jpg',
hotspotsCount: 1,
},
userResponse: {
value: [],
},
handleResponseUpdate: handleResponseUpdateStub,
multipleHotSpotEnabled: true,
}
// Helper to get class component instance via ref trick.
// HotSpotTake is a class component, so we can capture the instance.
function renderWithRef(overrideProps = {}) {
let instanceRef = null
const WrappedComponent = class extends HotSpotTake {
constructor(p) {
super(p)
instanceRef = this
}
}
// Copy static properties
WrappedComponent.propTypes = HotSpotTake.propTypes
WrappedComponent.defaultProps = HotSpotTake.defaultProps
const result = render(<WrappedComponent {...props} {...overrideProps} />)
return {result, getInstance: () => instanceRef}
}
describe('HotSpot Take', () => {
afterEach(() => {
handleResponseUpdateStub.mockClear()
})
describe('rendering', () => {
it('renders ItemBodyWrapper and TargetContainer', () => {
const {container} = render(<HotSpotTake {...props} />)
// ItemBodyWrapper renders the itemBody text
expect(screen.getByText('item body')).toBeInTheDocument()
// TargetContainer renders an image
const image = container.querySelector('img')
expect(image).toBeInTheDocument()
})
})
describe('Initial State', () => {
it('initializes selectedCoordinates from userResponse.value if it is already set and multipleHotSpotEnabled is true', () => {
const {getInstance} = renderWithRef({
userResponse: {
value: [{x: 0.3, y: 0.3}],
},
interactionData: {
...props.interactionData,
hotspotsCount: 2,
},
multipleHotSpotEnabled: true,
})
const instance = getInstance()
expect(instance.state.selectedCoordinates).toEqual([{x: 0.3, y: 0.3}])
})
it('initializes selectedCoordinates from userResponse.value if it is already set and multipleHotSpotEnabled is false', () => {
const {getInstance} = renderWithRef({
userResponse: {
value: {x: 0.3, y: 0.3},
},
interactionData: {
...props.interactionData,
hotspotsCount: 2,
},
multipleHotSpotEnabled: false,
})
const instance = getInstance()
expect(instance.state.selectedCoordinates).toEqual([{x: 0.3, y: 0.3}])
})
it('initializes selectedCoordinates as empty when userResponse.value is empty', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
expect(instance.state.selectedCoordinates).toEqual([])
})
})
describe('Helpers', () => {
it('#handleSetCoordinates - adds coordinates to selectedCoordinates and calls handleResponseUpdate', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const newState = {imageWidth: 700, imageHeight: 500}
const newCoordinates = [{x: 10, y: 20}]
instance.targetContainer.state = newState
instance.handleSetCoordinates(newCoordinates)
const args = handleResponseUpdateStub.mock.calls[0][0]
expect(args[0].x).toBe(newCoordinates[0].x / newState.imageWidth)
expect(args[0].y).toBe(newCoordinates[0].y / newState.imageHeight)
expect(instance.state.selectedCoordinates.length).toBe(1)
})
it('#handleSetCoordinates - does not add more than allowed hotspotsCount', () => {
const {getInstance} = renderWithRef({
interactionData: {
imageUrl: 'https://i.ytimg.com/vi/LgkAebhJ7iE/maxresdefault.jpg',
hotspotsCount: 1,
},
})
const instance = getInstance()
const newState = {imageWidth: 700, imageHeight: 500}
instance.targetContainer.state = newState
// Add first coordinate
instance.handleSetCoordinates([{x: 10, y: 20}])
expect(instance.state.selectedCoordinates.length).toBe(1)
// Try adding a second coordinate
instance.handleSetCoordinates([{x: 30, y: 40}])
expect(instance.state.selectedCoordinates.length).toBe(1) // Should still be 1, as limit is reached
})
it('#handleSetCoordinates - ensures response is an object if multipleHotSpotEnabled is false', () => {
const {getInstance} = renderWithRef({
...props,
multipleHotSpotEnabled: false,
interactionData: {...props.interactionData, hotspotsCount: 1},
})
const instance = getInstance()
const newState = {imageWidth: 700, imageHeight: 500}
const newCoordinates = [{x: 10, y: 20}]
instance.targetContainer.state = newState
instance.handleSetCoordinates(newCoordinates)
const args = handleResponseUpdateStub.mock.calls[0][0]
expect(args).toEqual({
x: newCoordinates[0].x / newState.imageWidth,
y: newCoordinates[0].y / newState.imageHeight,
})
})
it('#handleResetSelections - clears selectedCoordinates and calls handleResponseUpdate', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
instance.setState({selectedCoordinates: [{x: 0.1, y: 0.2}]})
instance.handleResetSelections()
expect(instance.state.selectedCoordinates.length).toBe(0)
expect(handleResponseUpdateStub).toHaveBeenCalledWith([])
})
it('#isAnyInputFocused - returns false when no input is focused', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const result = instance.isAnyInputFocused()
expect(result).toBe(false)
})
it('#isAnyInputFocused - returns true when an input is focused', () => {
const {getInstance} = renderWithRef()
// Mock input element
const input = document.createElement('input')
input.setAttribute('data-test-id', 'test-input')
document.body.appendChild(input)
input.focus()
const instance = getInstance()
const result = instance.isAnyInputFocused()
expect(result).toBe(true)
input.remove()
})
it('#saveSelectedPoint - sets isSelectingPoint to false and saves point to selectedCoordinates', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
instance.setState({keyboardCoordinates: {x: 0.5, y: 0.5}, isSelectingPoint: true})
instance.saveSelectedPoint()
expect(instance.state.selectedCoordinates).toEqual([{x: 0.5, y: 0.5}])
expect(instance.state.keyboardCoordinates).toBeNull()
expect(instance.state.isSelectingPoint).toBe(false)
})
it('#saveSelectedPoint - ensures response is an object if multipleHotSpotEnabled is false', () => {
const {getInstance} = renderWithRef({
...props,
multipleHotSpotEnabled: false,
interactionData: {...props.interactionData, hotspotsCount: 1},
})
const instance = getInstance()
instance.setState({keyboardCoordinates: {x: 0.5, y: 0.5}, isSelectingPoint: true})
instance.saveSelectedPoint()
const args = handleResponseUpdateStub.mock.calls[0][0]
expect(args).toEqual({x: 0.5, y: 0.5})
})
it('#canAddPoint - prevents adding a point when limit is reached', () => {
const {getInstance} = renderWithRef({
interactionData: {
imageUrl: 'https://i.ytimg.com/vi/LgkAebhJ7iE/maxresdefault.jpg',
hotspotsCount: 1,
},
})
const instance = getInstance()
instance.setState({selectedCoordinates: [{x: 0.1, y: 0.1}]})
const result = instance.canAddPoint()
expect(result).toBe(false)
})
it('#startSelectingPoint - updates position, sets isSelectingPoint to true, and calls handleResponseUpdate', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
instance.startSelectingPoint()
expect(instance.state.keyboardCoordinates).toEqual({x: 0.5, y: 0.5})
expect(instance.state.isSelectingPoint).toBe(true)
})
it('#movePosition - updates position and calls handleResponseUpdate', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
instance.setState({keyboardCoordinates: {x: 0.5, y: 0.5}})
instance.movePosition('ArrowUp', 0.1)
expect(instance.state.keyboardCoordinates).toEqual({x: 0.5, y: 0.4})
})
it('#movePosition - ensures position does not exceed bounds', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
instance.setState({keyboardCoordinates: {x: 1, y: 1}})
instance.movePosition('ArrowDown', 0.1)
expect(instance.state.keyboardCoordinates).toEqual({x: 1, y: 1})
})
})
describe('Key Handling', () => {
describe('#isArrowKey', () => {
it('returns true for valid arrow keys', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const validKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
validKeys.forEach(key => {
expect(instance.isArrowKey(key)).toBe(true)
})
})
it('returns false for invalid keys', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const invalidKeys = ['Enter', 's', 'a', '', null]
invalidKeys.forEach(key => {
expect(instance.isArrowKey(key)).toBe(false)
})
})
})
describe('#handleKeyDown', () => {
it('does nothing when an input field is focused', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
vi.spyOn(instance, 'isAnyInputFocused').mockReturnValue(true)
instance.setState({focusedContainer: 'targetContainer'})
const startSelectingPointSpy = vi.spyOn(instance, 'startSelectingPoint')
const event = new KeyboardEvent('keydown', {key: 's'})
instance.handleKeyDown(event)
expect(startSelectingPointSpy).not.toHaveBeenCalled()
})
it('calls startSelectingPoint when "s" key is pressed', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const startSelectingPointSpy = vi.spyOn(instance, 'startSelectingPoint')
instance.setState({focusedContainer: 'targetContainer'})
const event = new KeyboardEvent('keydown', {key: 's'})
instance.handleKeyDown(event)
expect(startSelectingPointSpy).toHaveBeenCalledOnce()
})
it('calls saveSelectedPoint when "d" key is pressed and a point is being selected', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const saveSelectedPointSpy = vi.spyOn(instance, 'saveSelectedPoint')
instance.setState({
isSelectingPoint: true,
keyboardCoordinates: {x: 0.5, y: 0.5},
focusedContainer: 'targetContainer',
})
const event = new KeyboardEvent('keydown', {key: 'd'})
instance.handleKeyDown(event)
expect(saveSelectedPointSpy).toHaveBeenCalledOnce()
})
it('calls handleResetSelections when "Backspace" or "Delete" key is pressed', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const handleResetSelectionsSpy = vi.spyOn(instance, 'handleResetSelections')
instance.setState({focusedContainer: 'targetContainer'})
const backspaceEvent = new KeyboardEvent('keydown', {key: 'Backspace'})
instance.handleKeyDown(backspaceEvent)
expect(handleResetSelectionsSpy).toHaveBeenCalledOnce()
const deleteEvent = new KeyboardEvent('keydown', {key: 'Delete'})
instance.handleKeyDown(deleteEvent)
expect(handleResetSelectionsSpy).toHaveBeenCalledTimes(2)
})
it('calls movePosition when arrow keys are pressed', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const movePositionSpy = vi.spyOn(instance, 'movePosition')
instance.setState({
keyboardCoordinates: {x: 0.5, y: 0.5},
focusedContainer: 'targetContainer',
})
const arrowUpEvent = new KeyboardEvent('keydown', {key: 'ArrowUp'})
instance.handleKeyDown(arrowUpEvent)
expect(movePositionSpy).toHaveBeenCalledWith('ArrowUp', expect.any(Number))
})
it('does nothing when arrow keys are pressed without keyboardCoordinates', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
const movePositionSpy = vi.spyOn(instance, 'movePosition')
instance.setState({keyboardCoordinates: null})
const event = new KeyboardEvent('keydown', {key: 'ArrowUp'})
instance.handleKeyDown(event)
expect(movePositionSpy).not.toHaveBeenCalled()
})
it('does not call any functions when focusedContainer is null', () => {
const {getInstance} = renderWithRef()
const instance = getInstance()
instance.setState({focusedContainer: null})
const startSelectingPointSpy = vi.spyOn(instance, 'startSelectingPoint')
const saveSelectedPointSpy = vi.spyOn(instance, 'saveSelectedPoint')
const handleResetSelectionsSpy = vi.spyOn(instance, 'handleResetSelections')
const movePositionSpy = vi.spyOn(instance, 'movePosition')
const eventS = new KeyboardEvent('keydown', {key: 's'})
instance.handleKeyDown(eventS)
const eventD = new KeyboardEvent('keydown', {key: 'd'})
instance.handleKeyDown(eventD)
const backspaceEvent = new KeyboardEvent('keydown', {key: 'Backspace'})
instance.handleKeyDown(backspaceEvent)
const arrowUpEvent = new KeyboardEvent('keydown', {key: 'ArrowUp'})
instance.handleKeyDown(arrowUpEvent)
expect(startSelectingPointSpy).not.toHaveBeenCalled()
expect(saveSelectedPointSpy).not.toHaveBeenCalled()
expect(handleResetSelectionsSpy).not.toHaveBeenCalled()
expect(movePositionSpy).not.toHaveBeenCalled()
})
})
})
describe('keyboard shortcuts', () => {
it('should render in a modal when clicking the keyboard shortcuts button', async () => {
render(<HotSpotTake {...props} />)
const showKeyboardShortcutsButton = screen.getByRole('button', {
name: /show keyboard shortcuts/i,
})
userEvent.click(showKeyboardShortcutsButton)
expect(screen.queryByRole('dialog', {name: /keyboard shortcuts/i})).not.toBeNull()
expect(screen.queryByRole('row', {name: /select a point/i})).not.toBeNull()
expect(screen.queryByRole('row', {name: /save the position/i})).not.toBeNull()
expect(screen.queryByRole('row', {name: /clear all selections/i})).not.toBeNull()
expect(screen.queryByRole('row', {name: /move the point/i})).not.toBeNull()
userEvent.click(screen.getAllByRole('button', {name: /close/i})[0])
await waitFor(
() => {
expect(screen.queryByRole('dialog', {name: /keyboard shortcuts/i})).toBeNull()
},
{timeout: 5000},
)
})
})
describe('a11y tests', () => {
it('should meet a11y standards', async () => {
const {container} = render(
<main>
<HotSpotTake {...props} />
</main>,
)
expect(await runAxeCheck(container)).toBe(true)
})
})
})