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 4e84bf71cb..1404d9753f 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 @@ -12,6 +12,7 @@ import { NO_INTERACTION_ITEM_DOCUMENT, CHOICE_ITEM_DOCUMENT_WITH_HINTS, NO_INTERACTION_ITEM_WITH_HINTS, + VALID_ASSOCIATE_ITEM_DOCUMENT, } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); @@ -29,6 +30,9 @@ const { unsupportedItemMessage$, incompleteItemIndicatorLabel$, hintsLabel$, + associateLabel$, + unknownTypeLabel$, + responsePoolLabel$, } = qtiEditorStrings; const defaultProps = { @@ -291,6 +295,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(associateLabel$(), { exact: false })).toBeInTheDocument(); + expect(screen.queryByText(unknownTypeLabel$(), { exact: false })).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 3ec1e5a803..8c36a87748 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -182,6 +182,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..697bbf07c9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js @@ -0,0 +1,122 @@ +import { ref } from 'vue'; +import { useAssociateInteraction } from '../useAssociateInteraction'; +import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../utils/testingFixtures'; +import { parseXML } from '../../serialization/xml'; +import { QuestionType } from '../../constants'; + +const GENERATED_ID = /^choice_[a-z0-9]{8}$/; + +const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content)); + +const maxAssociations = bodyXml => + parseXML(bodyXml).documentElement.getAttribute('max-associations'); + +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(contentsOf(state.value.pairs)).toEqual([ + ['Antonio', 'Prospero'], + ['Capulet', 'Montague'], + ['', ''], + ]); + const [first, second] = state.value.pairs[2]; + expect(first.id).toMatch(GENERATED_ID); + expect(second.id).toMatch(GENERATED_ID); + expect(first.id).not.toBe(second.id); + }); + + it('rebuilds bodyXml with the new max-associations', () => { + const { bodyXml, addPair } = setup(); + addPair(); + expect(maxAssociations(bodyXml.value)).toBe('3'); + }); + }); + + 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']]); + }); + + it('is a no-op when only one pair remains', () => { + const { state, removePair } = setup(); + removePair(0); + removePair(0); + expect(contentsOf(state.value.pairs)).toEqual([['Capulet', 'Montague']]); + }); + + it('drops the pair from the emitted correct response', () => { + const { responseDeclarations, removePair } = setup(); + removePair(0); + expect(responseDeclarations.value[0]).not.toContain('choice_aaa11111'); + }); + }); + + 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); + }); + + it('appends the given content when the distractor is written before it is added', () => { + const { state, addDistractor } = setup(); + addDistractor('

Demetrius

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

Demetrius

'); + 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

']); + }); + }); +}); 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..1b2223cd79 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js @@ -0,0 +1,74 @@ +import { readonly } from 'vue'; +import { associateInteractionDescriptor } from '../interactions/associate/Descriptor'; +import { newChoice } from '../interactions/associate/parse'; +import { useInteraction } from './useInteraction'; + +/** + * 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, [newChoice(), newChoice()]] }; + } + + function removePair(index) { + // An associate question is meaningless without a pair to associate. + if (state.value.pairs.length <= 1) return; + 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(content = '') { + state.value = { + ...state.value, + distractors: [...state.value.distractors, newChoice(content)], + }; + } + + 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 1ebd3f003e..22572b1467 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', }); @@ -82,6 +83,7 @@ export const QuestionType = Object.freeze({ TEXT_ENTRY: 'textEntry', FREE_RESPONSE: 'freeResponse', ORDERING: 'ordering', + ASSOCIATE: 'associate', }); /** @@ -103,6 +105,9 @@ 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', + DUPLICATE_DISTRACTOR_CONTENT: 'DUPLICATE_DISTRACTOR_CONTENT', }); export const RESPONSE_IDENTIFIER = 'RESPONSE'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js new file mode 100644 index 0000000000..2b4d103d84 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js @@ -0,0 +1,81 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { InteractionDescriptor } from '../InteractionDescriptor'; +import { parseAssociateInteraction, buildAssociateInteractionXML } from './parse'; +import { validateAssociateInteraction } from './validation'; + +/** + * Owns all associate-specific interaction logic: schema, parse, buildXML, and validate. + */ +export class AssociateInteractionDescriptor extends InteractionDescriptor { + constructor() { + super({ + type: QtiInteraction.ASSOCIATE, + questionTypes: [QuestionType.ASSOCIATE], + }); + this.convertsFrom = []; + } + + getTypeOptions(tr) { + return [ + { + value: QuestionType.ASSOCIATE, + label: tr.associateLabel$(), + description: tr.associateDescription$(), + }, + ]; + } + + /** + * 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/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue new file mode 100644 index 0000000000..1b7ad2c114 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue @@ -0,0 +1,1124 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js new file mode 100644 index 0000000000..b6eda3e34c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js @@ -0,0 +1,20 @@ +import { associateInteractionDescriptor as descriptor } from '../Descriptor'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; +import { QuestionType } from '../../../constants'; +import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../../utils/testingFixtures'; + +describe('AssociateInteractionDescriptor', () => { + 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__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js new file mode 100644 index 0000000000..c35d630b3d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js @@ -0,0 +1,603 @@ +import { render, screen, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import AssociateEditor from '../Editor.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'); + +let mockWindowIsLarge = true; +let mockWindowIsSmall = false; +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ + windowIsLarge: ref(mockWindowIsLarge), + windowIsSmall: ref(mockWindowIsSmall), + }), + }; +}); + +const POOL_CONTENTS = ['Antonio', 'Prospero', 'Capulet', 'Montague', 'Lysander']; + +// One pair whose second member has no content, so the pool carries a blank +// distractor the author still has to fill in. +const BLANK_DISTRACTOR_XML = ` +

Match each character to his adversary.

+ Antonio + Prospero + +
`; + +const ONE_PAIR_DECL_XML = ` + + choice_aaa11111 choice_bbb22222 + +`; + +// Antonio is paired twice, so its match-max is 2. +const SHARED_CHOICE_XML = ` +

Match each character to his adversary.

+ Antonio + Prospero + Capulet +
`; + +const SHARED_CHOICE_DECL_XML = ` + + choice_aaa11111 choice_bbb22222 + choice_aaa11111 choice_ccc33333 + +`; + +// The mock TipTapEditor renders a