Skip to content

Commit fdeef39

Browse files
refactor: replace incorrect choice-interaction fallback with explicit parse error handling in QTI editor
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 942cf81 commit fdeef39

21 files changed

Lines changed: 191 additions & 283 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@ const renderSection = (props = {}) =>
1717
routes: new VueRouter(),
1818
});
1919

20-
// ---------------------------------------------------------------------------
21-
// Tests
22-
// ---------------------------------------------------------------------------
23-
2420
describe('InteractionSection', () => {
2521
describe('choice interaction', () => {
2622
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
@@ -43,11 +39,9 @@ describe('InteractionSection', () => {
4339
});
4440

4541
describe('parse error handling', () => {
46-
it('gracefully falls back to default interaction state when XML is malformed', () => {
42+
it('shows a parse error when XML is malformed', () => {
4743
renderSection({ interaction: interactionBlock('not-xml<{{') });
48-
// It should render exactly 1 choice fallback element
49-
const inputs = screen.queryAllByRole('radio').concat(screen.queryAllByRole('checkbox'));
50-
expect(inputs).toHaveLength(1);
44+
expect(screen.getByText('This question could not be loaded')).toBeInTheDocument();
5145
});
5246
});
5347

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
6363
import { computed, ref, watch } from 'vue';
6464
import { qtiEditorStrings } from '../../qtiEditorStrings';
65+
import { QuestionType } from '../../constants';
6566
import useQtiItem from '../../composables/useQtiItem';
6667
import InteractionSection from '../InteractionSection/index.vue';
6768
@@ -119,9 +120,14 @@
119120
const interactionTypeLabel = computed(() => {
120121
const type = currentQuestionType.value;
121122
if (!type) return unknownTypeLabel$();
122-
// Dynamic access to the localized string method (e.g. 'singleSelectLabel$()')
123-
const methodKey = `${type}Label$`;
124-
return qtiEditorStrings[methodKey]?.() ?? unknownTypeLabel$();
123+
const QUESTION_TYPE_LABELS = {
124+
[QuestionType.SINGLE_SELECT]: qtiEditorStrings.singleSelectLabel$,
125+
[QuestionType.MULTI_SELECT]: qtiEditorStrings.multiSelectLabel$,
126+
[QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$,
127+
[QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$,
128+
[QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$,
129+
};
130+
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
125131
});
126132
127133
const questionNumberAndTypeLabel = computed(() =>

contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,11 @@ function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) {
3030
}
3131

3232
function setup(choices, questionType = QuestionType.SINGLE_SELECT) {
33-
const qt = ref(questionType);
33+
const questionTypeRef = ref(questionType);
3434
const block = makeBlock(choices, questionType);
35-
return { qt, ...useChoiceInteraction(block, qt) };
35+
return { questionTypeRef, ...useChoiceInteraction(block, questionTypeRef) };
3636
}
3737

38-
// ---------------------------------------------------------------------------
39-
// Tests
40-
// ---------------------------------------------------------------------------
41-
4238
describe('useChoiceInteraction', () => {
4339
describe('addChoice()', () => {
4440
it('appends a new choice to the list', () => {
@@ -118,33 +114,33 @@ describe('useChoiceInteraction', () => {
118114

119115
describe('toggleCorrectChoice()', () => {
120116
it('singleSelect: sets only the target as correct and clears others', () => {
121-
const { state, toggleCorrectChoice, qt } = setup([
117+
const { state, toggleCorrectChoice, questionTypeRef } = setup([
122118
makeAnswer({ id: 'a', correct: true }),
123119
makeAnswer({ id: 'b', correct: false }),
124120
]);
125-
qt.value = QuestionType.SINGLE_SELECT;
121+
questionTypeRef.value = QuestionType.SINGLE_SELECT;
126122
toggleCorrectChoice('b');
127123
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
128124
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
129125
});
130126

131127
it('multiSelect: toggles only the target, leaves others unchanged', () => {
132-
const { state, toggleCorrectChoice, qt } = setup(
128+
const { state, toggleCorrectChoice, questionTypeRef } = setup(
133129
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
134130
QuestionType.MULTI_SELECT,
135131
);
136-
qt.value = QuestionType.MULTI_SELECT;
132+
questionTypeRef.value = QuestionType.MULTI_SELECT;
137133
toggleCorrectChoice('b');
138134
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
139135
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true);
140136
});
141137

142138
it('multiSelect: toggles correct off when already correct', () => {
143-
const { state, toggleCorrectChoice, qt } = setup(
139+
const { state, toggleCorrectChoice, questionTypeRef } = setup(
144140
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
145141
QuestionType.MULTI_SELECT,
146142
);
147-
qt.value = QuestionType.MULTI_SELECT;
143+
questionTypeRef.value = QuestionType.MULTI_SELECT;
148144
toggleCorrectChoice('a');
149145
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
150146
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);

contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ jest.mock('lodash/debounce', () => {
99
});
1010
});
1111

12-
// ---------------------------------------------------------------------------
13-
// Minimal descriptor stub
14-
// ---------------------------------------------------------------------------
15-
1612
function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) {
1713
return {
1814
parse: jest.fn(() => parseReturn),
@@ -76,7 +72,6 @@ describe('useInteraction', () => {
7672
questionType,
7773
);
7874

79-
// Because of immediate: true and mocked debounce, validate runs immediately
8075
expect(errors.value).toEqual(validateReturn);
8176
});
8277

@@ -92,7 +87,6 @@ describe('useInteraction', () => {
9287

9388
expect(errors.value).toEqual([]);
9489

95-
// Change the mock to return something else to simulate state change
9690
descriptor.validate.mockReturnValueOnce([{ code: 'NEW_ERROR' }]);
9791
runValidation();
9892

@@ -163,15 +157,12 @@ describe('useInteraction', () => {
163157
questionType,
164158
);
165159

166-
// With mocked debounce, validate fires synchronously on immediate watcher.
167-
// errors are already populated from the initial watcher run.
168160
expect(errors.value).toEqual(validateReturn);
169161

170-
// Reset mock and update state — validate should be called again.
171162
descriptor.validate.mockReset();
172163
descriptor.validate.mockReturnValue([{ code: 'UPDATED_ERROR' }]);
173164
state.value = { prompt: 'updated' };
174-
await nextTick(); // flush Vue watcher queue
165+
await nextTick();
175166

176167
expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect');
177168
expect(errors.value).toEqual([{ code: 'UPDATED_ERROR' }]);

contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,6 @@ import {
1616

1717
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
1818

19-
// ---------------------------------------------------------------------------
20-
// Helper: renders a wrapper component that runs the composable inside setup().
21-
// Because questionType is now set on mount (not as a computed), we must wait
22-
// for the component to mount before checking reactive values.
23-
// ---------------------------------------------------------------------------
24-
2519
function renderDescriptor(initialXml = null, declarations = []) {
2620
const interactionRef = ref(
2721
initialXml ? { bodyXml: initialXml, responseDeclarations: declarations } : null,
@@ -40,10 +34,6 @@ function renderDescriptor(initialXml = null, declarations = []) {
4034
return { result, interactionRef };
4135
}
4236

43-
// ---------------------------------------------------------------------------
44-
// Tests
45-
// ---------------------------------------------------------------------------
46-
4737
describe('useInteractionDescriptor', () => {
4838
describe('with a valid choice interaction', () => {
4939
it('resolves the Choice descriptor by its type', async () => {
@@ -110,11 +100,10 @@ describe('useInteractionDescriptor', () => {
110100

111101
describe('with malformed XML', () => {
112102
it('returns a parse error for malformed XML', async () => {
113-
// inferFromXml uses text/xml which throws a parser error for malformed fragments.
114103
const { result } = renderDescriptor('<unclosed');
115104
await nextTick();
116105
expect(typeof result.parseError.value).toBe('string');
117-
expect(result.parseError.value).toMatch(/parse error/);
106+
expect(result.parseError.value).toBe('This question could not be loaded');
118107
});
119108

120109
it('still returns a defined fallback descriptor on parse error', async () => {
@@ -132,22 +121,14 @@ describe('useInteractionDescriptor', () => {
132121
expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT);
133122
expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE);
134123

135-
// Simulate user switching question type via the selector
136124
result.questionType.value = QuestionType.MULTI_SELECT;
137125
await nextTick();
138126

139-
// Still the same descriptor (choice handles both), but questionType changed
140127
expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE);
141128
expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT);
142129
});
143130
});
144131

145-
// ---------------------------------------------------------------------------
146-
// Regression: inline placement — bodyXml is a full <qti-item-body>
147-
// Before the fix, documentElement was <qti-item-body> and no descriptor
148-
// matched it, so the code fell back to the choice descriptor for every
149-
// text-entry item, showing them as "Multiple Choice / Single Choice".
150-
// ---------------------------------------------------------------------------
151132
describe('with an inline text-entry interaction (bodyXml is qti-item-body)', () => {
152133
it('resolves the TextEntry descriptor for a numeric declaration', async () => {
153134
const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_NUMERIC_DECL_XML]);
@@ -169,13 +150,5 @@ describe('useInteractionDescriptor', () => {
169150
expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY);
170151
expect(result.questionType.value).toBe(QuestionType.FREE_RESPONSE);
171152
});
172-
173-
it('does NOT resolve as choice when bodyXml is a qti-item-body (regression)', async () => {
174-
// This is the exact bug: without the fix every text-entry item was
175-
// treated as a choice interaction because documentElement was <qti-item-body>.
176-
const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_FREE_DECL_XML]);
177-
await nextTick();
178-
expect(result.descriptor.value.type).not.toBe(QtiInteraction.CHOICE);
179-
});
180153
});
181154
});

contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { ref } from 'vue';
22
import { useTextEntryInteraction } from '../useTextEntryInteraction';
33
import { QuestionType, ValidationError } from '../../constants';
44

5-
// ─── Helpers ───────────────────────────────────────────────────────────────
6-
75
function makeNumericBlock(answerValues = ['12']) {
86
const values = answerValues.map(v => `<qti-value>${v}</qti-value>`).join('');
97
const cardinality = answerValues.length > 1 ? 'multiple' : 'single';
@@ -21,17 +19,15 @@ function makeFreeBlock() {
2119
}
2220

2321
function setupNumeric(answerValues = ['12']) {
24-
const qt = ref(QuestionType.NUMERIC);
25-
return { qt, ...useTextEntryInteraction(makeNumericBlock(answerValues), qt) };
22+
const questionType = ref(QuestionType.NUMERIC);
23+
return { questionType, ...useTextEntryInteraction(makeNumericBlock(answerValues), questionType) };
2624
}
2725

2826
function setupFree() {
29-
const qt = ref(QuestionType.FREE_RESPONSE);
30-
return { qt, ...useTextEntryInteraction(makeFreeBlock(), qt) };
27+
const questionType = ref(QuestionType.FREE_RESPONSE);
28+
return { questionType, ...useTextEntryInteraction(makeFreeBlock(), questionType) };
3129
}
3230

33-
// ─── Tests ─────────────────────────────────────────────────────────────────
34-
3531
describe('useTextEntryInteraction', () => {
3632
describe('initial state', () => {
3733
it('parses existing answers from the block', () => {

contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
1-
import { computed, ref, onMounted } from 'vue';
1+
import { computed, ref } from 'vue';
22
import { parseXML } from '../serialization/parseItem';
33
import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index';
4+
import { qtiEditorStrings } from '../qtiEditorStrings';
5+
6+
const { errorParsingQuestion$ } = qtiEditorStrings;
47

58
/**
6-
* Composable that manages the question type and descriptor for a single
7-
* interaction block.
9+
* Composable that resolves the interaction descriptor and question type for a
10+
* single interaction block.
811
*
912
* @param {import('vue').Ref<object>} interactionRef
1013
* Ref to the interaction block { bodyXml, responseDeclarations }.
1114
*/
1215
export default function useInteractionDescriptor(interactionRef) {
13-
/** Writable question type — set from XML on mount, then driven by UI selections. */
14-
const questionType = ref(null);
15-
/** Any parse error message from the initial XML parse; null when clean. */
16-
const parseError = ref(null);
17-
1816
/**
19-
* Parses bodyXml and returns { descriptor, questionType } without touching
20-
* any reactive state — pure helper used only on mount.
17+
* Parses bodyXml and returns the matching descriptor, resolved
18+
* question type, and any parse error without touching reactive state.
2119
*/
2220
function inferFromXml(xml, declarations) {
2321
if (!xml) {
@@ -33,29 +31,35 @@ export default function useInteractionDescriptor(interactionRef) {
3331
error: null,
3432
};
3533
} catch (e) {
34+
// eslint-disable-next-line no-console
35+
console.error('[QTI] Failed to parse interaction XML:', e.message);
3636
return {
3737
descriptor: registry[DEFAULT_INTERACTION],
3838
questionType: null,
39-
error: e.message,
39+
error: errorParsingQuestion$(),
4040
};
4141
}
4242
}
4343

44-
/** Parse XML once when the host component mounts to set the initial state. */
45-
onMounted(() => {
46-
const inferred = inferFromXml(
47-
interactionRef.value?.bodyXml,
48-
interactionRef.value?.responseDeclarations,
49-
);
50-
questionType.value = inferred.questionType;
51-
parseError.value = inferred.error;
52-
});
44+
/**
45+
* Parse the initial XML synchronously during component setup.
46+
*
47+
* This ensures `questionType` is immediately available for downstream components
48+
* on first render, avoiding prop validation warnings that would occur if
49+
* initialization was deferred to a lifecycle hook.
50+
*/
51+
const initial = inferFromXml(
52+
interactionRef.value?.bodyXml,
53+
interactionRef.value?.responseDeclarations,
54+
);
55+
56+
/** Writable ref driven by UI selections after initial parse. */
57+
const questionType = ref(initial.questionType);
58+
const parseError = ref(initial.error);
5359

5460
/**
55-
* Descriptor is derived from the current questionType ref.
56-
* Uses each descriptor's `questionTypes` array so the lookup stays accurate
57-
* when the user changes the question type via the selector.
58-
* Falls back to the default descriptor when no match is found.
61+
* Derived from questionType so the descriptor updates when the user switches
62+
* question types via the selector. Falls back to the default when no match.
5963
*/
6064
const descriptor = computed(
6165
() =>

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ describe('defineInteraction', () => {
3131
expect(descriptor.editorComponent).toBe(component);
3232
});
3333

34-
// Keys that must be present on the descriptor itself (editorComponent is injected
35-
// by defineInteraction from the second argument, so it is excluded here).
3634
const REQUIRED_DESCRIPTOR_KEYS = [
3735
'type',
3836
'placement',
@@ -56,7 +54,6 @@ describe('defineInteraction', () => {
5654

5755
it('throws when editorComponent is not passed as the second argument', () => {
5856
const descriptor = makeValidDescriptor();
59-
// Calling with no second arg means editorComponent is undefined — still flagged.
6057
expect(() => defineInteraction(descriptor)).toThrow(/missing required key "editorComponent"/i);
6158
});
6259

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ describe('parse()', () => {
144144
});
145145

146146
describe('buildXML()', () => {
147-
// Helper: parse an XML string and return the document root element.
148147
function parseXmlString(xml) {
149148
const parser = new DOMParser();
150149
const doc = parser.parseFromString(xml, 'text/xml');

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import textEntryDescriptor from './textEntry/index';
99
export const DEFAULT_INTERACTION = QtiInteraction.CHOICE;
1010

1111
/**
12-
* Ordered array of all registered interaction descriptors.
13-
* InteractionSection iterates this to find the first descriptor whose
14-
* matches(el) returns true.
12+
* Ordered list of all registered interaction descriptors.
13+
* Searched in order; the first whose `matches(el)` returns true wins.
1514
*/
1615
export const descriptors = [choiceDescriptor, textEntryDescriptor];
1716

@@ -32,13 +31,3 @@ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d]));
3231
export function getDescriptorForQuestionType(questionType) {
3332
return descriptors.find(d => d.questionTypes.includes(questionType));
3433
}
35-
36-
/**
37-
* Find the interaction descriptor for a given QTI interaction tag name.
38-
*
39-
* @param {string} tagName
40-
* @returns {import('./defineInteraction').InteractionDescriptor|undefined}
41-
*/
42-
export function getDescriptorForInteractionType(tagName) {
43-
return registry[tagName];
44-
}

0 commit comments

Comments
 (0)