From 249d6e9ae2da47a008ea0b5d73f34b64fd451795 Mon Sep 17 00:00:00 2001 From: Andrei Miron Date: Tue, 14 Jul 2026 11:19:51 +0300 Subject: [PATCH 1/7] feat(hotspot): enhance focus handling and default hover outline color PIE-637 PIE-640 --- packages/hotspot/configure/src/root.jsx | 16 ++--- packages/hotspot/controller/src/index.js | 50 ++++++++------- packages/hotspot/docs/demo/generate.js | 1 + packages/hotspot/src/hotspot/circle.jsx | 5 +- packages/hotspot/src/hotspot/container.jsx | 75 ++++++++++++++++++++++ packages/hotspot/src/hotspot/polygon.jsx | 5 +- packages/hotspot/src/hotspot/rectangle.jsx | 7 +- 7 files changed, 121 insertions(+), 38 deletions(-) diff --git a/packages/hotspot/configure/src/root.jsx b/packages/hotspot/configure/src/root.jsx index eb227d406c..c3484e342a 100644 --- a/packages/hotspot/configure/src/root.jsx +++ b/packages/hotspot/configure/src/root.jsx @@ -12,6 +12,8 @@ import { updateImageDimensions, generateValidationMessage, getUpdatedShapes, get const { Panel, toggle, dropdown } = settings; +const checkNullish = (value) => value !== null && value !== undefined; + const DimensionsContainer = styled('div')(({ theme }) => ({ display: 'flex', marginBottom: theme.spacing(1.5), @@ -164,6 +166,7 @@ export class Root extends React.Component { ...props, }); + return ( - - Define Hotspot - - + Define Hotspot + @@ -255,7 +251,7 @@ export class Root extends React.Component { hotspotColor={model.hotspotColor} outlineColor={model.outlineColor} selectedHotspotColor={model.selectedHotspotColor} - hoverOutlineColor={model.hoverOutlineColor} + hoverOutlineColor={checkNullish(model.hoverOutlineColor) ? model.hoverOutlineColor : 'black'} onUpdateImageDimension={onUpdateImageDimension} onUpdateShapes={onUpdateShapes} onImageUpload={onImageUpload} diff --git a/packages/hotspot/controller/src/index.js b/packages/hotspot/controller/src/index.js index 9380b63879..ef1cda9373 100644 --- a/packages/hotspot/controller/src/index.js +++ b/packages/hotspot/controller/src/index.js @@ -8,6 +8,8 @@ import defaults from './defaults'; const log = debug('pie-elements:hotspot:controller'); +const checkNullish = (value) => value !== null && value !== undefined; + export const normalize = (question) => ({ ...defaults, ...question, @@ -44,7 +46,7 @@ export function model(question, session, env) { imageUrl, outlineColor, hotspotColor, - hoverOutlineColor, + hoverOutlineColor: checkNullish(hoverOutlineColor) ? hoverOutlineColor : 'black', selectedHotspotColor, multipleCorrect, partialScoring, @@ -150,25 +152,25 @@ export const getLogTrace = (model, session, env) => { const traceLog = []; const { answers } = session || {}; const { shapes } = model || {}; - + const allShapes = []; if (shapes) { if (shapes.rectangles) allShapes.push(...shapes.rectangles); if (shapes.polygons) allShapes.push(...shapes.polygons); if (shapes.circles) allShapes.push(...shapes.circles); } - + const correctShapes = allShapes.filter(shape => shape.correct); const totalShapes = allShapes.length; - + traceLog.push(`Total of ${totalShapes} hotspot(s) defined, ${correctShapes.length} correct.`); - + if (answers && answers.length > 0) { traceLog.push(`Student selected ${answers.length} hotspot(s).`); - + let correctSelections = 0; let incorrectSelections = 0; - + answers.forEach(answer => { const shape = allShapes.find(s => s.id === answer.id); if (shape && shape.correct) { @@ -177,11 +179,11 @@ export const getLogTrace = (model, session, env) => { incorrectSelections++; } }); - - const missedCorrect = correctShapes.filter(correctShape => + + const missedCorrect = correctShapes.filter(correctShape => !answers.some(answer => answer.id === correctShape.id) ).length; - + if (correctSelections > 0) { traceLog.push(`${correctSelections} correct hotspot(s) selected.`); } @@ -196,14 +198,14 @@ export const getLogTrace = (model, session, env) => { } const partialScoringEnabled = partialScoring.enabled(model, env); - + if (partialScoringEnabled) { traceLog.push(`Score calculated using partial scoring.`); - + if (answers && answers.length > 0) { let correctSelections = 0; let incorrectSelections = 0; - + answers.forEach(answer => { const shape = allShapes.find(s => s.id === answer.id); if (shape && shape.correct) { @@ -212,10 +214,10 @@ export const getLogTrace = (model, session, env) => { incorrectSelections++; } }); - + const totalCorrectAvailable = correctShapes.length; traceLog.push(`Partial scoring calculation: ${correctSelections} correct selections out of ${totalCorrectAvailable} available.`); - + if (incorrectSelections > totalCorrectAvailable) { const extraSelections = incorrectSelections - (totalCorrectAvailable - correctSelections); traceLog.push(`${extraSelections} extra incorrect selection(s) beyond required amount are deducted from score.`); @@ -236,12 +238,12 @@ export function outcome(config, session, env = {}) { log('outcome...'); if (!session || isEmpty(session)) { - resolve({ - score: 0, - empty: true, - traceLog: ['No hotspots selected. Score: 0.'] + resolve({ + score: 0, + empty: true, + traceLog: ['No hotspots selected. Score: 0.'] }); - } + } if (session.answers) { const traceLog = getLogTrace(config, session, env); @@ -249,10 +251,10 @@ export function outcome(config, session, env = {}) { resolve({ score, empty: false, traceLog }); } else { - resolve({ - score: 0, - empty: true, - traceLog: ['No hotspots selected. Score: 0.'] + resolve({ + score: 0, + empty: true, + traceLog: ['No hotspots selected. Score: 0.'] }); } }); diff --git a/packages/hotspot/docs/demo/generate.js b/packages/hotspot/docs/demo/generate.js index a3d993a030..09d9e803f5 100644 --- a/packages/hotspot/docs/demo/generate.js +++ b/packages/hotspot/docs/demo/generate.js @@ -13,6 +13,7 @@ exports.model = (id, element) => ({ 'rgba(254, 241, 96, 0.25)', 'rgba(0, 0, 0, 0.1)', ], + // hoverOutlineColor: 'black', outlineColor: 'blue', outlineList: ['blue', 'red', 'yellow'], multipleCorrect: true, diff --git a/packages/hotspot/src/hotspot/circle.jsx b/packages/hotspot/src/hotspot/circle.jsx index 1856c9f8ae..d11928219c 100644 --- a/packages/hotspot/src/hotspot/circle.jsx +++ b/packages/hotspot/src/hotspot/circle.jsx @@ -50,6 +50,7 @@ class CircleComponent extends React.Component { hoverOutlineColor, outlineColor, selected, + focused, x, y, evaluateText, @@ -88,7 +89,7 @@ class CircleComponent extends React.Component { } } - const useHoveredStyle = hovered && hoverOutlineColor; + const useHoveredStyle = (hovered || focused) && hoverOutlineColor; return ( @@ -128,6 +129,7 @@ CircleComponent.propTypes = { isCorrect: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), isEvaluateMode: PropTypes.bool.isRequired, disabled: PropTypes.bool.isRequired, + focused: PropTypes.bool, hoverOutlineColor: PropTypes.string, onClick: PropTypes.func.isRequired, outlineColor: PropTypes.string.isRequired, @@ -145,6 +147,7 @@ CircleComponent.propTypes = { CircleComponent.defaultProps = { isCorrect: false, evaluateText: null, + focused: false, strokeWidth: 5, scale: 1, }; diff --git a/packages/hotspot/src/hotspot/container.jsx b/packages/hotspot/src/hotspot/container.jsx index cf61929da3..75722eca51 100644 --- a/packages/hotspot/src/hotspot/container.jsx +++ b/packages/hotspot/src/hotspot/container.jsx @@ -14,8 +14,19 @@ const BaseContainer = styled('div')(({ theme }) => ({ background: theme.palette.common.white, border: `${theme.spacing(1)} solid ${theme.palette.common.white}`, width: 'fit-content', + maxWidth: '100%', + overflowX: 'auto', })); +const HiddenFocusable = styled('span')({ + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', +}); + const ImageContainer = styled('div')({ position: 'relative', width: 'fit-content', @@ -34,6 +45,13 @@ const StyledStage = styled(Stage)({ }); export class Container extends React.Component { + constructor(props) { + super(props); + this.state = { + focusedShapeId: null, + }; + } + isSelected(shape) { const selectedShape = this.props.session.answers.filter((answer) => answer.id === shape.id)[0]; return !!selectedShape; @@ -57,6 +75,44 @@ export class Container extends React.Component { return null; }; + getAllShapesSorted = () => { + const { shapes: { rectangles = [], polygons = [], circles = [] } } = this.props; + const allShapes = [ + ...rectangles.map((s) => ({ ...s, type: 'rectangle' })), + ...polygons.map((s) => ({ ...s, type: 'polygon' })), + ...circles.map((s) => ({ ...s, type: 'circle' })), + ]; + allShapes.sort((a, b) => String(a.id).localeCompare(String(b.id), undefined, { numeric: true })); + + return allShapes; + }; + + handleShapeFocus = (shapeId) => { + this.setState({ focusedShapeId: shapeId }); + }; + + handleShapeBlur = () => { + this.setState({ focusedShapeId: null }); + }; + + handleShapeKeyDown = (e, shapeId) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + + const { onSelectChoice, disabled } = this.props; + + if (!disabled) { + const shape = this.getAllShapesSorted().find((s) => s.id === shapeId); + + if (shape) { + const selected = this.isSelected(shape); + + onSelectChoice({ id: shapeId, selected: !selected, selector: 'Keyboard' }); + } + } + } + }; + render() { const { dimensions: { width: withProp, height: heightProp }, @@ -74,9 +130,13 @@ export class Container extends React.Component { showCorrect, } = this.props; + const { focusedShapeId } = this.state; + const width = withProp * SCALE; const height = heightProp * SCALE; + const sortedShapes = this.getAllShapesSorted(); + return ( {imageUrl ? ( @@ -125,6 +185,7 @@ export class Container extends React.Component { strokeWidth={strokeWidth} markAsCorrect={markAsCorrect} showCorrectEnabled={showCorrect} + focused={focusedShapeId === shape.id} /> ); })} @@ -153,6 +214,7 @@ export class Container extends React.Component { selectedHotspotColor={selectedHotspotColor} hoverOutlineColor={hoverOutlineColor} showCorrectEnabled={showCorrect} + focused={focusedShapeId === polygon.id} /> ); })} @@ -183,11 +245,24 @@ export class Container extends React.Component { selectedHotspotColor={selectedHotspotColor} hoverOutlineColor={hoverOutlineColor} showCorrectEnabled={showCorrect} + focused={focusedShapeId === shape.id} /> ); })} + + {sortedShapes.map((shape) => ( + this.handleShapeFocus(shape.id)} + onBlur={this.handleShapeBlur} + onKeyDown={(e) => this.handleShapeKeyDown(e, shape.id)} + /> + ))} ); } diff --git a/packages/hotspot/src/hotspot/polygon.jsx b/packages/hotspot/src/hotspot/polygon.jsx index 89168583d4..27df355886 100644 --- a/packages/hotspot/src/hotspot/polygon.jsx +++ b/packages/hotspot/src/hotspot/polygon.jsx @@ -68,6 +68,7 @@ class PolygonComponent extends React.Component { hoverOutlineColor, outlineColor, selected, + focused, points, evaluateText, strokeWidth, @@ -120,7 +121,7 @@ class PolygonComponent extends React.Component { iconSrc = faWrong; } } - const useHoveredStyle = hovered && hoverOutlineColor; + const useHoveredStyle = (hovered || focused) && hoverOutlineColor; const xValues = pointsParsed.filter((_, index) => index % 2 === 0); // Even indices are x-coordinates const yValues = pointsParsed.filter((_, index) => index % 2 !== 0); // Odd indices are y-coordinates @@ -174,6 +175,7 @@ PolygonComponent.propTypes = { isEvaluateMode: PropTypes.bool.isRequired, hoverOutlineColor: PropTypes.string, disabled: PropTypes.bool.isRequired, + focused: PropTypes.bool, onClick: PropTypes.func.isRequired, outlineColor: PropTypes.string.isRequired, points: PropTypes.array.isRequired, @@ -188,6 +190,7 @@ PolygonComponent.propTypes = { PolygonComponent.defaultProps = { evaluateText: null, + focused: false, strokeWidth: 5, scale: 1, }; diff --git a/packages/hotspot/src/hotspot/rectangle.jsx b/packages/hotspot/src/hotspot/rectangle.jsx index 43dc26f58e..99108f4e6d 100644 --- a/packages/hotspot/src/hotspot/rectangle.jsx +++ b/packages/hotspot/src/hotspot/rectangle.jsx @@ -51,6 +51,7 @@ class RectComponent extends React.Component { isEvaluateMode, outlineColor, selected, + focused, width, x, y, @@ -60,6 +61,7 @@ class RectComponent extends React.Component { markAsCorrect, showCorrectEnabled, } = this.props; + const { hovered } = this.state; const outlineColorParsed = isEvaluateMode ? this.getEvaluateOutlineColor(isCorrect, markAsCorrect, outlineColor) @@ -102,8 +104,7 @@ class RectComponent extends React.Component { } } - const { hovered } = this.state; - const useHoveredStyle = hovered && hoverOutlineColor; + const useHoveredStyle = (hovered || focused) && hoverOutlineColor; return ( @@ -147,6 +148,7 @@ RectComponent.propTypes = { isEvaluateMode: PropTypes.bool.isRequired, hoverOutlineColor: PropTypes.string, disabled: PropTypes.bool.isRequired, + focused: PropTypes.bool, onClick: PropTypes.func.isRequired, outlineColor: PropTypes.string.isRequired, selected: PropTypes.bool.isRequired, @@ -164,6 +166,7 @@ RectComponent.propTypes = { RectComponent.defaultProps = { isCorrect: false, evaluateText: null, + focused: false, strokeWidth: 5, scale: 1, }; From 8791de36640adc85a8cf43d095a092bce6d7e7a1 Mon Sep 17 00:00:00 2001 From: Andrei Miron Date: Tue, 14 Jul 2026 13:09:24 +0300 Subject: [PATCH 2/7] feat(hotspot): add ariaLabel to shapes for improved accessibility PIE-639 --- packages/hotspot/docs/demo/generate.js | 35 ++++++++++++---------- packages/hotspot/src/hotspot/container.jsx | 27 ++++++++++------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/packages/hotspot/docs/demo/generate.js b/packages/hotspot/docs/demo/generate.js index 09d9e803f5..96daa27d54 100644 --- a/packages/hotspot/docs/demo/generate.js +++ b/packages/hotspot/docs/demo/generate.js @@ -26,7 +26,8 @@ exports.model = (id, element) => ({ x: 261, y: 44, index: 2, - correct: true + correct: true, + // ariaLabel: 'First rectangle', }, { id: '4', @@ -35,8 +36,8 @@ exports.model = (id, element) => ({ x: 270, y: 215.4886474609375, index: 3, - correct: false - } + correct: false, + }, ], polygons: [ { @@ -54,10 +55,11 @@ exports.model = (id, element) => ({ { x: 109, y: 294 }, { x: 106, y: 280 }, { x: 56, y: 193 }, - { x: 68, y: 139 } + { x: 68, y: 139 }, ], index: 1, - correct: false + correct: false, + // ariaLabel: 'First polygon', }, { id: '5', @@ -72,10 +74,10 @@ exports.model = (id, element) => ({ { x: 564, y: 465 }, { x: 575, y: 461 }, { x: 575, y: 438 }, - { x: 544, y: 367 } + { x: 544, y: 367 }, ], index: 4, - correct: false + correct: false, }, { id: '7', @@ -85,11 +87,11 @@ exports.model = (id, element) => ({ { x: 556, y: 287 }, { x: 575, y: 302 }, { x: 601, y: 280 }, - { x: 598, y: 246 } + { x: 598, y: 246 }, ], index: 6, - correct: true - } + correct: true, + }, ], circles: [ { @@ -98,7 +100,8 @@ exports.model = (id, element) => ({ x: 71, y: 420, index: 1, - correct: false + correct: false, + // ariaLabel: 'First circle', }, { id: '6', @@ -106,14 +109,14 @@ exports.model = (id, element) => ({ x: 479, y: 140, index: 5, - correct: false - } - ] + correct: false, + // ariaLabel: 'Second circle', + }, + ], }, rationale: null, teacherInstructions: null, toolbarEditorPosition: 'bottom', - prompt: - '
Which state(s) have names beginning in "North"?
', + prompt: '
Which state(s) have names beginning in "North"?
', rubricEnabled: false, }); diff --git a/packages/hotspot/src/hotspot/container.jsx b/packages/hotspot/src/hotspot/container.jsx index 75722eca51..01ed3178bf 100644 --- a/packages/hotspot/src/hotspot/container.jsx +++ b/packages/hotspot/src/hotspot/container.jsx @@ -252,17 +252,22 @@ export class Container extends React.Component { - {sortedShapes.map((shape) => ( - this.handleShapeFocus(shape.id)} - onBlur={this.handleShapeBlur} - onKeyDown={(e) => this.handleShapeKeyDown(e, shape.id)} - /> - ))} + {sortedShapes.map((shape) => { + const selected = this.isSelected(shape); + + return ( + this.handleShapeFocus(shape.id)} + onBlur={this.handleShapeBlur} + onKeyDown={(e) => this.handleShapeKeyDown(e, shape.id)} + /> + ); + })} ); } From b27c51f77eb41f6a3aee1e87c85ea7c8769b542f Mon Sep 17 00:00:00 2001 From: Andrei Miron Date: Fri, 17 Jul 2026 19:08:20 +0300 Subject: [PATCH 3/7] fix(hotspot): improve hover effect handling PIE-783 --- .../src/hotspot/__tests__/circle.test.jsx | 84 +++++++++---------- .../src/hotspot/__tests__/rectangle.test.jsx | 74 ++++++++-------- packages/hotspot/src/hotspot/circle.jsx | 25 +++--- packages/hotspot/src/hotspot/polygon.jsx | 31 ++++--- packages/hotspot/src/hotspot/rectangle.jsx | 26 +++--- 5 files changed, 118 insertions(+), 122 deletions(-) diff --git a/packages/hotspot/src/hotspot/__tests__/circle.test.jsx b/packages/hotspot/src/hotspot/__tests__/circle.test.jsx index 8ac04cba30..de0d08f719 100644 --- a/packages/hotspot/src/hotspot/__tests__/circle.test.jsx +++ b/packages/hotspot/src/hotspot/__tests__/circle.test.jsx @@ -70,7 +70,7 @@ describe('CircleComponent', () => { it('should render with correct position and radius', () => { const { getByTestId } = render(); const circle = getByTestId('circle'); - + expect(circle).toHaveAttribute('x', '50'); expect(circle).toHaveAttribute('y', '50'); expect(circle).toHaveAttribute('radius', '30'); @@ -79,21 +79,21 @@ describe('CircleComponent', () => { it('should render with hotspot color when not selected', () => { const { getByTestId } = render(); const circle = getByTestId('circle'); - + expect(circle).toHaveAttribute('fill', '#FF0000'); }); it('should render with selected color when selected', () => { const { getByTestId } = render(); const circle = getByTestId('circle'); - + expect(circle).toHaveAttribute('fill', '#00FF00'); }); it('should apply scale transform', () => { const { getByTestId } = render(); const group = getByTestId('group'); - + expect(group).toHaveAttribute('scaleX', '2'); expect(group).toHaveAttribute('scaleY', '2'); }); @@ -101,7 +101,7 @@ describe('CircleComponent', () => { it('should render with default scale of 1', () => { const { getByTestId } = render(); const group = getByTestId('group'); - + expect(group).toHaveAttribute('scaleX', '1'); expect(group).toHaveAttribute('scaleY', '1'); }); @@ -112,9 +112,9 @@ describe('CircleComponent', () => { const onClick = jest.fn(); const { getByTestId } = render(); const circle = getByTestId('circle'); - + fireEvent.click(circle); - + expect(onClick).toHaveBeenCalledWith({ id: 'circle1', selected: true, @@ -126,20 +126,20 @@ describe('CircleComponent', () => { const onClick = jest.fn(); const { getByTestId, rerender } = render(); const circle = getByTestId('circle'); - + fireEvent.click(circle); - + expect(onClick).toHaveBeenCalledWith({ id: 'circle1', selected: true, selector: 'Mouse', }); - + rerender(); - + const circleAfter = getByTestId('circle'); fireEvent.click(circleAfter); - + expect(onClick).toHaveBeenCalledWith({ id: 'circle1', selected: false, @@ -151,37 +151,37 @@ describe('CircleComponent', () => { const onClick = jest.fn(); const { getByTestId } = render(); const circle = getByTestId('circle'); - + fireEvent.click(circle); - + expect(onClick).not.toHaveBeenCalled(); }); it('should change cursor to pointer on mouse enter when not disabled', () => { const { getByTestId } = render(); const circle = getByTestId('circle'); - + fireEvent.mouseEnter(circle); - + expect(document.body.style.cursor).toBe('pointer'); }); it('should not change cursor when disabled', () => { const { getByTestId } = render(); const circle = getByTestId('circle'); - + fireEvent.mouseEnter(circle); - + expect(document.body.style.cursor).toBe('default'); }); it('should reset cursor to default on mouse leave', () => { const { getByTestId } = render(); const circle = getByTestId('circle'); - + fireEvent.mouseEnter(circle); fireEvent.mouseLeave(circle); - + expect(document.body.style.cursor).toBe('default'); }); }); @@ -190,9 +190,9 @@ describe('CircleComponent', () => { it('should show hover rect when hoverOutlineColor is provided', () => { const { container, getByTestId } = render(); const circle = getByTestId('circle'); - + fireEvent.mouseEnter(circle); - + const rects = container.querySelectorAll('[data-testid="rect"]'); expect(rects.length).toBeGreaterThan(0); }); @@ -208,9 +208,9 @@ describe('CircleComponent', () => { /> ); const circle = getByTestId('circle'); - + fireEvent.mouseEnter(circle); - + const rect = container.querySelector('[data-testid="rect"]'); if (rect) { // Rect should be positioned at (x - radius, y - radius) with width/height = radius * 2 @@ -226,12 +226,12 @@ describe('CircleComponent', () => { ); const circle = getByTestId('circle'); - + fireEvent.mouseEnter(circle); - + const rect = container.querySelector('[data-testid="rect"]'); if (rect) { - expect(rect).toHaveAttribute('stroke', 'transparent'); + expect(rect).toHaveAttribute('stroke', '#FFFF00'); } }); }); @@ -247,7 +247,7 @@ describe('CircleComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -262,7 +262,7 @@ describe('CircleComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -277,7 +277,7 @@ describe('CircleComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -292,7 +292,7 @@ describe('CircleComponent', () => { showCorrectEnabled={false} /> ); - + const icon = queryByTestId('icon-image'); expect(icon).not.toBeInTheDocument(); }); @@ -307,7 +307,7 @@ describe('CircleComponent', () => { showCorrectEnabled={true} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -322,7 +322,7 @@ describe('CircleComponent', () => { showCorrectEnabled={true} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -337,7 +337,7 @@ describe('CircleComponent', () => { showCorrectEnabled={true} /> ); - + const icon = queryByTestId('icon-image'); expect(icon).not.toBeInTheDocument(); }); @@ -352,7 +352,7 @@ describe('CircleComponent', () => { showCorrectEnabled={true} /> ); - + const icon = queryByTestId('icon-image'); expect(icon).not.toBeInTheDocument(); }); @@ -365,7 +365,7 @@ describe('CircleComponent', () => { markAsCorrect={true} /> ); - + const circle = getByTestId('circle'); expect(circle).toHaveAttribute('stroke', 'green'); }); @@ -379,7 +379,7 @@ describe('CircleComponent', () => { markAsCorrect={false} /> ); - + const circle = getByTestId('circle'); expect(circle).toHaveAttribute('stroke', 'red'); }); @@ -395,7 +395,7 @@ describe('CircleComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toHaveAttribute('data-tooltip', 'Great job!'); }); @@ -415,7 +415,7 @@ describe('CircleComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); // Icon should be at x - 10, y - 10 expect(icon).toHaveAttribute('data-x', '90'); @@ -431,7 +431,7 @@ describe('CircleComponent', () => { radius={5} /> ); - + const circle = getByTestId('circle'); expect(circle).toHaveAttribute('radius', '5'); }); @@ -443,7 +443,7 @@ describe('CircleComponent', () => { radius={200} /> ); - + const circle = getByTestId('circle'); expect(circle).toHaveAttribute('radius', '200'); }); @@ -455,7 +455,7 @@ describe('CircleComponent', () => { scale={0.5} /> ); - + const group = getByTestId('group'); expect(group).toHaveAttribute('scaleX', '0.5'); expect(group).toHaveAttribute('scaleY', '0.5'); diff --git a/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx b/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx index e865c9dbbe..183615fb3f 100644 --- a/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx +++ b/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx @@ -71,7 +71,7 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + expect(mainRect).toHaveAttribute('x', '10'); expect(mainRect).toHaveAttribute('y', '20'); expect(mainRect).toHaveAttribute('width', '100'); @@ -82,7 +82,7 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + expect(mainRect).toHaveAttribute('fill', '#FF0000'); }); @@ -90,14 +90,14 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + expect(mainRect).toHaveAttribute('fill', '#00FF00'); }); it('should apply scale transform', () => { const { getByTestId } = render(); const group = getByTestId('group'); - + expect(group).toHaveAttribute('scaleX', '1.5'); expect(group).toHaveAttribute('scaleY', '1.5'); }); @@ -105,7 +105,7 @@ describe('RectComponent', () => { it('should render with default scale of 1', () => { const { getByTestId } = render(); const group = getByTestId('group'); - + expect(group).toHaveAttribute('scaleX', '1'); expect(group).toHaveAttribute('scaleY', '1'); }); @@ -117,9 +117,9 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.click(mainRect); - + expect(onClick).toHaveBeenCalledWith({ id: 'rect1', selected: true, @@ -132,21 +132,21 @@ describe('RectComponent', () => { const { container, rerender } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.click(mainRect); - + expect(onClick).toHaveBeenCalledWith({ id: 'rect1', selected: true, selector: 'Mouse', }); - + rerender(); - + const rectsAfter = container.querySelectorAll('[data-testid="rect"]'); const mainRectAfter = rectsAfter[rectsAfter.length - 1]; fireEvent.click(mainRectAfter); - + expect(onClick).toHaveBeenCalledWith({ id: 'rect1', selected: false, @@ -159,9 +159,9 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.click(mainRect); - + expect(onClick).not.toHaveBeenCalled(); }); @@ -169,9 +169,9 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.mouseEnter(mainRect); - + expect(document.body.style.cursor).toBe('pointer'); }); @@ -179,9 +179,9 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.mouseEnter(mainRect); - + expect(document.body.style.cursor).toBe('default'); }); @@ -189,10 +189,10 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.mouseEnter(mainRect); fireEvent.mouseLeave(mainRect); - + expect(document.body.style.cursor).toBe('default'); }); }); @@ -202,9 +202,9 @@ describe('RectComponent', () => { const { container } = render(); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.mouseEnter(mainRect); - + const rectsAfterHover = container.querySelectorAll('[data-testid="rect"]'); expect(rectsAfterHover.length).toBeGreaterThan(1); }); @@ -215,12 +215,12 @@ describe('RectComponent', () => { ); const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; - + fireEvent.mouseEnter(mainRect); - + const hoverRect = container.querySelector('[stroke="#FFFF00"]'); if (hoverRect) { - expect(hoverRect).toHaveAttribute('stroke', 'transparent'); + expect(hoverRect).toHaveAttribute('stroke', '#FFFF00'); } }); }); @@ -236,7 +236,7 @@ describe('RectComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); expect(icon).toHaveAttribute('data-src'); @@ -252,7 +252,7 @@ describe('RectComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -267,7 +267,7 @@ describe('RectComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -282,7 +282,7 @@ describe('RectComponent', () => { showCorrectEnabled={false} /> ); - + const icon = queryByTestId('icon-image'); expect(icon).not.toBeInTheDocument(); }); @@ -297,7 +297,7 @@ describe('RectComponent', () => { showCorrectEnabled={true} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -312,7 +312,7 @@ describe('RectComponent', () => { showCorrectEnabled={true} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toBeInTheDocument(); }); @@ -327,7 +327,7 @@ describe('RectComponent', () => { showCorrectEnabled={true} /> ); - + const icon = queryByTestId('icon-image'); expect(icon).not.toBeInTheDocument(); }); @@ -342,7 +342,7 @@ describe('RectComponent', () => { showCorrectEnabled={true} /> ); - + const icon = queryByTestId('icon-image'); expect(icon).not.toBeInTheDocument(); }); @@ -355,7 +355,7 @@ describe('RectComponent', () => { markAsCorrect={true} /> ); - + const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; expect(mainRect).toHaveAttribute('stroke', 'green'); @@ -370,7 +370,7 @@ describe('RectComponent', () => { markAsCorrect={false} /> ); - + const rects = container.querySelectorAll('[data-testid="rect"]'); const mainRect = rects[rects.length - 1]; expect(mainRect).toHaveAttribute('stroke', 'red'); @@ -387,7 +387,7 @@ describe('RectComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); expect(icon).toHaveAttribute('data-tooltip', 'Correct answer!'); }); @@ -408,7 +408,7 @@ describe('RectComponent', () => { showCorrectEnabled={false} /> ); - + const icon = getByTestId('icon-image'); // Icon should be centered: x + width/2 - 10, y + height/2 - 10 expect(icon).toHaveAttribute('data-x', '50'); // 10 + 100/2 - 10 diff --git a/packages/hotspot/src/hotspot/circle.jsx b/packages/hotspot/src/hotspot/circle.jsx index d11928219c..82e0ca2d9b 100644 --- a/packages/hotspot/src/hotspot/circle.jsx +++ b/packages/hotspot/src/hotspot/circle.jsx @@ -92,17 +92,7 @@ class CircleComponent extends React.Component { const useHoveredStyle = (hovered || focused) && hoverOutlineColor; return ( - - {useHoveredStyle && ( - - )} + + {useHoveredStyle && ( + + )} {isEvaluateMode && iconSrc ? : null} ); diff --git a/packages/hotspot/src/hotspot/polygon.jsx b/packages/hotspot/src/hotspot/polygon.jsx index 27df355886..71027f273a 100644 --- a/packages/hotspot/src/hotspot/polygon.jsx +++ b/packages/hotspot/src/hotspot/polygon.jsx @@ -137,31 +137,30 @@ class PolygonComponent extends React.Component { const rectHeight = maxY - minY; return ( - - {useHoveredStyle && ( - - )} + + {useHoveredStyle && ( + + )} {isEvaluateMode && iconSrc ? : null} ); diff --git a/packages/hotspot/src/hotspot/rectangle.jsx b/packages/hotspot/src/hotspot/rectangle.jsx index 99108f4e6d..313dd31687 100644 --- a/packages/hotspot/src/hotspot/rectangle.jsx +++ b/packages/hotspot/src/hotspot/rectangle.jsx @@ -107,18 +107,7 @@ class RectComponent extends React.Component { const useHoveredStyle = (hovered || focused) && hoverOutlineColor; return ( - - {useHoveredStyle && ( - - )} + + {useHoveredStyle && ( + + )} {isEvaluateMode && iconSrc ? : null} ); From 6010172f083a9670b5c0f48375e690c483bdedaf Mon Sep 17 00:00:00 2001 From: arimieandreea Date: Tue, 28 Jul 2026 14:57:59 +0300 Subject: [PATCH 4/7] fix(multiple-choice): maxSelections should not affect single choices, repair tests in src PIE-432 --- .../controller/src/__tests__/index.test.js | 43 +++- .../multiple-choice/controller/src/index.js | 8 + ...e-input-test.jsx => choice-input.test.jsx} | 0 .../{index-test.jsx => index.test.jsx} | 58 +++-- ...ey-events-test.jsx => key-events.test.jsx} | 15 +- .../src/__tests__/multiple-choice-test.jsx | 97 -------- .../src/__tests__/multiple-choice.test.jsx | 226 ++++++++++++++++++ ...ater-test.jsx => session-updater.test.jsx} | 0 packages/multiple-choice/src/index.js | 6 +- .../multiple-choice/src/multiple-choice.jsx | 8 +- packages/multiple-choice/src/utils.js | 7 + 11 files changed, 342 insertions(+), 126 deletions(-) rename packages/multiple-choice/src/__tests__/{choice-input-test.jsx => choice-input.test.jsx} (100%) rename packages/multiple-choice/src/__tests__/{index-test.jsx => index.test.jsx} (81%) rename packages/multiple-choice/src/__tests__/{key-events-test.jsx => key-events.test.jsx} (86%) delete mode 100644 packages/multiple-choice/src/__tests__/multiple-choice-test.jsx create mode 100644 packages/multiple-choice/src/__tests__/multiple-choice.test.jsx rename packages/multiple-choice/src/__tests__/{session-updater-test.jsx => session-updater.test.jsx} (100%) create mode 100644 packages/multiple-choice/src/utils.js diff --git a/packages/multiple-choice/controller/src/__tests__/index.test.js b/packages/multiple-choice/controller/src/__tests__/index.test.js index 57f70bd1ce..b80f3557eb 100644 --- a/packages/multiple-choice/controller/src/__tests__/index.test.js +++ b/packages/multiple-choice/controller/src/__tests__/index.test.js @@ -1,4 +1,4 @@ -import { model, outcome, getScore, createCorrectResponseSession, normalize } from '../index'; +import { model, outcome, getScore, createCorrectResponseSession, normalize, validate } from '../index'; import { isResponseCorrect } from '../utils'; import defaults from '../defaults'; @@ -478,4 +478,45 @@ describe('controller', () => { expect(sess).toEqual({ ...defaults, ...question, choicesLayout: 'vertical' }); }); }); + + describe('validate', () => { + const makeChoice = (value, correct = false) => ({ value, label: value, correct }); + const config = { minAnswerChoices: 2, maxAnswerChoices: 5 }; + + it('returns no error when maxSelections >= correctCount', () => { + const m = { + choiceMode: 'checkbox', + maxSelections: 2, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toBeUndefined(); + }); + + it('returns error when maxSelections < correctCount', () => { + const m = { + choiceMode: 'checkbox', + maxSelections: 1, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toMatch(/exceeds max selections/); + }); + + it('does not error in radio mode even if maxSelections < correctCount', () => { + const m = { + choiceMode: 'radio', + maxSelections: 1, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toBeUndefined(); + }); + + it('does not error when maxSelections is null', () => { + const m = { + choiceMode: 'checkbox', + maxSelections: null, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toBeUndefined(); + }); + }); }); diff --git a/packages/multiple-choice/controller/src/index.js b/packages/multiple-choice/controller/src/index.js index f22840c303..8ffdf41116 100644 --- a/packages/multiple-choice/controller/src/index.js +++ b/packages/multiple-choice/controller/src/index.js @@ -245,12 +245,14 @@ export const validate = (model = {}, config = {}) => { }); let hasCorrectResponse = false; + let correctCount = 0; reversedChoices.forEach((choice, index) => { const { correct, value, label, rationale } = choice; if (correct) { hasCorrectResponse = true; + correctCount++; } if (!getContent(label)) { @@ -278,6 +280,12 @@ export const validate = (model = {}, config = {}) => { if (!hasCorrectResponse) { errors.correctResponse = 'No correct response defined.'; + } else { + const { maxSelections, choiceMode } = model; + + if (choiceMode !== 'radio' && maxSelections != null && correctCount > maxSelections) { + errors.correctResponse = `The number of correct answers (${correctCount}) exceeds max selections (${maxSelections}). Students won't be able to select all correct answers.`; + } } if (!isEmpty(choicesErrors)) { diff --git a/packages/multiple-choice/src/__tests__/choice-input-test.jsx b/packages/multiple-choice/src/__tests__/choice-input.test.jsx similarity index 100% rename from packages/multiple-choice/src/__tests__/choice-input-test.jsx rename to packages/multiple-choice/src/__tests__/choice-input.test.jsx diff --git a/packages/multiple-choice/src/__tests__/index-test.jsx b/packages/multiple-choice/src/__tests__/index.test.jsx similarity index 81% rename from packages/multiple-choice/src/__tests__/index-test.jsx rename to packages/multiple-choice/src/__tests__/index.test.jsx index 87b54e34a0..20ae26a56d 100644 --- a/packages/multiple-choice/src/__tests__/index-test.jsx +++ b/packages/multiple-choice/src/__tests__/index.test.jsx @@ -7,6 +7,18 @@ import { isComplete } from '../index'; jest.useFakeTimers(); jest.mock('@pie-lib/math-rendering', () => ({ renderMath: jest.fn() })); +jest.mock('@pie-lib/correct-answer-toggle', () => () => null); +jest.mock('@pie-lib/translator', () => ({ + __esModule: true, + default: { translator: { t: (key) => key } }, +})); +jest.mock('../choice', () => { + const React = require('react'); + return { + __esModule: true, + default: ({ choice }) =>
{choice.label}
, + }; +}); jest.mock('lodash-es', () => { const lodash = require('lodash'); return { @@ -48,6 +60,16 @@ describe('isComplete', () => { }); }); +beforeAll(() => { + customElements.define('pie-multiple-choice', MultipleChoice); +}); + +const makeEl = () => { + const el = new MultipleChoice(); + el.dispatchEvent = jest.fn(); + return el; +}; + describe('multiple-choice', () => { describe('rendering', () => { const renderComponent = (modelOverrides = {}) => { @@ -74,11 +96,6 @@ describe('multiple-choice', () => { expect(screen.getByTestId('preview-layout')).toBeInTheDocument(); }); - it('renders with rationale', () => { - renderComponent({ rationale: 'This is rationale' }); - expect(screen.getByText('This is rationale')).toBeInTheDocument(); - }); - it('renders with teacherInstructions', () => { renderComponent({ teacherInstructions: 'These are teacher instructions' }); expect(screen.getByText('These are teacher instructions')).toBeInTheDocument(); @@ -99,56 +116,51 @@ describe('multiple-choice', () => { describe('events', () => { describe('model', () => { it('dispatches model set event', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = {}; - expect(el.dispatchEvent).toBeCalledWith(new ModelSetEvent('mc-el', false, true)); + expect(el.dispatchEvent).toBeCalledWith(new ModelSetEvent(el.tagName.toLowerCase(), false, true)); }); }); describe('onChange', () => { it('dispatches session changed event - add answer (checkbox)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'checkbox' }; el.session = { value: [] }; el._onChange({ value: 'a', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); }); it('dispatches session changed event - remove answer (checkbox)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'checkbox' }; el.session = { value: ['a'] }; el._onChange({ value: 'a', selected: false }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', false)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), false)); }); it('dispatches session changed event - add/remove answer (checkbox)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'checkbox' }; el.session = { value: ['1'] }; el._onChange({ id: '2', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); el._onChange({ id: '1', selected: false }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); el._onChange({ id: '2', selected: false }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', false)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), false)); }); it('dispatches session changed event - add/change answer (radio)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'radio' }; el.session = { value: [] }; el._onChange({ value: 'a', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); el._onChange({ value: 'b', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); }); }); }); diff --git a/packages/multiple-choice/src/__tests__/key-events-test.jsx b/packages/multiple-choice/src/__tests__/key-events.test.jsx similarity index 86% rename from packages/multiple-choice/src/__tests__/key-events-test.jsx rename to packages/multiple-choice/src/__tests__/key-events.test.jsx index 9e65fbcb21..a34e8698e2 100644 --- a/packages/multiple-choice/src/__tests__/key-events-test.jsx +++ b/packages/multiple-choice/src/__tests__/key-events.test.jsx @@ -1,4 +1,10 @@ -import MultipleChoice from '../multiple-choice'; +import MultipleChoice from '../index'; + +beforeAll(() => { + if (!customElements.get('pie-multiple-choice-key-events')) { + customElements.define('pie-multiple-choice-key-events', MultipleChoice); + } +}); describe('MultipleChoice', () => { let instance; @@ -6,6 +12,7 @@ describe('MultipleChoice', () => { beforeEach(() => { instance = new MultipleChoice(); instance._model = { + mode: 'gather', choices: [ { value: '1' }, { value: '2' }, @@ -28,6 +35,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: true, + selector: 'Keyboard', }); }); @@ -37,6 +45,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: false, + selector: 'Keyboard', }); }); @@ -45,6 +54,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: true, + selector: 'Keyboard', }); }); @@ -59,6 +69,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '2', selected: true, + selector: 'Keyboard', }); }); @@ -69,6 +80,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '3', selected: true, + selector: 'Keyboard', }); }); @@ -79,6 +91,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: true, + selector: 'Keyboard', }); }); diff --git a/packages/multiple-choice/src/__tests__/multiple-choice-test.jsx b/packages/multiple-choice/src/__tests__/multiple-choice-test.jsx deleted file mode 100644 index d4c9a5b3fa..0000000000 --- a/packages/multiple-choice/src/__tests__/multiple-choice-test.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { MultipleChoice, Choice } from '../multiple-choice'; -import CorrectAnswerToggle from '@pie-lib/correct-answer-toggle'; - -// Mock the CorrectAnswerToggle component -jest.mock('@pie-lib/correct-answer-toggle', () => { - const MockToggle = (props) => ( -
- Mock Toggle -
- ); - MockToggle.displayName = 'CorrectAnswerToggle'; - return MockToggle; -}); - -describe('Choice', () => { - let onChoiceChanged; - - beforeEach(() => { - onChoiceChanged = jest.fn(); - }); - - const renderChoice = (extras = {}) => { - const props = { - classes: {}, - choice: {}, - index: 0, - choicesLength: 1, - showCorrect: false, - isEvaluateMode: false, - choiceMode: 'radio', - disabled: true, - onChoiceChanged, - checked: true, - correctness: 'correct', - displayKey: '0', - choicesLayout: 'grid', - gridColumns: 2, - ...extras, - }; - return render(); - }; - - describe('logic', () => { - it('does not call onChoiceChanged if disabled is true', () => { - const { container } = renderChoice({ disabled: true }); - const input = container.querySelector('input'); - if (input) { - input.click(); - } - // When disabled, onChange should not be called - expect(onChoiceChanged).not.toHaveBeenCalled(); - }); - }); -}); - -describe('CorespringChoice', () => { - const mkWrapper = (opts = {}) => { - const defaultProps = { - classes: {}, - choices: [], - disabled: false, - keyMode: 'letters', - onChoiceChanged: jest.fn(), - mode: 'gather', - ...opts, - }; - - return render(); - }; - - describe('Toggle', () => { - it('toggle is rendered', () => { - mkWrapper(); - expect(screen.getByTestId('correct-answer-toggle')).toBeInTheDocument(); - }); - - it('toggle show is set to false by default', () => { - mkWrapper(); - const toggle = screen.getByTestId('correct-answer-toggle'); - expect(toggle).toHaveAttribute('data-show', 'false'); - }); - - it('shows toggle if mode is evaluate, feedback is enabled and responseCorrect is false', () => { - mkWrapper({ mode: 'evaluate', responseCorrect: false, feedbackEnabled: true }); - const toggle = screen.getByTestId('correct-answer-toggle'); - expect(toggle).toHaveAttribute('data-show', 'true'); - }); - - it('hides toggle if mode is evaluate and responseCorrect is true', () => { - mkWrapper({ mode: 'evaluate', responseCorrect: true }); - const toggle = screen.getByTestId('correct-answer-toggle'); - expect(toggle).toHaveAttribute('data-show', 'false'); - }); - }); -}); diff --git a/packages/multiple-choice/src/__tests__/multiple-choice.test.jsx b/packages/multiple-choice/src/__tests__/multiple-choice.test.jsx new file mode 100644 index 0000000000..9fa42401ea --- /dev/null +++ b/packages/multiple-choice/src/__tests__/multiple-choice.test.jsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MultipleChoice } from '../multiple-choice'; + +// Mock the CorrectAnswerToggle component +jest.mock('@pie-lib/correct-answer-toggle', () => { + const MockToggle = (props) => ( +
+ Mock Toggle +
+ ); + MockToggle.displayName = 'CorrectAnswerToggle'; + return MockToggle; +}); + +jest.mock('@pie-lib/render-ui', () => ({ + color: { + text: () => '#000', + background: () => '#fff', + incorrect: () => '#f00', + correct: () => '#0f0', + primaryLight: () => '#eee', + primary: () => '#333', + }, + Collapsible: ({ children }) =>
{children}
, + PreviewPrompt: ({ prompt, tagName: Tag = 'div' }) => {prompt}, + transformDataHeadings: (html) => html, +})); + +jest.mock('@pie-lib/translator', () => ({ + __esModule: true, + default: { translator: { t: (key) => key } }, +})); + +// Mock Choice with separate select/deselect buttons so fireEvent.click +// reliably triggers handleChange with the correct checked value. +jest.mock('../choice', () => { + const React = require('react'); + return { + __esModule: true, + default: ({ choice, onChoiceChanged }) => ( + <> +