From 853b84b48b2155b3470d1313d66ef85c43b319ff Mon Sep 17 00:00:00 2001 From: Patricia Romaniuc Date: Mon, 9 Feb 2026 15:25:59 +0200 Subject: [PATCH 1/2] fix(placement-ordering): enhance StyledTileContent with conditional styles and improve cursor behavior PD-5538, PD-5540 --- .../configure/src/choice-tile.jsx | 1 - .../src/placement-ordering.jsx | 4 +- packages/placement-ordering/src/tile.jsx | 55 +++++++++++++------ 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/placement-ordering/configure/src/choice-tile.jsx b/packages/placement-ordering/configure/src/choice-tile.jsx index 9ec255ea08..2eb29aaa46 100644 --- a/packages/placement-ordering/configure/src/choice-tile.jsx +++ b/packages/placement-ordering/configure/src/choice-tile.jsx @@ -23,7 +23,6 @@ const StyledChoiceTile = styled('div')(({ theme }) => ({ const StyledEditableHtml = styled(EditableHtml)(({ theme, isTargetPrompt }) => ({ width: '80%', - border: 'none', borderRadius: '4px', ...(isTargetPrompt && { backgroundColor: theme.palette.error.light, diff --git a/packages/placement-ordering/src/placement-ordering.jsx b/packages/placement-ordering/src/placement-ordering.jsx index 75566a544b..99aeaa737e 100644 --- a/packages/placement-ordering/src/placement-ordering.jsx +++ b/packages/placement-ordering/src/placement-ordering.jsx @@ -6,6 +6,7 @@ import uniqueId from 'lodash/uniqueId'; import isEqual from 'lodash/isEqual'; import difference from 'lodash/difference'; import { styled } from '@mui/material/styles'; +import { closestCenter } from '@dnd-kit/core'; import { Collapsible, color, Feedback, hasMedia, hasText, PreviewPrompt, UiLayout } from '@pie-lib/render-ui'; import { renderMath } from '@pie-lib/math-rendering'; @@ -325,7 +326,7 @@ export class PlacementOrdering extends React.Component { }; return ( - { }} onDragEnd={this.onDragEnd}> + { }} onDragEnd={this.onDragEnd} collisionDetection={closestCenter}> {showTeacherInstructions && ( @@ -342,7 +343,6 @@ export class PlacementOrdering extends React.Component { ({ - cursor: 'pointer', +const StyledTileContent = styled('div')(({ theme, isDragging, isOver, disabled, outcome, label, type }) => ({ + cursor: disabled ? 'not-allowed' : 'grab', width: '100%', height: '100%', padding: '10px', boxSizing: 'border-box', overflow: 'hidden', - border: `1px solid ${theme.palette.grey[400]}`, - backgroundColor: color.background(), - transition: 'opacity 200ms linear', + border: (type === 'choice') ? `1px solid ${theme.palette.grey[400]}` : '1px solid transparent', + backgroundColor: (type === 'choice') ? color.background() : 'transparent', + transition: (type === 'choice') ? 'background-color 150ms ease, border-color 150ms ease, opacity 150ms ease' : 'none', pointerEvents: 'none', - '&:hover': { - backgroundColor: color.secondary(), - }, + userSelect: 'none', - // Apply conditional styles based on props - ...(isOver && !disabled && { - opacity: 0.2, + ...((type === 'choice') && { + '&:hover': { + backgroundColor: disabled ? color.background() : color.secondary(), + borderColor: disabled ? theme.palette.grey[400] : theme.palette.primary.main, + transform: disabled ? 'none' : 'scale(1.02)', + }, }), - ...(isDragging && !disabled && { - opacity: 0.5, + // Apply conditional styles based on props (only if not empty spacing tile) + ...((type === 'choice') && isOver && !disabled && { + opacity: 0.4, + backgroundColor: color.primaryLight(), + borderColor: theme.palette.primary.main, + borderStyle: 'dashed', + transform: 'scale(1.05)', + }), + + ...((type === 'choice') && isDragging && !disabled && { + opacity: 0.6, backgroundColor: color.secondaryLight(), + transform: 'scale(1.05) rotate(2deg)', + boxShadow: '0 8px 16px rgba(0,0,0,0.2)', + cursor: 'grabbing', }), - ...(disabled && { + ...((type === 'choice') && disabled && { + opacity: 0.6, cursor: 'not-allowed', '&:hover': { backgroundColor: color.background(), + transform: 'none', }, }), - ...(outcome === 'incorrect' && { + ...((type === 'choice') && outcome === 'incorrect' && { border: `1px solid ${color.incorrect()}`, }), - ...(outcome === 'correct' && { + ...((type === 'choice') && outcome === 'correct' && { border: `1px solid ${color.correct()}`, }), @@ -83,8 +98,10 @@ const TileContent = (props) => { if (empty) { return ; } else { + console.log('TileContent render, props: ', props); return ( { const style = { transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined, boxSizing: 'border-box', - overflow: 'hidden', + overflow: 'visible', padding: 0, margin: 0, textAlign: 'center', + pointerEvents: 'auto', + cursor: disabled ? 'not-allowed' : (isDragging ? 'grabbing' : 'grab'), + zIndex: isDragging ? 1000 : 'auto', + willChange: isDragging ? 'transform' : 'auto', }; return ( From ffddcddc48c71b4ebbdd1ef3195839d1707a608c Mon Sep 17 00:00:00 2001 From: Patricia Romaniuc Date: Mon, 9 Feb 2026 15:56:45 +0200 Subject: [PATCH 2/2] test: add comprehensive tests for ChoiceEditor, ChoiceTile, Help, and utility functions --- .../src/__tests__/choice-editor.test.jsx | 349 ++++++++++++++++++ .../src/__tests__/choice-tile.test.jsx | 283 ++++++++++++++ .../configure/src/__tests__/help.test.jsx | 65 ++++ .../configure/src/__tests__/utils.test.js | 340 +++++++++++++++++ 4 files changed, 1037 insertions(+) create mode 100644 packages/placement-ordering/configure/src/__tests__/choice-editor.test.jsx create mode 100644 packages/placement-ordering/configure/src/__tests__/choice-tile.test.jsx create mode 100644 packages/placement-ordering/configure/src/__tests__/help.test.jsx create mode 100644 packages/placement-ordering/configure/src/__tests__/utils.test.js diff --git a/packages/placement-ordering/configure/src/__tests__/choice-editor.test.jsx b/packages/placement-ordering/configure/src/__tests__/choice-editor.test.jsx new file mode 100644 index 0000000000..b2163d19bd --- /dev/null +++ b/packages/placement-ordering/configure/src/__tests__/choice-editor.test.jsx @@ -0,0 +1,349 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ChoiceEditor from '../choice-editor'; + +// Mock dependencies +jest.mock('@pie-lib/render-ui', () => ({ + InputContainer: ({ label, children }) => ( +
+ {label &&
{label}
} + {children} +
+ ), +})); + +jest.mock('@pie-lib/config-ui', () => ({ + AlertDialog: ({ open, title, text, onConfirm }) => + open ? ( +
+
{title}
+
{text}
+ +
+ ) : null, +})); + +jest.mock('../choice-tile', () => ({ + __esModule: true, + default: ({ choice, onDelete, onChoiceChange, error }) => ( +
+
{choice.label}
+ + + {error &&
{error}
} +
+ ), +})); + +describe('ChoiceEditor', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + choices: [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c2', label: 'Choice 2' }, + { id: 'c3', label: 'Choice 3' }, + ], + correctResponse: [ + { id: 'c1', weight: 0 }, + { id: 'c2', weight: 0 }, + { id: 'c3', weight: 0 }, + ], + onChange: jest.fn(), + imageSupport: { + add: jest.fn(), + delete: jest.fn(), + }, + singularChoiceLabel: 'choice', + pluralChoiceLabel: 'choices', + choicesLabel: 'Choices', + ordering: { + tiles: [ + { id: 'c1', label: 'Choice 1', type: 'choice' }, + { id: 'c2', label: 'Choice 2', type: 'choice' }, + { id: 'c3', label: 'Choice 3', type: 'choice' }, + { id: 'c1', label: 'Choice 1', type: 'target' }, + { id: 'c2', label: 'Choice 2', type: 'target' }, + { id: 'c3', label: 'Choice 3', type: 'target' }, + ], + }, + errors: {}, + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render column labels', () => { + render(); + expect(screen.getByText('Student Choices')).toBeInTheDocument(); + expect(screen.getByText('Correct Order')).toBeInTheDocument(); + }); + + it('should render all choice tiles', () => { + render(); + expect(screen.getByTestId('choice-tile-choice-c1')).toBeInTheDocument(); + expect(screen.getByTestId('choice-tile-choice-c2')).toBeInTheDocument(); + expect(screen.getByTestId('choice-tile-choice-c3')).toBeInTheDocument(); + expect(screen.getByTestId('choice-tile-target-c1')).toBeInTheDocument(); + expect(screen.getByTestId('choice-tile-target-c2')).toBeInTheDocument(); + expect(screen.getByTestId('choice-tile-target-c3')).toBeInTheDocument(); + }); + + it('should render shuffle button', () => { + render(); + expect(screen.getByText('SHUFFLE CHOICES')).toBeInTheDocument(); + }); + + it('should render add button', () => { + render(); + expect(screen.getByText('ADD CHOICE')).toBeInTheDocument(); + }); + + it('should use custom choice labels', () => { + const props = { + ...defaultProps, + singularChoiceLabel: 'item', + pluralChoiceLabel: 'items', + choicesLabel: 'Items', + }; + render(); + expect(screen.getByText('SHUFFLE ITEMS')).toBeInTheDocument(); + expect(screen.getByText('ADD ITEM')).toBeInTheDocument(); + expect(screen.getByText('Student Items')).toBeInTheDocument(); + }); + }); + + describe('adding choices', () => { + it('should add a new choice when add button is clicked', () => { + render(); + const addButton = screen.getByText('ADD CHOICE'); + + fireEvent.click(addButton); + + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.arrayContaining([ + ...defaultProps.choices, + { id: 'c4', label: '' }, + ]), + expect.arrayContaining([ + ...defaultProps.correctResponse, + { id: 'c4', weight: 0 }, + ]), + ); + }); + + it('should show warning when trying to add more than 10 choices', () => { + const props = { + ...defaultProps, + choices: Array.from({ length: 10 }, (_, i) => ({ + id: `c${i + 1}`, + label: `Choice ${i + 1}`, + })), + ordering: { + tiles: Array.from({ length: 10 }, (_, i) => ({ + id: `c${i + 1}`, + label: `Choice ${i + 1}`, + type: 'choice', + })), + }, + }; + + render(); + const addButton = screen.getByText('ADD CHOICE'); + + fireEvent.click(addButton); + + expect(screen.getByTestId('alert-dialog')).toBeInTheDocument(); + expect(screen.getByText('There can be maximum 10 Choices.')).toBeInTheDocument(); + }); + + it('should find free slot when adding choice', () => { + const props = { + ...defaultProps, + choices: [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c3', label: 'Choice 3' }, + ], + correctResponse: [ + { id: 'c1', weight: 0 }, + { id: 'c3', weight: 0 }, + ], + ordering: { + tiles: [ + { id: 'c1', label: 'Choice 1', type: 'choice' }, + { id: 'c3', label: 'Choice 3', type: 'choice' }, + ], + }, + }; + + render(); + const addButton = screen.getByText('ADD CHOICE'); + + fireEvent.click(addButton); + + expect(props.onChange).toHaveBeenCalledWith( + expect.arrayContaining([ + ...props.choices, + { id: 'c2', label: '' }, + ]), + expect.anything(), + ); + }); + }); + + describe('deleting choices', () => { + it('should show warning when trying to delete with only 3 choices', () => { + render(); + const deleteButton = screen.getByTestId('delete-choice-c1'); + + fireEvent.click(deleteButton); + + expect(screen.getByTestId('alert-dialog')).toBeInTheDocument(); + expect(screen.getByText('There have to be at least 3 Choices.')).toBeInTheDocument(); + }); + + it('should allow deletion when more than 3 choices exist', () => { + const props = { + ...defaultProps, + choices: [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c2', label: 'Choice 2' }, + { id: 'c3', label: 'Choice 3' }, + { id: 'c4', label: 'Choice 4' }, + ], + ordering: { + tiles: [ + { id: 'c1', label: 'Choice 1', type: 'choice' }, + { id: 'c2', label: 'Choice 2', type: 'choice' }, + { id: 'c3', label: 'Choice 3', type: 'choice' }, + { id: 'c4', label: 'Choice 4', type: 'choice' }, + ], + }, + }; + + render(); + const deleteButton = screen.getByTestId('delete-choice-c1'); + + fireEvent.click(deleteButton); + + expect(props.onChange).toHaveBeenCalled(); + expect(screen.queryByTestId('alert-dialog')).not.toBeInTheDocument(); + }); + }); + + describe('changing choices', () => { + it('should update choice label when changed', () => { + render(); + const changeButton = screen.getByTestId('change-choice-c1'); + + fireEvent.click(changeButton); + + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.arrayContaining([ + { id: 'c1', label: 'Updated' }, + { id: 'c2', label: 'Choice 2' }, + { id: 'c3', label: 'Choice 3' }, + ]), + defaultProps.correctResponse, + ); + }); + }); + + describe('shuffling choices', () => { + it('should shuffle choices when shuffle button is clicked', () => { + render(); + const shuffleButton = screen.getByText('SHUFFLE CHOICES'); + + fireEvent.click(shuffleButton); + + expect(defaultProps.onChange).toHaveBeenCalled(); + const [shuffledChoices] = defaultProps.onChange.mock.calls[0]; + expect(shuffledChoices).toHaveLength(3); + expect(shuffledChoices.map((c) => c.id).sort()).toEqual(['c1', 'c2', 'c3']); + }); + + it('should shuffle again if result matches correct order when placementArea is disabled', () => { + const props = { + ...defaultProps, + placementArea: false, + }; + + // Mock shuffle to return the same order first time + const originalMath = Math.random; + let callCount = 0; + Math.random = () => { + callCount++; + // First call returns ordered, second returns shuffled + return callCount === 1 ? 0.1 : 0.9; + }; + + render(); + const shuffleButton = screen.getByText('SHUFFLE CHOICES'); + + fireEvent.click(shuffleButton); + + expect(props.onChange).toHaveBeenCalled(); + + Math.random = originalMath; + }); + }); + + describe('alert dialog', () => { + it('should close warning dialog when confirm is clicked', () => { + render(); + const deleteButton = screen.getByTestId('delete-choice-c1'); + + fireEvent.click(deleteButton); + expect(screen.getByTestId('alert-dialog')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('alert-confirm'); + fireEvent.click(confirmButton); + + expect(screen.queryByTestId('alert-dialog')).not.toBeInTheDocument(); + }); + }); + + describe('errors', () => { + it('should display choice errors', () => { + const props = { + ...defaultProps, + errors: { + choicesErrors: { + c1: 'Choice is required', + }, + }, + }; + + render(); + // Error appears on both choice and target tiles for the same id + expect(screen.getAllByTestId(/error-.*-c1/)[0]).toHaveTextContent('Choice is required'); + }); + + it('should display order error', () => { + const props = { + ...defaultProps, + errors: { + orderError: 'Order must be different', + }, + }; + + render(); + expect(screen.getByText('Order must be different')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/placement-ordering/configure/src/__tests__/choice-tile.test.jsx b/packages/placement-ordering/configure/src/__tests__/choice-tile.test.jsx new file mode 100644 index 0000000000..6887298b0c --- /dev/null +++ b/packages/placement-ordering/configure/src/__tests__/choice-tile.test.jsx @@ -0,0 +1,283 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ChoiceTile } from '../choice-tile'; + +// Mock dependencies +jest.mock('@dnd-kit/core', () => ({ + useDraggable: () => ({ + attributes: { 'data-draggable': 'true' }, + listeners: { onPointerDown: jest.fn() }, + setNodeRef: jest.fn(), + transform: null, + isDragging: false, + }), + useDroppable: () => ({ + setNodeRef: jest.fn(), + isOver: false, + }), +})); + +jest.mock('@pie-lib/editable-html-tip-tap', () => ({ + __esModule: true, + default: ({ markup, onChange, disabled, placeholder, error }) => ( +
+ onChange(e.target.value)} + disabled={disabled} + placeholder={placeholder} + /> + {error &&
{error}
} +
+ ), + DEFAULT_PLUGINS: ['bold', 'italic', 'underline', 'bulleted-list', 'numbered-list'], +})); + +jest.mock('@pie-lib/render-ui', () => ({ + color: { + tertiary: () => '#666666', + }, +})); + +describe('ChoiceTile', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + choice: { + id: 'c1', + label: 'Test choice', + editable: true, + type: 'choice', + }, + index: 0, + choices: [], + onChoiceChange: jest.fn(), + onDelete: jest.fn(), + imageSupport: { + add: jest.fn(), + delete: jest.fn(), + }, + spellCheck: true, + toolbarOpts: {}, + pluginProps: {}, + maxImageWidth: { unit: 'px', value: 300 }, + maxImageHeight: { unit: 'px', value: 300 }, + error: null, + mathMlOptions: {}, + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render with correct label', () => { + render(); + const input = screen.getByTestId('editable-input'); + expect(input).toHaveValue('Test choice'); + }); + + it('should render drag handle', () => { + const { container } = render(); + const dragHandle = container.querySelector('[data-testid="DragHandleIcon"]'); + expect(container.querySelector('[data-draggable="true"]')).toBeInTheDocument(); + }); + + it('should render delete button when editable', () => { + const { container } = render(); + const deleteButton = container.querySelector('button'); + expect(deleteButton).toBeInTheDocument(); + }); + + it('should not render delete button when not editable', () => { + const props = { + ...defaultProps, + choice: { + ...defaultProps.choice, + editable: false, + }, + }; + + const { container } = render(); + const buttons = container.querySelectorAll('button'); + // No delete button when not editable + expect(buttons.length).toBe(0); + }); + + it('should show placeholder for empty choice', () => { + const props = { + ...defaultProps, + choice: { + ...defaultProps.choice, + label: '', + }, + }; + + render(); + const input = screen.getByTestId('editable-input'); + expect(input).toHaveAttribute('placeholder', 'Enter a choice'); + }); + + it('should not show placeholder for target type', () => { + const props = { + ...defaultProps, + choice: { + ...defaultProps.choice, + type: 'target', + label: '', + }, + }; + + render(); + const input = screen.getByTestId('editable-input'); + expect(input).toHaveAttribute('placeholder', ''); + }); + + it('should not show placeholder for LaTeX content', () => { + const props = { + ...defaultProps, + choice: { + ...defaultProps.choice, + label: '', + }, + }; + + render(); + const input = screen.getByTestId('editable-input'); + expect(input).toHaveAttribute('placeholder', ''); + }); + }); + + describe('interactions', () => { + it('should call onDelete when delete button is clicked', () => { + const { container } = render(); + const deleteButton = container.querySelector('button'); + + fireEvent.click(deleteButton); + + expect(defaultProps.onDelete).toHaveBeenCalled(); + }); + + it('should call onChoiceChange when label is changed', () => { + render(); + const input = screen.getByTestId('editable-input'); + + fireEvent.change(input, { target: { value: 'Updated choice' } }); + + expect(defaultProps.onChoiceChange).toHaveBeenCalledWith({ + ...defaultProps.choice, + label: 'Updated choice', + }); + }); + + it('should not allow changes when disabled', () => { + const props = { + ...defaultProps, + choice: { + ...defaultProps.choice, + editable: false, + }, + }; + + render(); + const input = screen.getByTestId('editable-input'); + + expect(input).toBeDisabled(); + }); + }); + + describe('errors', () => { + it('should display error message when editable and error exists', () => { + const props = { + ...defaultProps, + error: 'This field is required', + }; + + render(); + const errorElements = screen.getAllByText('This field is required'); + expect(errorElements).toHaveLength(2); // error appears in both EditableHtml and the component + expect(errorElements[0]).toBeInTheDocument(); + }); + + it('should pass error to EditableHtml', () => { + const props = { + ...defaultProps, + error: 'This field is required', + }; + + render(); + expect(screen.getByTestId('editable-error')).toHaveTextContent('This field is required'); + }); + + it('should not display error when not editable', () => { + const props = { + ...defaultProps, + choice: { + ...defaultProps.choice, + editable: false, + }, + error: 'This field is required', + }; + + render(); + expect(screen.queryByText('This field is required')).not.toBeInTheDocument(); + }); + }); + + describe('image support', () => { + it('should pass imageSupport to EditableHtml', () => { + render(); + expect(screen.getByTestId('editable-html')).toBeInTheDocument(); + }); + + it('should pass maxImageWidth and maxImageHeight', () => { + const props = { + ...defaultProps, + maxImageWidth: { unit: 'px', value: 500 }, + maxImageHeight: { unit: 'px', value: 400 }, + }; + + render(); + expect(screen.getByTestId('editable-html')).toBeInTheDocument(); + }); + }); + + describe('drag and drop IDs', () => { + it('should generate correct draggable ID', () => { + const props = { + ...defaultProps, + choice: { + id: 'c1', + label: 'Choice 1', + type: 'choice', + editable: true, + }, + }; + + render(); + // The component uses draggableId: `${type}-${choice.id}` => 'choice-c1' + expect(screen.getByTestId('editable-html')).toBeInTheDocument(); + }); + + it('should generate correct droppable ID', () => { + const props = { + ...defaultProps, + choice: { + id: 'c1', + label: 'Choice 1', + type: 'target', + editable: false, + }, + }; + + render(); + // The component uses droppableId: `${type}-drop-${choice.id}` => 'target-drop-c1' + expect(screen.getByTestId('editable-html')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/placement-ordering/configure/src/__tests__/help.test.jsx b/packages/placement-ordering/configure/src/__tests__/help.test.jsx new file mode 100644 index 0000000000..be291a87c3 --- /dev/null +++ b/packages/placement-ordering/configure/src/__tests__/help.test.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Help from '../help'; + +// Mock the Help component from @pie-lib/config-ui +jest.mock('@pie-lib/config-ui', () => ({ + Help: ({ title, children }) => ( +
+
{title}
+
{children}
+
+ ), +})); + +describe('Help Component', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render with correct title', () => { + render(); + expect(screen.getByTestId('help-title')).toHaveTextContent('Help'); + }); + + it('should render help content', () => { + render(); + const content = screen.getByTestId('help-content'); + expect(content).toBeInTheDocument(); + }); + + it('should contain correct help text about ordering', () => { + render(); + const content = screen.getByTestId('help-content'); + expect(content).toHaveTextContent( + 'In Ordering, a student is asked to sequence events or inputs in a specific order.', + ); + }); + + it('should contain instructions about drag and drop', () => { + render(); + const content = screen.getByTestId('help-content'); + expect(content).toHaveTextContent( + 'After setting up the choices, drag and drop them into the correct order.', + ); + }); + + it('should contain information about student view', () => { + render(); + const content = screen.getByTestId('help-content'); + expect(content).toHaveTextContent('Students will see a shuffled version of the choices.'); + }); + + it('should render the Help component from pie-lib', () => { + render(); + expect(screen.getByTestId('help-component')).toBeInTheDocument(); + }); + + it('should have line breaks in content', () => { + const { container } = render(); + const breaks = container.querySelectorAll('br'); + expect(breaks.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/placement-ordering/configure/src/__tests__/utils.test.js b/packages/placement-ordering/configure/src/__tests__/utils.test.js new file mode 100644 index 0000000000..0155302b1a --- /dev/null +++ b/packages/placement-ordering/configure/src/__tests__/utils.test.js @@ -0,0 +1,340 @@ +import { + generateValidationMessage, + normalizeIndex, + updateResponseOrChoices, + buildTiles, +} from '../utils'; + +describe('utils', () => { + describe('generateValidationMessage', () => { + it('should return a validation message string', () => { + const message = generateValidationMessage(); + expect(typeof message).toBe('string'); + }); + + it('should contain validation requirements heading', () => { + const message = generateValidationMessage(); + expect(message).toContain('Validation requirements:'); + }); + + it('should mention minimum token requirement', () => { + const message = generateValidationMessage(); + expect(message).toContain('There should be at least 3 tokens.'); + }); + + it('should mention tokens should not be empty', () => { + const message = generateValidationMessage(); + expect(message).toContain('The tokens should not be empty and should be unique.'); + }); + + it('should mention correct ordering requirement', () => { + const message = generateValidationMessage(); + expect(message).toContain('The correct ordering should not be identical to the initial ordering.'); + }); + }); + + describe('normalizeIndex', () => { + const ordering = { + response: [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }], + choices: [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }], + }; + + it('should return correct index for target type', () => { + const tile = { id: 'r2', type: 'target' }; + const index = normalizeIndex(tile, ordering); + expect(index).toBe(1); + }); + + it('should return correct index for choice type', () => { + const tile = { id: 'c3', type: 'choice' }; + const index = normalizeIndex(tile, ordering); + expect(index).toBe(2); + }); + + it('should return -1 for unknown type', () => { + const tile = { id: 'x1', type: 'unknown' }; + const index = normalizeIndex(tile, ordering); + expect(index).toBe(-1); + }); + + it('should return -1 when tile not found in response', () => { + const tile = { id: 'r99', type: 'target' }; + const index = normalizeIndex(tile, ordering); + expect(index).toBe(-1); + }); + + it('should return -1 when tile not found in choices', () => { + const tile = { id: 'c99', type: 'choice' }; + const index = normalizeIndex(tile, ordering); + expect(index).toBe(-1); + }); + + it('should return correct index for first element', () => { + const tile = { id: 'r1', type: 'target' }; + const index = normalizeIndex(tile, ordering); + expect(index).toBe(0); + }); + }); + + describe('updateResponseOrChoices', () => { + describe('moving within response (target to target)', () => { + it('should move item within response array', () => { + const response = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }]; + const choices = [{ id: 'c1' }, { id: 'c2' }]; + const from = { type: 'target', index: 0, id: 'r1' }; + const to = { type: 'target', index: 2, id: 'r3' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.response).toEqual([{ id: 'r2' }, { id: 'r3' }, { id: 'r1' }]); + expect(result.choices).toEqual(choices); + }); + + it('should move item to beginning of response', () => { + const response = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }]; + const choices = [{ id: 'c1' }]; + const from = { type: 'target', index: 2, id: 'r3' }; + const to = { type: 'target', index: 0, id: 'r1' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.response).toEqual([{ id: 'r3' }, { id: 'r1' }, { id: 'r2' }]); + }); + + it('should move item to end of response', () => { + const response = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }]; + const choices = [{ id: 'c1' }]; + const from = { type: 'target', index: 0, id: 'r1' }; + const to = { type: 'target', index: 2, id: 'r3' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.response[result.response.length - 1]).toEqual({ id: 'r1' }); + }); + }); + + describe('moving within choices (choice to choice)', () => { + it('should move item within choices array', () => { + const response = [{ id: 'r1' }]; + const choices = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]; + const from = { type: 'choice', index: 0, id: 'c1' }; + const to = { type: 'choice', index: 2, id: 'c3' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.choices).toEqual([{ id: 'c2' }, { id: 'c3' }, { id: 'c1' }]); + expect(result.response).toEqual(response); + }); + + it('should handle moving to beginning of choices', () => { + const response = [{ id: 'r1' }]; + const choices = [{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]; + const from = { type: 'choice', index: 2, id: 'c3' }; + const to = { type: 'choice', index: 0, id: 'c1' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.choices[0]).toEqual({ id: 'c3' }); + }); + }); + + describe('mixed type movements', () => { + it('should return unchanged arrays when moving from choice to target', () => { + const response = [{ id: 'r1' }]; + const choices = [{ id: 'c1' }]; + const from = { type: 'choice', index: 0, id: 'c1' }; + const to = { type: 'target', index: 0, id: 'r1' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.response).toEqual(response); + expect(result.choices).toEqual(choices); + }); + + it('should return unchanged arrays when moving from target to choice', () => { + const response = [{ id: 'r1' }]; + const choices = [{ id: 'c1' }]; + const from = { type: 'target', index: 0, id: 'r1' }; + const to = { type: 'choice', index: 0, id: 'c1' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.response).toEqual(response); + expect(result.choices).toEqual(choices); + }); + }); + + describe('edge cases', () => { + it('should handle single item arrays', () => { + const response = [{ id: 'r1' }]; + const choices = [{ id: 'c1' }]; + const from = { type: 'target', index: 0, id: 'r1' }; + const to = { type: 'target', index: 0, id: 'r1' }; + + const result = updateResponseOrChoices(response, choices, from, to); + + expect(result.response).toHaveLength(1); + }); + }); + }); + + describe('buildTiles', () => { + it('should create tiles from choices and response', () => { + const choices = [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c2', label: 'Choice 2' }, + ]; + const response = [{ id: 'c1' }, { id: 'c2' }]; + + const tiles = buildTiles(choices, response); + + expect(tiles).toHaveLength(4); // 2 choices + 2 targets + }); + + it('should mark choice tiles as editable and draggable', () => { + const choices = [{ id: 'c1', label: 'Choice 1' }]; + const response = [{ id: 'c1' }]; + + const tiles = buildTiles(choices, response); + const choiceTile = tiles.find((t) => t.type === 'choice'); + + expect(choiceTile).toMatchObject({ + type: 'choice', + editable: true, + draggable: true, + droppable: false, + }); + }); + + it('should mark target tiles as draggable but not editable', () => { + const choices = [{ id: 'c1', label: 'Choice 1' }]; + const response = [{ id: 'c1' }]; + + const tiles = buildTiles(choices, response); + const targetTile = tiles.find((t) => t.type === 'target'); + + expect(targetTile).toMatchObject({ + type: 'target', + editable: false, + draggable: true, + }); + }); + + it('should match target tiles with corresponding choices by id', () => { + const choices = [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c2', label: 'Choice 2' }, + ]; + const response = [{ id: 'c2' }, { id: 'c1' }]; + + const tiles = buildTiles(choices, response); + const targetTiles = tiles.filter((t) => t.type === 'target'); + + expect(targetTiles[0]).toMatchObject({ + id: 'c2', + label: 'Choice 2', + }); + expect(targetTiles[1]).toMatchObject({ + id: 'c1', + label: 'Choice 1', + }); + }); + + it('should assign correct index to target tiles', () => { + const choices = [{ id: 'c1', label: 'Choice 1' }]; + const response = [{ id: 'c1' }]; + + const tiles = buildTiles(choices, response); + const targetTile = tiles.find((t) => t.type === 'target'); + + expect(targetTile.index).toBe(0); + }); + + it('should handle response with null or undefined ids', () => { + const choices = [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c2', label: 'Choice 2' }, + ]; + const response = [{ id: null }, { id: undefined }, { id: 'c1' }]; + + const tiles = buildTiles(choices, response); + const targetTiles = tiles.filter((t) => t.type === 'target'); + + expect(targetTiles).toHaveLength(3); + expect(targetTiles[2].id).toBe('c1'); + }); + + it('should preserve choice properties in choice tiles', () => { + const choices = [ + { id: 'c1', label: 'Choice 1', customProp: 'value' }, + ]; + const response = []; + + const tiles = buildTiles(choices, response); + const choiceTile = tiles.find((t) => t.type === 'choice'); + + expect(choiceTile.customProp).toBe('value'); + expect(choiceTile.label).toBe('Choice 1'); + }); + + it('should preserve choice properties in target tiles', () => { + const choices = [ + { id: 'c1', label: 'Choice 1', customProp: 'value' }, + ]; + const response = [{ id: 'c1' }]; + + const tiles = buildTiles(choices, response); + const targetTile = tiles.find((t) => t.type === 'target'); + + expect(targetTile.customProp).toBe('value'); + expect(targetTile.label).toBe('Choice 1'); + }); + + it('should handle empty choices array', () => { + const choices = []; + const response = []; + + const tiles = buildTiles(choices, response); + + expect(tiles).toEqual([]); + }); + + it('should handle empty response array', () => { + const choices = [{ id: 'c1', label: 'Choice 1' }]; + const response = []; + + const tiles = buildTiles(choices, response); + + expect(tiles).toHaveLength(1); + expect(tiles[0].type).toBe('choice'); + }); + + it('should maintain order of choices in choice tiles', () => { + const choices = [ + { id: 'c1', label: 'First' }, + { id: 'c2', label: 'Second' }, + { id: 'c3', label: 'Third' }, + ]; + const response = []; + + const tiles = buildTiles(choices, response); + const choiceTiles = tiles.filter((t) => t.type === 'choice'); + + expect(choiceTiles.map((t) => t.label)).toEqual(['First', 'Second', 'Third']); + }); + + it('should place choice tiles before target tiles', () => { + const choices = [ + { id: 'c1', label: 'Choice 1' }, + { id: 'c2', label: 'Choice 2' }, + ]; + const response = [{ id: 'c1' }]; + + const tiles = buildTiles(choices, response); + + expect(tiles[0].type).toBe('choice'); + expect(tiles[1].type).toBe('choice'); + expect(tiles[2].type).toBe('target'); + }); + }); +});