diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index e1b2e27350..a3c831b10c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -215,6 +215,50 @@ export const ORDERING_ITEM_XML = ` `; +/** + * Demo item 7: associate interaction — learner connects countries to capitals. + * Uses cardinality="multiple" and base-type="pair" per QTI 3.0 §3.2.13. + */ +export const ASSOCIATE_ITEM_XML = ` + + + + choice_kenya choice_nairobi + choice_japan choice_tokyo + choice_brazil choice_brasilia + + + + + +

Match each country with its capital city:

+ Kenya + Nairobi + Japan + Tokyo + Brazil + Brasília + Mombasa + Osaka +
+
+
`; + /** * Hardcoded items covering different states: * - item-1: single-select choice interaction @@ -223,6 +267,7 @@ export const ORDERING_ITEM_XML = ` * - item-text-entry: string text-entry with case-sensitive answers * - item-free-response: free-response text-entry (no correct answer) * - item-ordering: ordering interaction (planets by distance from the Sun) + * - item-associate: associate interaction (countries to capitals, with distractors) */ export const INITIAL_ASSESSMENTS = [ { @@ -255,4 +300,9 @@ export const INITIAL_ASSESSMENTS = [ type: AssessmentItemTypes.QTI, raw_data: ORDERING_ITEM_XML, }, + { + assessment_id: 'demo-item-associate', + type: AssessmentItemTypes.QTI, + raw_data: ASSOCIATE_ITEM_XML, + }, ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 8a9d19fe02..9708a3397c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router'; import QTIItemEditor from '../index.vue'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { AssessmentItemTypes } from '../../../constants'; +import { VALID_ASSOCIATE_ITEM_DOCUMENT } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { @@ -13,7 +14,13 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); -const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; +const { + closeBtnLabel$, + questionContentPlaceholder$, + associateLabel$, + unknownTypeLabel$, + responsePoolLabel$, +} = qtiEditorStrings; const defaultProps = { item: { @@ -77,6 +84,29 @@ describe('QTIItemEditor', () => { }); }); + describe('associate interaction', () => { + const renderAssociateItem = () => + renderComponent({ + item: { + assessment_id: 'test-item-id', + type: AssessmentItemTypes.QTI, + raw_data: VALID_ASSOCIATE_ITEM_DOCUMENT, + }, + }); + + test('names the associate question type rather than falling back to unknown', async () => { + renderAssociateItem(); + expect(await screen.findByText(new RegExp(associateLabel$()))).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(unknownTypeLabel$()))).not.toBeInTheDocument(); + }); + + test('renders the associate editor for the parsed interaction', async () => { + renderAssociateItem(); + expect(await screen.findByText(responsePoolLabel$())).toBeInTheDocument(); + expect(screen.getByText('Antonio')).toBeInTheDocument(); + }); + }); + describe('toolbarActions slot', () => { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: '' }); 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 40711ad6f9..ee2f245975 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -132,6 +132,7 @@ [QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$, [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, [QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$, + [QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$, }; return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js new file mode 100644 index 0000000000..dc6ef2a073 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js @@ -0,0 +1,115 @@ +import { ref } from 'vue'; +import { useAssociateInteraction } from '../useAssociateInteraction'; +import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../utils/testingFixtures'; +import { QuestionType, ValidationError } from '../../constants'; + +const GENERATED_ID = /^choice_[a-zA-Z0-9]{8}$/; + +const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content)); + +describe('useAssociateInteraction', () => { + function setup(bodyXml = ASSOCIATE_XML, declarationXml = ASSOCIATE_DECL_XML) { + const questionType = ref(QuestionType.ASSOCIATE); + return useAssociateInteraction( + { bodyXml, responseDeclarations: [declarationXml] }, + questionType, + ); + } + + describe('initial state', () => { + it('parses pairs and distractors from the fixture XML', () => { + const { state } = setup(); + expect(contentsOf(state.value.pairs)).toEqual([ + ['Antonio', 'Prospero'], + ['Capulet', 'Montague'], + ]); + expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander']); + }); + }); + + describe('addPair()', () => { + it('appends a pair of two blank choices with distinct generated ids', () => { + const { state, addPair } = setup(); + addPair(); + expect(state.value.pairs).toHaveLength(3); + const [first, second] = state.value.pairs[2]; + expect(first.content).toBe(''); + expect(second.content).toBe(''); + expect(first.id).toMatch(GENERATED_ID); + expect(second.id).toMatch(GENERATED_ID); + expect(first.id).not.toBe(second.id); + }); + + it('leaves the existing pairs untouched', () => { + const { state, addPair } = setup(); + addPair(); + expect(contentsOf(state.value.pairs.slice(0, 2))).toEqual([ + ['Antonio', 'Prospero'], + ['Capulet', 'Montague'], + ]); + }); + + it('rebuilds bodyXml so the mutation reaches the emitted interaction', () => { + const { bodyXml, addPair } = setup(); + const before = bodyXml.value; + addPair(); + expect(bodyXml.value).not.toBe(before); + }); + }); + + describe('removePair()', () => { + it('drops the pair at the given index and keeps the rest in order', () => { + const { state, removePair } = setup(); + removePair(0); + expect(contentsOf(state.value.pairs)).toEqual([['Capulet', 'Montague']]); + }); + }); + + describe('setPair()', () => { + it('replaces only the pair at the given index', () => { + const { state, setPair } = setup(); + const [first, second] = state.value.pairs[0]; + setPair(0, [{ ...first, content: '

Updated

' }, second]); + expect(contentsOf(state.value.pairs)).toEqual([ + ['

Updated

', 'Prospero'], + ['Capulet', 'Montague'], + ]); + }); + }); + + describe('addDistractor()', () => { + it('appends one blank choice with a generated id', () => { + const { state, addDistractor } = setup(); + addDistractor(); + expect(state.value.distractors).toHaveLength(2); + expect(state.value.distractors[1].content).toBe(''); + expect(state.value.distractors[1].id).toMatch(GENERATED_ID); + }); + }); + + describe('removeDistractor()', () => { + it('drops the distractor at the given index', () => { + const { state, removeDistractor } = setup(); + removeDistractor(0); + expect(state.value.distractors).toEqual([]); + }); + }); + + describe('setDistractorContent()', () => { + it('updates only the targeted distractor', () => { + const { state, addDistractor, setDistractorContent } = setup(); + addDistractor(); + setDistractorContent(1, '

Updated

'); + expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander', '

Updated

']); + }); + }); + + describe('runValidation()', () => { + it('populates errors for an invalid state', () => { + const { setPrompt, runValidation, errors } = setup(); + setPrompt(''); + runValidation(); + expect(errors.value.map(e => e.code)).toContain(ValidationError.PROMPT_REQUIRED); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js new file mode 100644 index 0000000000..1e4903a92f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js @@ -0,0 +1,71 @@ +import { readonly } from 'vue'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { associateInteractionDescriptor } from '../interactions/associate/AssociateInteractionDescriptor'; +import { useInteraction } from './useInteraction'; + +const blankChoice = () => ({ id: generateRandomSlug('choice'), content: '' }); + +/** + * Composable for the associate interaction editor. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useAssociateInteraction(interactionBlock, questionType) { + const base = useInteraction(associateInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + function addPair() { + state.value = { ...state.value, pairs: [...state.value.pairs, [blankChoice(), blankChoice()]] }; + } + + function removePair(index) { + state.value = { + ...state.value, + pairs: state.value.pairs.filter((_, i) => i !== index), + }; + } + + function setPair(index, newPair) { + state.value = { + ...state.value, + pairs: state.value.pairs.map((pair, i) => (i === index ? newPair : pair)), + }; + } + + function addDistractor() { + state.value = { ...state.value, distractors: [...state.value.distractors, blankChoice()] }; + } + + function removeDistractor(index) { + state.value = { + ...state.value, + distractors: state.value.distractors.filter((_, i) => i !== index), + }; + } + + function setDistractorContent(index, html) { + state.value = { + ...state.value, + distractors: state.value.distractors.map((choice, i) => + i === index ? { ...choice, content: html } : choice, + ), + }; + } + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + return { + ...base, + state: readonly(state), + addPair, + removePair, + setPair, + addDistractor, + removeDistractor, + setDistractorContent, + setPrompt, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 3fa2bcd6a4..c668382b56 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -53,6 +53,7 @@ export const QtiInteraction = Object.freeze({ CHOICE: 'qti-choice-interaction', ORDER: 'qti-order-interaction', MATCH: 'qti-match-interaction', + ASSOCIATE: 'qti-associate-interaction', TEXT_ENTRY: 'qti-text-entry-interaction', EXTENDED_TEXT: 'qti-extended-text-interaction', }); @@ -81,6 +82,7 @@ export const QuestionType = Object.freeze({ TEXT_ENTRY: 'textEntry', FREE_RESPONSE: 'freeResponse', ORDERING: 'ordering', + ASSOCIATE: 'associate', }); /** @@ -98,6 +100,8 @@ export const ValidationError = Object.freeze({ EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT', DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT', TOO_FEW_CHOICES: 'TOO_FEW_CHOICES', + TOO_FEW_PAIRS: 'TOO_FEW_PAIRS', + DUPLICATE_PAIR_CONTENT: 'DUPLICATE_PAIR_CONTENT', }); export const RESPONSE_IDENTIFIER = 'RESPONSE'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js new file mode 100644 index 0000000000..bac3347bd3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js @@ -0,0 +1,85 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseAssociateInteraction, buildAssociateInteractionXML } from './parse'; +import { validateAssociateInteraction } from './validate'; + +/** + * Owns all associate-specific interaction logic: schema, parse, buildXML, and validate. + */ +export class AssociateInteractionDescriptor { + constructor({ editorComponent = null } = {}) { + this.type = QtiInteraction.ASSOCIATE; + this.placement = 'block'; + this.questionTypes = [QuestionType.ASSOCIATE]; + this.editorComponent = editorComponent; + this.convertsFrom = []; + } + + getTypeOptions(tr) { + return [ + { + value: QuestionType.ASSOCIATE, + label: tr.associateLabel$(), + description: tr.associateDescription$(), + }, + ]; + } + + /** @param {Element} el */ + matches(el) { + return el.tagName.toLowerCase() === QtiInteraction.ASSOCIATE; + } + + /** + * Associate always has exactly one question type. + * + * @returns {string} + */ + getQuestionType() { + return QuestionType.ASSOCIATE; + } + + /** + * @returns {{ baseType: string, cardinality: string }} + */ + getResponseDeclarationSchema() { + return { + baseType: BaseType.PAIR, + cardinality: Cardinality.MULTIPLE, + }; + } + + /** + * Parse body XML + response declarations → AssociateState. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {object} AssociateState + */ + parse(bodyXml, responseDeclarations) { + return parseAssociateInteraction(bodyXml, responseDeclarations); + } + + /** + * Serialize AssociateState → { bodyXml, responseDeclarations }. + * + * @param {object} state - AssociateState + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ + buildXML(state, questionType) { + return buildAssociateInteractionXML(state, questionType, this.getResponseDeclarationSchema()); + } + + /** + * Validate AssociateState → ValidationError[]. + * + * @param {object} state - AssociateState + * @returns {Array<{ code: string, id?: string, index?: number }>} + */ + validate(state) { + return validateAssociateInteraction(state); + } +} + +/** Singleton — safe to import from any file in the associate module tree. */ +export const associateInteractionDescriptor = new AssociateInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue new file mode 100644 index 0000000000..d6088435bb --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue @@ -0,0 +1,854 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js new file mode 100644 index 0000000000..292a81fdf4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js @@ -0,0 +1,49 @@ +import { associateInteractionDescriptor as descriptor } from '../AssociateInteractionDescriptor'; +import { parseXML } from '../../../serialization/parseItem'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; +import { QtiInteraction, QuestionType } from '../../../constants'; +import { + ASSOCIATE_XML, + ASSOCIATE_DECL_XML, + ORDERING_XML, + CHOICE_SINGLE_SELECT_XML, +} from '../../../utils/testingFixtures'; + +const elementOf = xml => parseXML(xml).documentElement; + +describe('AssociateInteractionDescriptor', () => { + it('declares the associate interaction tag, block placement, and question type', () => { + expect(descriptor.type).toBe(QtiInteraction.ASSOCIATE); + expect(descriptor.placement).toBe('block'); + expect(descriptor.questionTypes).toEqual([QuestionType.ASSOCIATE]); + }); + + describe('matches()', () => { + it('matches a element', () => { + expect(descriptor.matches(elementOf(ASSOCIATE_XML))).toBe(true); + }); + + it('does not match other interaction elements', () => { + expect(descriptor.matches(elementOf(ORDERING_XML))).toBe(false); + expect(descriptor.matches(elementOf(CHOICE_SINGLE_SELECT_XML))).toBe(false); + }); + }); + + it('getQuestionType() returns the associate question type', () => { + expect(descriptor.getQuestionType()).toBe(QuestionType.ASSOCIATE); + }); + + it('getTypeOptions() offers the associate question type to the type selector', () => { + const options = descriptor.getTypeOptions(qtiEditorStrings); + expect(options).toHaveLength(1); + expect(options[0].value).toBe(QuestionType.ASSOCIATE); + expect(options[0].label).toBe(qtiEditorStrings.$tr('associateLabel')); + }); + + it('buildXML() forwards its own declaration schema', () => { + const state = descriptor.parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]); + const [declXml] = descriptor.buildXML(state, QuestionType.ASSOCIATE).responseDeclarations; + expect(declXml).toContain('base-type="pair"'); + expect(declXml).toContain('cardinality="multiple"'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js new file mode 100644 index 0000000000..aa3631bab9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js @@ -0,0 +1,225 @@ +import { render, screen, within, fireEvent } from '@testing-library/vue'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import AssociateInteractionEditor from '../AssociateInteractionEditor.vue'; + +import { + ASSOCIATE_XML, + ASSOCIATE_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'); +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); + +const POOL_CONTENTS = ['Antonio', 'Prospero', 'Capulet', 'Montague', 'Lysander']; + +// The mock TipTapEditor renders a