diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index d4f3f9d50b..e1b2e27350 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -174,6 +174,47 @@ export const FREE_RESPONSE_ITEM_XML = ` `; +/** + * Demo item 6: ordering interaction — learner arranges planets in correct order. + * Uses cardinality="ordered" and base-type="identifier" per QTI 3.0 §3.2.10. + */ +export const ORDERING_ITEM_XML = ` + + + + order_mercury + order_venus + order_earth + order_mars + + + + + +

Arrange the planets in order from closest to farthest from the Sun.

+ Mercury + Venus + Earth + Mars +
+
+
`; + /** * Hardcoded items covering different states: * - item-1: single-select choice interaction @@ -181,7 +222,7 @@ export const FREE_RESPONSE_ITEM_XML = ` * - item-numeric: numeric text-entry * - item-text-entry: string text-entry with case-sensitive answers * - item-free-response: free-response text-entry (no correct answer) - * - item-blank: no raw_data → shows placeholder (blank new item state) + * - item-ordering: ordering interaction (planets by distance from the Sun) */ export const INITIAL_ASSESSMENTS = [ { @@ -210,7 +251,8 @@ export const INITIAL_ASSESSMENTS = [ raw_data: FREE_RESPONSE_ITEM_XML, }, { - assessment_id: 'demo-item-blank', + assessment_id: 'demo-item-ordering', type: AssessmentItemTypes.QTI, + raw_data: ORDERING_ITEM_XML, }, ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index a699e6ed16..40711ad6f9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -131,6 +131,7 @@ [QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$, [QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$, [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, + [QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$, }; return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js new file mode 100644 index 0000000000..dcc93ea64c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js @@ -0,0 +1,124 @@ +import { ref } from 'vue'; +import { useOrderingInteraction } from '../useOrderingInteraction'; +import { ORDERING_XML, ORDERING_DECL_XML } from '../../utils/testingFixtures'; +import { QuestionType, ValidationError, Orientation } from '../../constants'; + +function makeInteractionBlock(bodyXml = ORDERING_XML, declarationXml = ORDERING_DECL_XML) { + return { bodyXml, responseDeclarations: [declarationXml] }; +} + +describe('useOrderingInteraction', () => { + function setup(bodyXml, declarationXml) { + const questionType = ref(QuestionType.ORDERING); + return useOrderingInteraction(makeInteractionBlock(bodyXml, declarationXml), questionType); + } + + describe('initial state', () => { + it('parses items correctly from the fixture XML', () => { + const { state } = setup(); + expect(state.value.items).toHaveLength(3); + expect(state.value.items[0].id).toBe('order_aaa11111'); + }); + + it('starts with an empty errors array', () => { + const { errors } = setup(); + expect(errors.value).toEqual([]); + }); + + it('orientation defaults to vertical', () => { + const { state } = setup(); + expect(state.value.orientation).toBe(Orientation.VERTICAL); + }); + }); + + describe('addItem()', () => { + it('appends a new item with a generated order_ identifier', () => { + const { state, addItem } = setup(); + const before = state.value.items.length; + addItem(); + expect(state.value.items).toHaveLength(before + 1); + expect(state.value.items[before].id).toMatch(/^order_/); + }); + + it('new item starts with empty content', () => { + const { state, addItem } = setup(); + addItem(); + const last = state.value.items[state.value.items.length - 1]; + expect(last.content).toBe(''); + }); + }); + + describe('removeItem()', () => { + it('removes the item with the given id', () => { + const { state, removeItem } = setup(); + const idToRemove = state.value.items[0].id; + removeItem(idToRemove); + expect(state.value.items.find(i => i.id === idToRemove)).toBeUndefined(); + }); + + it('is a no-op when only one item remains', () => { + const { state, removeItem } = setup(); + // Remove until one left + while (state.value.items.length > 1) { + removeItem(state.value.items[0].id); + } + const lastId = state.value.items[0].id; + removeItem(lastId); + expect(state.value.items).toHaveLength(1); + }); + }); + + describe('moveItemUp()', () => { + it('swaps the item at index N with the one at index N-1', () => { + const { state, moveItemUp } = setup(); + const [firstId, secondId] = state.value.items.map(i => i.id); + moveItemUp(secondId); + expect(state.value.items[0].id).toBe(secondId); + expect(state.value.items[1].id).toBe(firstId); + }); + + it('is a no-op when the item is already at the top', () => { + const { state, moveItemUp } = setup(); + const firstId = state.value.items[0].id; + moveItemUp(firstId); + expect(state.value.items[0].id).toBe(firstId); + }); + }); + + describe('moveItemDown()', () => { + it('swaps the item at index N with the one at index N+1', () => { + const { state, moveItemDown } = setup(); + const [firstId, secondId] = state.value.items.map(i => i.id); + moveItemDown(firstId); + expect(state.value.items[0].id).toBe(secondId); + expect(state.value.items[1].id).toBe(firstId); + }); + + it('is a no-op when the item is already at the bottom', () => { + const { state, moveItemDown } = setup(); + const lastId = state.value.items[state.value.items.length - 1].id; + moveItemDown(lastId); + expect(state.value.items[state.value.items.length - 1].id).toBe(lastId); + }); + }); + + describe('setItemContent()', () => { + it('updates only the targeted item content', () => { + const { state, setItemContent } = setup(); + const targetId = state.value.items[1].id; + setItemContent(targetId, '

Updated

'); + expect(state.value.items[1].content).toBe('

Updated

'); + // Other items untouched + expect(state.value.items[0].content).toBe(state.value.items[0].content); + }); + }); + + describe('runValidation()', () => { + it('populates errors for an invalid state', () => { + const { runValidation, errors, setPrompt } = setup(); + setPrompt(''); + runValidation(); + expect(errors.value.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js new file mode 100644 index 0000000000..9590207c3b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js @@ -0,0 +1,68 @@ +import { readonly } from 'vue'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { orderingInteractionDescriptor } from '../interactions/ordering/OrderingInteractionDescriptor'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the ordering interaction editor. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useOrderingInteraction(interactionBlock, questionType) { + const base = useInteraction(orderingInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + function addItem() { + state.value = { + ...state.value, + items: [...state.value.items, { id: generateRandomSlug('order'), content: '', fixed: false }], + }; + } + + function removeItem(id) { + if (state.value.items.length <= 1) return; + state.value = { + ...state.value, + items: state.value.items.filter(item => item.id !== id), + }; + } + + function moveItemUp(id) { + const items = [...state.value.items]; + const idx = items.findIndex(item => item.id === id); + if (idx <= 0) return; + [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]]; + state.value = { ...state.value, items }; + } + + function moveItemDown(id) { + const items = [...state.value.items]; + const idx = items.findIndex(item => item.id === id); + if (idx === -1 || idx >= items.length - 1) return; + [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]]; + state.value = { ...state.value, items }; + } + + function setItemContent(id, html) { + state.value = { + ...state.value, + items: state.value.items.map(item => (item.id === id ? { ...item, content: html } : item)), + }; + } + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + return { + ...base, + state: readonly(state), + addItem, + removeItem, + moveItemUp, + moveItemDown, + setItemContent, + setPrompt, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index bf03debb10..3fa2bcd6a4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -80,6 +80,7 @@ export const QuestionType = Object.freeze({ NUMERIC: 'numeric', TEXT_ENTRY: 'textEntry', FREE_RESPONSE: 'freeResponse', + ORDERING: 'ordering', }); /** @@ -96,6 +97,7 @@ export const ValidationError = Object.freeze({ INVALID_NUMERIC_VALUE: 'INVALID_NUMERIC_VALUE', EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT', DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT', + TOO_FEW_CHOICES: 'TOO_FEW_CHOICES', }); export const RESPONSE_IDENTIFIER = 'RESPONSE'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 0260e0fbfc..107a549a6b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -1,6 +1,7 @@ import { QtiInteraction } from '../constants'; import choiceDescriptor from './choice/index'; import textEntryDescriptor from './textEntry/index'; +import orderingDescriptor from './ordering/index'; /** * The default interaction type used as fallback when no descriptor matches @@ -12,7 +13,7 @@ export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; * Ordered list of all registered interaction descriptors. * Searched in order; the first whose `matches(el)` returns true wins. */ -export const descriptors = [choiceDescriptor, textEntryDescriptor]; +export const descriptors = [choiceDescriptor, textEntryDescriptor, orderingDescriptor]; /** * Registry map keyed by descriptor.type for O(1) direct lookup. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js new file mode 100644 index 0000000000..d6f281780d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionDescriptor.js @@ -0,0 +1,85 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse'; +import { validateOrderingInteraction } from './validate'; + +/** + * Owns all ordering-specific interaction logic: schema, parse, buildXML, and validate. + */ +export class OrderingInteractionDescriptor { + constructor({ editorComponent = null } = {}) { + this.type = QtiInteraction.ORDER; + this.placement = 'block'; + this.questionTypes = [QuestionType.ORDERING]; + this.editorComponent = editorComponent; + this.convertsFrom = []; + } + + getTypeOptions(tr) { + return [ + { + value: QuestionType.ORDERING, + label: tr.orderingLabel$(), + description: tr.orderingDescription$(), + }, + ]; + } + + /** @param {Element} el */ + matches(el) { + return el.tagName.toLowerCase() === QtiInteraction.ORDER; + } + + /** + * Ordering always has exactly one question type. + * + * @returns {string} + */ + getQuestionType() { + return QuestionType.ORDERING; + } + + /** + * @returns {{ baseType: string, cardinality: string }} + */ + getResponseDeclarationSchema() { + return { + baseType: BaseType.IDENTIFIER, + cardinality: Cardinality.ORDERED, + }; + } + + /** + * Parse body XML + response declarations → OrderingState. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {object} OrderingState + */ + parse(bodyXml, responseDeclarations) { + return parseOrderingInteraction(bodyXml, responseDeclarations); + } + + /** + * Serialize OrderingState → { bodyXml, responseDeclarations }. + * + * @param {object} state - OrderingState + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ + buildXML(state, questionType) { + return buildOrderingInteractionXML(state, questionType, this.getResponseDeclarationSchema()); + } + + /** + * Validate OrderingState → ValidationError[]. + * + * @param {object} state - OrderingState + * @returns {Array<{ code: string, id?: string }>} + */ + validate(state) { + return validateOrderingInteraction(state); + } +} + +/** Singleton — safe to import from any file in the ordering module tree. */ +export const orderingInteractionDescriptor = new OrderingInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue new file mode 100644 index 0000000000..efa58d3170 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue @@ -0,0 +1,596 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js new file mode 100644 index 0000000000..9018bcbada --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js @@ -0,0 +1,240 @@ +import { render, screen, fireEvent } from '@testing-library/vue'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import OrderingInteractionEditor from '../OrderingInteractionEditor.vue'; + +import { + ORDERING_XML, + ORDERING_DECL_XML, + mockInteractionBlock as block, + mockInteractionBlockWithDecl as blockWithDecl, +} from '../../../utils/testingFixtures'; +import { QuestionType } from '../../../constants'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + +const renderEditor = (props = {}) => + render(OrderingInteractionEditor, { + props: { mode: 'edit', ...props }, + routes: new VueRouter(), + }); + +describe('OrderingInteractionEditor', () => { + describe('edit mode rendering', () => { + it('renders the prompt text from the XML', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + // TipTapEditor mock renders `value` as-is in a

; use partial text match. + expect(screen.getByText(/Arrange the planets/)).toBeInTheDocument(); + }); + + it('renders a numbered position badge for each item', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + // Items fixture has 3 items — badges "1", "2", "3" + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('renders item content text for each item', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + expect(screen.getByText('Mercury')).toBeInTheDocument(); + expect(screen.getByText('Venus')).toBeInTheDocument(); + expect(screen.getByText('Earth')).toBeInTheDocument(); + }); + + it('renders the correct order header', () => { + renderEditor({ + interaction: block(ORDERING_XML), + questionType: QuestionType.ORDERING, + }); + expect(screen.getByText(tr.$tr('correctOrderLabel'))).toBeInTheDocument(); + }); + + it('renders the "Learners will see these shuffled" description', () => { + renderEditor({ + interaction: block(ORDERING_XML), + questionType: QuestionType.ORDERING, + }); + expect(screen.getByText(tr.$tr('correctOrderDescription'))).toBeInTheDocument(); + }); + + it('renders the Add option button', () => { + renderEditor({ + interaction: block(ORDERING_XML), + questionType: QuestionType.ORDERING, + }); + expect(screen.getByRole('button', { name: tr.$tr('addItemBtn') })).toBeInTheDocument(); + }); + + it('adds a new item row when Add option is clicked', async () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') })); + // 3 original + 1 new = position badge "4" + expect(screen.getByText('4')).toBeInTheDocument(); + }); + + it('disables move-up button for the first item', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + const moveUpBtns = screen.getAllByRole('button', { + name: name => name.includes('up'), + }); + expect(moveUpBtns[0]).toBeDisabled(); + expect(moveUpBtns[1]).toBeEnabled(); + }); + + it('disables move-down button for the last item', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + const moveDownBtns = screen.getAllByRole('button', { + name: name => name.includes('down'), + }); + expect(moveDownBtns[moveDownBtns.length - 1]).toBeDisabled(); + expect(moveDownBtns[0]).toBeEnabled(); + }); + + it('disables delete button when only one item remains', async () => { + const singleItemXml = ` + Mercury + `; + renderEditor({ + interaction: block(singleItemXml), + questionType: QuestionType.ORDERING, + }); + const deleteBtns = screen.getAllByRole('button', { name: name => name.includes('Delete') }); + expect(deleteBtns[0]).toBeDisabled(); + }); + }); + + describe('view mode', () => { + it('hides items when mode=view and showAnswers=false', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + mode: 'view', + showAnswers: false, + }); + expect(screen.queryByText('Mercury')).not.toBeInTheDocument(); + }); + + it('shows items in correct order when mode=view and showAnswers=true', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + mode: 'view', + showAnswers: true, + }); + expect(screen.getByText('Mercury')).toBeInTheDocument(); + expect(screen.getByText('Venus')).toBeInTheDocument(); + expect(screen.getByText('Earth')).toBeInTheDocument(); + }); + + it('hides the Add option button in view mode', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + mode: 'view', + showAnswers: true, + }); + expect(screen.queryByRole('button', { name: tr.$tr('addItemBtn') })).not.toBeInTheDocument(); + }); + + it('hides move/delete buttons in view mode', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + mode: 'view', + showAnswers: true, + }); + expect( + screen.queryByRole('button', { name: name => name.includes('Delete') }), + ).not.toBeInTheDocument(); + }); + }); + + describe('emits', () => { + it('emits update:interaction on initial mount in edit mode', () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + expect(emitted()['update:interaction']).toBeTruthy(); + const payload = emitted()['update:interaction'][0][0]; + expect(typeof payload.bodyXml).toBe('string'); + expect(Array.isArray(payload.responseDeclarations)).toBe(true); + }); + + it('emits update:interaction after adding an item', async () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + const before = emitted()['update:interaction'].length; + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') })); + expect(emitted()['update:interaction'].length).toBeGreaterThan(before); + }); + + it('does not emit update:interaction in view mode', async () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + mode: 'view', + showAnswers: true, + }); + // Clear mount-time emissions — none should fire in view mode + expect(emitted()['update:interaction']).toBeFalsy(); + }); + }); + + describe('validation', () => { + it('does not show errors before any field is touched', () => { + renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows errors after runValidation is triggered by state mutation', async () => { + jest.useFakeTimers(); + renderEditor({ + interaction: block(''), + questionType: QuestionType.ORDERING, + }); + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addItemBtn') })); + await nextTick(); + jest.advanceTimersByTime(400); + await nextTick(); + jest.useRealTimers(); + // Prompt is empty → should show prompt required error + expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); + }); + }); + + describe('graceful fallback', () => { + it('renders default state when bodyXml is empty', () => { + renderEditor({ interaction: block(''), questionType: QuestionType.ORDERING }); + // Default state now seeds 0 items + expect(screen.queryByText('1')).not.toBeInTheDocument(); + // Should show the 'Add option' button + expect(screen.getByRole('button', { name: 'Add option' })).toBeInTheDocument(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js new file mode 100644 index 0000000000..bab4513620 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/parse.spec.js @@ -0,0 +1,225 @@ +/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ +// The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml'). + +import { orderingInteractionDescriptor } from '../OrderingInteractionDescriptor'; +import { _defaultState } from '../parse'; +import { ORDERING_XML, ORDERING_DECL_XML } from '../../../utils/testingFixtures'; +import { QuestionType, Orientation } from '../../../constants'; + +const parse = orderingInteractionDescriptor.parse.bind(orderingInteractionDescriptor); +const buildXML = orderingInteractionDescriptor.buildXML.bind(orderingInteractionDescriptor); + +function parseXmlString(xml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'text/xml'); + const err = doc.querySelector('parseerror, parsererror'); + if (err) throw new Error(`Invalid XML: ${err.textContent}`); + return doc.documentElement; +} + +describe('_defaultState()', () => { + it('seeds items with an empty array', () => { + const state = _defaultState(); + expect(state.items).toHaveLength(0); + }); + + it('sets shuffle to true by default', () => { + expect(_defaultState().shuffle).toBe(true); + }); + + it('sets orientation to vertical by default', () => { + expect(_defaultState().orientation).toBe(Orientation.VERTICAL); + }); +}); + +describe('parse()', () => { + describe('attribute defaults', () => { + it('returns _defaultState() when bodyXml is empty', () => { + const state = parse('', []); + expect(state.items).toHaveLength(0); + }); + + it('returns _defaultState() when bodyXml is invalid XML', () => { + const state = parse(' { + const xml = ` + A + `; + expect(parse(xml, []).orientation).toBe(Orientation.VERTICAL); + }); + + it('defaults shuffle to false when attribute is absent', () => { + const xml = ` + A + `; + expect(parse(xml, []).shuffle).toBe(false); + }); + + it('defaults prompt to empty string when is absent', () => { + const xml = ` + A + `; + expect(parse(xml, []).prompt).toBe(''); + }); + }); + + describe('attribute reading', () => { + it('reads orientation="horizontal"', () => { + const xml = ` + A + `; + expect(parse(xml, []).orientation).toBe('horizontal'); + }); + + it('reads shuffle="true"', () => { + const state = parse(ORDERING_XML, []); + expect(state.shuffle).toBe(true); + }); + + it('reads the prompt HTML', () => { + const state = parse(ORDERING_XML, []); + expect(state.prompt).toContain('Arrange the planets'); + }); + }); + + describe('items parsing', () => { + it('parses items from elements', () => { + const state = parse(ORDERING_XML, []); + expect(state.items).toHaveLength(3); + }); + + it('assigns a generated order_ slug to items without an identifier', () => { + const xml = ` + No ID + `; + const state = parse(xml, []); + expect(state.items[0].id).toMatch(/^order_/); + }); + + it('reads the fixed attribute', () => { + const xml = ` + A + `; + expect(parse(xml, []).items[0].fixed).toBe(true); + }); + + it('reorders items to match the correct-response declaration sequence', () => { + const xml = ` + Mercury + Venus + Earth + `; + const decl = ` + + order_ccc33333 + order_aaa11111 + order_bbb22222 + + `; + const state = parse(xml, [decl]); + expect(state.items.map(i => i.id)).toEqual([ + 'order_ccc33333', + 'order_aaa11111', + 'order_bbb22222', + ]); + }); + + it('does not reorder when no declaration is present', () => { + const state = parse(ORDERING_XML, []); + expect(state.items.map(i => i.id)).toEqual([ + 'order_aaa11111', + 'order_bbb22222', + 'order_ccc33333', + ]); + }); + }); +}); + +describe('buildXML()', () => { + const baseState = { + responseIdentifier: 'RESPONSE', + prompt: 'Order these planets.', + items: [ + { id: 'order_aaa11111', content: 'Mercury', fixed: false }, + { id: 'order_bbb22222', content: 'Venus', fixed: false }, + { id: 'order_ccc33333', content: 'Earth', fixed: false }, + ], + orientation: Orientation.VERTICAL, + shuffle: true, + }; + + it('emits orientation attribute', () => { + const { bodyXml } = buildXML(baseState, QuestionType.ORDERING); + const root = parseXmlString(bodyXml); + expect(root.getAttribute('orientation')).toBe('vertical'); + }); + + it('emits shuffle="true"', () => { + const { bodyXml } = buildXML(baseState, QuestionType.ORDERING); + const root = parseXmlString(bodyXml); + expect(root.getAttribute('shuffle')).toBe('true'); + }); + + it('emits shuffle="false" when state.shuffle is false', () => { + const { bodyXml } = buildXML({ ...baseState, shuffle: false }, QuestionType.ORDERING); + const root = parseXmlString(bodyXml); + expect(root.getAttribute('shuffle')).toBe('false'); + }); + + it('emits for each item in state.items order', () => { + const { bodyXml } = buildXML(baseState, QuestionType.ORDERING); + const root = parseXmlString(bodyXml); + const choices = root.querySelectorAll('qti-simple-choice'); + expect(choices).toHaveLength(3); + expect(choices[0].getAttribute('identifier')).toBe('order_aaa11111'); + expect(choices[1].getAttribute('identifier')).toBe('order_bbb22222'); + expect(choices[2].getAttribute('identifier')).toBe('order_ccc33333'); + }); + + it('emits identifiers in state.items order inside ', () => { + const { responseDeclarations } = buildXML(baseState, QuestionType.ORDERING); + const decl = parseXmlString(responseDeclarations[0]); + const values = [...decl.querySelectorAll('qti-value')].map(n => n.textContent.trim()); + expect(values).toEqual(['order_aaa11111', 'order_bbb22222', 'order_ccc33333']); + }); + + it('sets cardinality="ordered" on the declaration', () => { + const { responseDeclarations } = buildXML(baseState, QuestionType.ORDERING); + const decl = parseXmlString(responseDeclarations[0]); + expect(decl.getAttribute('cardinality')).toBe('ordered'); + }); + + it('sets base-type="identifier" on the declaration', () => { + const { responseDeclarations } = buildXML(baseState, QuestionType.ORDERING); + const decl = parseXmlString(responseDeclarations[0]); + expect(decl.getAttribute('base-type')).toBe('identifier'); + }); + + it('omits when prompt is empty', () => { + const { bodyXml } = buildXML({ ...baseState, prompt: '' }, QuestionType.ORDERING); + const root = parseXmlString(bodyXml); + expect(root.querySelector('qti-prompt')).toBeNull(); + }); + + it('omits when items array is empty', () => { + const { responseDeclarations } = buildXML({ ...baseState, items: [] }, QuestionType.ORDERING); + const decl = parseXmlString(responseDeclarations[0]); + expect(decl.querySelector('qti-correct-response')).toBeNull(); + }); +}); + +describe('parse → buildXML → parse round-trip', () => { + it('re-parsed state matches original for a full ordering XML', () => { + const original = parse(ORDERING_XML, [ORDERING_DECL_XML]); + const { bodyXml, responseDeclarations } = buildXML(original, QuestionType.ORDERING); + const reparsed = parse(bodyXml, responseDeclarations); + + expect(reparsed.orientation).toBe(original.orientation); + expect(reparsed.shuffle).toBe(original.shuffle); + expect(reparsed.items.map(i => i.id)).toEqual(original.items.map(i => i.id)); + expect(reparsed.items.map(i => i.content)).toEqual(original.items.map(i => i.content)); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js new file mode 100644 index 0000000000..f480cd7fcd --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/validate.spec.js @@ -0,0 +1,130 @@ +import { validateOrderingInteraction } from '../validate'; +import { ValidationError, Orientation } from '../../../constants'; + +function makeItem(overrides = {}) { + return { id: 'order_aaa11111', content: 'Mercury', fixed: false, ...overrides }; +} + +function makeState(overrides = {}) { + return { + prompt: 'Order the planets.', + items: [ + makeItem({ id: 'order_aaa11111', content: 'Mercury' }), + makeItem({ id: 'order_bbb22222', content: 'Venus' }), + ], + orientation: Orientation.VERTICAL, + shuffle: true, + ...overrides, + }; +} + +const errorCodes = errors => errors.map(e => e.code); + +describe('validateOrderingInteraction()', () => { + it('returns an empty array for a valid state', () => { + expect(validateOrderingInteraction(makeState())).toEqual([]); + }); + + describe('PROMPT_REQUIRED', () => { + it('returns error when prompt is empty', () => { + expect(errorCodes(validateOrderingInteraction(makeState({ prompt: '' })))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('returns error when prompt is whitespace only', () => { + expect(errorCodes(validateOrderingInteraction(makeState({ prompt: ' ' })))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('returns error when prompt is tags-only with no visible text', () => { + expect(errorCodes(validateOrderingInteraction(makeState({ prompt: '

' })))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('does not return error when prompt has visible text', () => { + expect( + errorCodes(validateOrderingInteraction(makeState({ prompt: '

Arrange these.

' }))), + ).not.toContain(ValidationError.PROMPT_REQUIRED); + }); + }); + + describe('TOO_FEW_CHOICES', () => { + it('returns error when fewer than 2 items', () => { + const state = makeState({ items: [makeItem()] }); + expect(errorCodes(validateOrderingInteraction(state))).toContain( + ValidationError.TOO_FEW_CHOICES, + ); + }); + + it('returns error when items list is empty', () => { + const state = makeState({ items: [] }); + expect(errorCodes(validateOrderingInteraction(state))).toContain( + ValidationError.TOO_FEW_CHOICES, + ); + }); + + it('does not return error with 2 or more items', () => { + expect(errorCodes(validateOrderingInteraction(makeState()))).not.toContain( + ValidationError.TOO_FEW_CHOICES, + ); + }); + }); + + describe('EMPTY_CHOICE_CONTENT', () => { + it('returns error for each item with empty content', () => { + const state = makeState({ + items: [makeItem({ id: 'a', content: '' }), makeItem({ id: 'b', content: ' ' })], + }); + const errors = validateOrderingInteraction(state).filter( + e => e.code === ValidationError.EMPTY_CHOICE_CONTENT, + ); + expect(errors).toHaveLength(2); + expect(errors.map(e => e.id)).toContain('a'); + expect(errors.map(e => e.id)).toContain('b'); + }); + + it('does not flag items with content wrapped in HTML tags', () => { + const state = makeState({ + items: [ + makeItem({ id: 'a', content: 'Mercury' }), + makeItem({ id: 'b', content: 'Venus' }), + ], + }); + expect(errorCodes(validateOrderingInteraction(state))).not.toContain( + ValidationError.EMPTY_CHOICE_CONTENT, + ); + }); + }); + + describe('DUPLICATE_CHOICE_CONTENT', () => { + it('flags all items with identical normalised text content', () => { + const state = makeState({ + items: [ + makeItem({ id: 'a', content: 'Mercury' }), + makeItem({ id: 'b', content: 'Mercury' }), + makeItem({ id: 'c', content: ' Mercury ' }), + makeItem({ id: 'd', content: '

Mercury

' }), + makeItem({ id: 'e', content: 'Venus' }), + ], + }); + const errors = validateOrderingInteraction(state).filter( + e => e.code === ValidationError.DUPLICATE_CHOICE_CONTENT, + ); + const ids = errors.map(e => e.id); + expect(ids).toContain('a'); + expect(ids).toContain('b'); + expect(ids).toContain('c'); + expect(ids).toContain('d'); + expect(ids).not.toContain('e'); + }); + + it('does not return error for unique content', () => { + expect(errorCodes(validateOrderingInteraction(makeState()))).not.toContain( + ValidationError.DUPLICATE_CHOICE_CONTENT, + ); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js new file mode 100644 index 0000000000..2a16ab7fcc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/index.js @@ -0,0 +1,5 @@ +import defineInteraction from '../defineInteraction'; +import OrderingInteractionEditor from './OrderingInteractionEditor.vue'; +import { orderingInteractionDescriptor } from './OrderingInteractionDescriptor'; + +export default defineInteraction(orderingInteractionDescriptor, OrderingInteractionEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js new file mode 100644 index 0000000000..7177d88835 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js @@ -0,0 +1,161 @@ +import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; +import { getPromptHTML, parseXML } from '../../serialization/parseItem'; +import { buildXmlNode } from '../../serialization/assembleItem'; +import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; +import { Orientation, RESPONSE_IDENTIFIER } from '../../constants'; + +/** + * @typedef {object} OrderingItem + * @property {string} id - QTI identifier, e.g. "order_xlqTuVoq" + * @property {string} content - HTML content of the + * @property {boolean} fixed - Whether this item is fixed in place + * (round-trip only; not editable in UI) + */ + +/** + * @typedef {object} OrderingState + * @property {string} responseIdentifier - Response identifier attribute + * @property {string} prompt - HTML content of ; default "" + * @property {OrderingItem[]} items - Items in the CORRECT order + * @property {string} orientation - From orientation attribute; default "vertical" + * @property {boolean} shuffle - From shuffle attribute; + * default true for new items + */ + +const serializer = new XMLSerializer(); + +export function _defaultState() { + return { + responseIdentifier: RESPONSE_IDENTIFIER, + prompt: '', + items: [], + orientation: Orientation.VERTICAL, + shuffle: true, + }; +} + +/** + * Extract the ordered list of correct identifiers from a response declaration string. + * Returns an array (ordered) rather than a Set. + * + * @param {string[]} declarations + * @returns {string[]} + */ +export function _extractOrderedCorrectIds(declarations) { + const [declXml] = declarations || []; + if (!declXml) return []; + + try { + const declEl = parseXML(declXml).documentElement; + const declaration = QTIDeclaration.fromXML(declEl); + const correct = declaration.correctResponse; + return correct ? [...correct] : []; + } catch { + return []; + } +} + +/** + * Parse body XML + response declarations → OrderingState. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {object} OrderingState + */ +export function parseOrderingInteraction(bodyXml, responseDeclarations) { + if (!bodyXml) return _defaultState(); + + let root; + try { + root = parseXML(bodyXml).documentElement; + } catch { + return _defaultState(); + } + + const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER; + const orientation = root.getAttribute('orientation') ?? Orientation.VERTICAL; + const shuffle = root.getAttribute('shuffle') === 'true'; + const prompt = getPromptHTML(root); + + const rawItems = [...root.querySelectorAll('qti-simple-choice')].map(el => ({ + id: el.getAttribute('identifier') || generateRandomSlug('order'), + content: el.innerHTML, + fixed: el.getAttribute('fixed') === 'true', + })); + + const correctOrder = _extractOrderedCorrectIds(responseDeclarations); + + let items; + if (correctOrder.length > 0) { + const itemById = Object.fromEntries(rawItems.map(item => [item.id, item])); + const ordered = correctOrder.map(id => itemById[id]).filter(Boolean); + const declaredIds = new Set(correctOrder); + const remainder = rawItems.filter(item => !declaredIds.has(item.id)); + items = [...ordered, ...remainder]; + } else { + items = rawItems; + } + + return { + responseIdentifier, + prompt, + items, + orientation, + shuffle, + }; +} + +/** + * Serialize OrderingState → { bodyXml, responseDeclarations }. + * + * @param {object} state - OrderingState + * @param {string} _questionType - unused (ordering has only one question type); kept for API parity + * @param {object} declarationSchema - { baseType: string, cardinality: string } + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ +export function buildOrderingInteractionXML(state, _questionType, declarationSchema) { + const { responseIdentifier = RESPONSE_IDENTIFIER, prompt, items, orientation, shuffle } = state; + + const attrs = { + 'response-identifier': responseIdentifier, + orientation, + shuffle: String(shuffle), + }; + + const children = []; + + if (prompt) { + children.push(buildXmlNode({ tag: 'qti-prompt', innerHTML: prompt })); + } + + for (const item of items) { + const itemAttrs = { identifier: item.id }; + if (item.fixed) itemAttrs.fixed = 'true'; + children.push( + buildXmlNode({ + tag: 'qti-simple-choice', + attrs: itemAttrs, + innerHTML: item.content, + }), + ); + } + + const interactionEl = buildXmlNode({ tag: 'qti-order-interaction', attrs, children }); + const bodyXml = serializer.serializeToString(interactionEl); + + const { cardinality, baseType } = declarationSchema; + const declaration = new QTIDeclaration({ + identifier: responseIdentifier, + baseType, + cardinality, + tag: 'qti-response-declaration', + }); + const correctIds = items.map(item => item.id); + if (correctIds.length > 0) { + new CorrectResponse(correctIds, declaration); + } + + const declarationXml = serializer.serializeToString(declaration.getXML()); + return { bodyXml, responseDeclarations: [declarationXml] }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js new file mode 100644 index 0000000000..3b9b3df163 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/validate.js @@ -0,0 +1,42 @@ +import { ValidationError } from '../../constants'; +import { stripTags } from '../../utils/stripTags'; + +/** + * Validate OrderingState → ValidationError[]. + * + * @param {object} state - OrderingState + * @returns {Array<{ code: string, id?: string }>} + */ +export function validateOrderingInteraction(state) { + const errors = []; + const { prompt, items } = state; + + if (!stripTags(prompt).trim()) { + errors.push({ code: ValidationError.PROMPT_REQUIRED }); + } + + if (items.length < 2) { + errors.push({ code: ValidationError.TOO_FEW_CHOICES }); + } + + const firstSeenId = new Map(); + const duplicateIds = new Set(); + + for (const item of items) { + const textContent = stripTags(item.content).trim(); + if (!textContent) { + errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: item.id }); + } else if (firstSeenId.has(textContent)) { + duplicateIds.add(firstSeenId.get(textContent)); + duplicateIds.add(item.id); + } else { + firstSeenId.set(textContent, item.id); + } + } + + for (const duplicateId of duplicateIds) { + errors.push({ code: ValidationError.DUPLICATE_CHOICE_CONTENT, id: duplicateId }); + } + + return errors; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index b7d4a8f6c7..68f03e3de9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -57,6 +57,51 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Order', context: 'Display name for an order question type', }, + orderingLabel: { + message: 'Ordering', + context: 'Display name for an ordering question type shown in the question type selector', + }, + orderingDescription: { + message: 'Learners must arrange items into the correct order.', + context: 'Description for the ordering question type in the info modal', + }, + correctOrderLabel: { + message: 'Correct order', + context: 'Section header above the ordering item list — items are shown in the correct order', + }, + correctOrderDescription: { + message: 'Learners will see these shuffled', + context: + 'Subtitle under the correct order header explaining that items will be shuffled for learners', + }, + addItemBtn: { + message: 'Add option', + context: 'Button that appends a new ordering item', + }, + deleteItemBtn: { + message: 'Delete option {number}', + context: 'Accessible label for the delete icon button next to an ordering item row', + }, + moveItemUpBtn: { + message: 'Move option {number} up', + context: 'Accessible label for the move-up icon button next to an ordering item row', + }, + moveItemDownBtn: { + message: 'Move option {number} down', + context: 'Accessible label for the move-down icon button next to an ordering item row', + }, + errorTooFewChoices: { + message: 'At least 2 items are required for an ordering question.', + context: 'Validation error when fewer than 2 ordering items are present', + }, + errorEmptyItemContent: { + message: 'Item cannot be blank', + context: 'Validation error when an ordering item has no content', + }, + errorDuplicateItemContent: { + message: 'Duplicate items are not allowed', + context: 'Validation error when two or more ordering items have identical content', + }, matchLabel: { message: 'Match', context: 'Display name for a match question type', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index 2bd903afbc..24c12a77c2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -46,6 +46,21 @@ export const CHOICE_MULTI_DECL_XML = ` +

Arrange the planets in order from closest to farthest from the Sun.

+ Mercury + Venus + Earth +
`; + +export const ORDERING_DECL_XML = ` + + order_aaa11111 + order_bbb22222 + order_ccc33333 + +`; + // Full QTI Assessment Item XML Documents export const VALID_CHOICE_ITEM_DOCUMENT = `