Skip to content

Commit dd82a1e

Browse files
authored
Merge pull request learningequality#6012 from Abhishek-Punhani/Issue5965
implement choice interaction parsing and serialization for QTIEditor
2 parents e9066e4 + c500802 commit dd82a1e

37 files changed

Lines changed: 2895 additions & 265 deletions

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
mockInteractionBlock as interactionBlock,
1010
} from '../../../utils/testingFixtures';
1111

12+
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
13+
1214
const renderSection = (props = {}) =>
1315
render(InteractionSection, {
1416
props: { mode: 'edit', ...props },
@@ -41,12 +43,11 @@ describe('InteractionSection', () => {
4143
});
4244

4345
describe('parse error handling', () => {
44-
it('shows a parse error message and no interaction when XML is malformed', () => {
46+
it('gracefully falls back to default interaction state when XML is malformed', () => {
4547
renderSection({ interaction: interactionBlock('not-xml<{{') });
46-
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
47-
// At minimum no interactive elements render
48-
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
49-
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
48+
// It should render exactly 1 choice fallback element
49+
const inputs = screen.queryAllByRole('radio').concat(screen.queryAllByRole('checkbox'));
50+
expect(inputs).toHaveLength(1);
5051
});
5152
});
5253

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
:interaction="interaction"
1616
:mode="mode"
1717
:showAnswers="showAnswers"
18+
@update:interaction="interaction => $emit('update:interaction', interaction)"
1819
/>
1920
</div>
2021

@@ -30,8 +31,8 @@
3031
name: 'InteractionSection',
3132
3233
setup(props, { emit }) {
33-
const bodyXmlRef = computed(() => props.interaction?.bodyXml);
34-
const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef);
34+
const interactionRef = computed(() => props.interaction);
35+
const { descriptor, questionType, parseError } = useInteractionDescriptor(interactionRef);
3536
3637
watch(
3738
questionType,
@@ -67,7 +68,7 @@
6768
},
6869
},
6970
70-
emits: ['update:questionType'],
71+
emits: ['update:questionType', 'update:interaction'],
7172
};
7273
7374
</script>

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

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
:mode="mode"
3434
:showAnswers="showAnswers"
3535
@update:questionType="type => (currentQuestionType = type)"
36+
@update:interaction="onUpdateInteraction"
3637
/>
3738
<p
3839
v-else
@@ -59,7 +60,7 @@
5960

6061
<script>
6162
62-
import { computed, ref } from 'vue';
63+
import { computed, ref, watch } from 'vue';
6364
import { qtiEditorStrings } from '../../qtiEditorStrings';
6465
import { QuestionType } from '../../constants';
6566
import useQtiItem from '../../composables/useQtiItem';
@@ -70,7 +71,7 @@
7071
7172
components: { InteractionSection },
7273
73-
setup(props) {
74+
setup(props, { emit }) {
7475
const {
7576
questionNumberLabel$,
7677
questionNumberAndTypeLabel$,
@@ -79,7 +80,28 @@
7980
unknownTypeLabel$,
8081
} = qtiEditorStrings;
8182
82-
const { interactions } = useQtiItem(props.item.raw_data);
83+
/**
84+
* Track the current bodyXml and responseDeclarations for the interaction.
85+
* Initialised after parsing; updated atomically when the editor emits
86+
* update:interaction. Declared before useQtiItem so they can be passed in
87+
* and observed by the rawData computed inside the composable.
88+
*/
89+
const currentBodyXml = ref('');
90+
const currentResponseDeclarations = ref([]);
91+
92+
// Parse the item XML. rawData is a computed inside useQtiItem that
93+
// re-assembles the full XML whenever identifier/title/language or the
94+
// editor refs change — no need to duplicate assembleItemXml here.
95+
const { interactions, rawData } = useQtiItem(props.item.raw_data, {
96+
bodyXml: currentBodyXml,
97+
responseDeclarations: currentResponseDeclarations,
98+
});
99+
100+
// Seed the editor refs from the parsed interactions (first interaction only).
101+
if (interactions.value.length > 0) {
102+
currentBodyXml.value = interactions.value[0].bodyXml;
103+
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
104+
}
83105
84106
const questionNumberLabel = computed(() =>
85107
questionNumberLabel$({
@@ -116,13 +138,28 @@
116138
}),
117139
);
118140
141+
// Emit only when the assembled XML actually changes after initial mount.
142+
watch(rawData, newVal => {
143+
if (process.env.NODE_ENV === 'development') {
144+
// eslint-disable-next-line no-console
145+
console.log('[QTIItemEditor] assembled XML:\n', newVal);
146+
}
147+
emit('update:rawData', newVal);
148+
});
149+
150+
function onUpdateInteraction({ bodyXml, responseDeclarations }) {
151+
currentBodyXml.value = bodyXml;
152+
currentResponseDeclarations.value = responseDeclarations;
153+
}
154+
119155
return {
120156
currentQuestionType,
121157
interactions,
122158
questionNumberLabel,
123159
questionNumberAndTypeLabel,
124160
closeBtnLabel$,
125161
questionContentPlaceholder$,
162+
onUpdateInteraction,
126163
};
127164
},
128165
@@ -158,7 +195,7 @@
158195
},
159196
},
160197
161-
emits: ['close'],
198+
emits: ['close', 'update:rawData'],
162199
};
163200
164201
</script>
@@ -193,13 +230,13 @@
193230
194231
.question-card-body {
195232
min-width: 0;
196-
padding: 10px var(--question-card-horizontal-padding);
233+
padding: 10px var(--question-card-horizontal-padding) 16px;
197234
}
198235
199236
.question-card-footer {
200237
display: flex;
201238
justify-content: flex-end;
202-
padding: 0 var(--question-card-horizontal-padding) 20px;
239+
padding: 0 var(--question-card-horizontal-padding) 16px;
203240
}
204241
205242
</style>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<template>
2+
3+
<div>
4+
<p
5+
class="validation-message"
6+
role="alert"
7+
:style="{ color: $themeTokens.error }"
8+
>
9+
<slot></slot>
10+
</p>
11+
</div>
12+
13+
</template>
14+
15+
16+
<script>
17+
18+
export default {
19+
name: 'ValidationMessage',
20+
};
21+
22+
</script>
23+
24+
25+
<style scoped>
26+
27+
.validation-message {
28+
margin: 2px 0 0;
29+
font-size: 14px;
30+
line-height: 1.4;
31+
}
32+
33+
</style>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { ref } from 'vue';
2+
import { useChoiceInteraction } from '../useChoiceInteraction';
3+
import { QuestionType } from '../../constants';
4+
5+
// ---------------------------------------------------------------------------
6+
// Helpers
7+
// ---------------------------------------------------------------------------
8+
9+
function makeAnswer(overrides = {}) {
10+
return { id: 'choice_a', content: 'A', correct: false, fixed: false, ...overrides };
11+
}
12+
13+
function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) {
14+
const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2;
15+
const correctIds = choices.filter(a => a.correct).map(a => a.id);
16+
17+
const bodyXml = `<qti-choice-interaction response-identifier="RESPONSE" max-choices="${maxChoices}">
18+
${choices.map(a => `<qti-simple-choice identifier="${a.id}">${a.content}</qti-simple-choice>`).join('\n ')}
19+
</qti-choice-interaction>`;
20+
21+
const declaration = `<qti-response-declaration identifier="RESPONSE"
22+
cardinality="${questionType === QuestionType.SINGLE_SELECT ? 'single' : 'multiple'}"
23+
base-type="identifier">
24+
<qti-correct-response>
25+
${correctIds.map(id => `<qti-value>${id}</qti-value>`).join('')}
26+
</qti-correct-response>
27+
</qti-response-declaration>`;
28+
29+
return { bodyXml, responseDeclarations: [declaration] };
30+
}
31+
32+
function setup(choices, questionType = QuestionType.SINGLE_SELECT) {
33+
const qt = ref(questionType);
34+
const block = makeBlock(choices, questionType);
35+
return { qt, ...useChoiceInteraction(block, qt) };
36+
}
37+
38+
// ---------------------------------------------------------------------------
39+
// Tests
40+
// ---------------------------------------------------------------------------
41+
42+
describe('useChoiceInteraction', () => {
43+
describe('addChoice()', () => {
44+
it('appends a new choice to the list', () => {
45+
const { state, addChoice } = setup([
46+
makeAnswer({ id: 'a', content: 'A' }),
47+
makeAnswer({ id: 'b', content: 'B' }),
48+
]);
49+
addChoice();
50+
expect(state.value.choices).toHaveLength(3);
51+
});
52+
53+
it('new choice has a generated "choice_" identifier', () => {
54+
const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
55+
addChoice();
56+
const newAnswer = state.value.choices[2];
57+
expect(newAnswer.id).toMatch(/^choice_[a-z0-9]{8}$/);
58+
});
59+
60+
it('new choice has empty content and correct: false', () => {
61+
const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
62+
addChoice();
63+
const newAnswer = state.value.choices[2];
64+
expect(newAnswer.content).toBe('');
65+
expect(newAnswer.correct).toBe(false);
66+
});
67+
});
68+
69+
describe('removeChoice()', () => {
70+
it('removes the choice with the given id', () => {
71+
const { state, removeChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
72+
removeChoice('a');
73+
expect(state.value.choices.find(a => a.id === 'a')).toBeUndefined();
74+
});
75+
76+
it('is a no-op when only one choice remains', () => {
77+
const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]);
78+
removeChoice('a');
79+
expect(state.value.choices).toHaveLength(1);
80+
});
81+
});
82+
83+
describe('moveChoiceUp()', () => {
84+
it('swaps choice with the previous one', () => {
85+
const { state, moveChoiceUp } = setup([
86+
makeAnswer({ id: 'a' }),
87+
makeAnswer({ id: 'b' }),
88+
makeAnswer({ id: 'c' }),
89+
]);
90+
moveChoiceUp('b');
91+
expect(state.value.choices.map(a => a.id)).toEqual(['b', 'a', 'c']);
92+
});
93+
94+
it('is a no-op when the choice is first', () => {
95+
const { state, moveChoiceUp } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
96+
moveChoiceUp('a');
97+
expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']);
98+
});
99+
});
100+
101+
describe('moveChoiceDown()', () => {
102+
it('swaps choice with the next one', () => {
103+
const { state, moveChoiceDown } = setup([
104+
makeAnswer({ id: 'a' }),
105+
makeAnswer({ id: 'b' }),
106+
makeAnswer({ id: 'c' }),
107+
]);
108+
moveChoiceDown('b');
109+
expect(state.value.choices.map(a => a.id)).toEqual(['a', 'c', 'b']);
110+
});
111+
112+
it('is a no-op when the choice is last', () => {
113+
const { state, moveChoiceDown } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
114+
moveChoiceDown('b');
115+
expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']);
116+
});
117+
});
118+
119+
describe('toggleCorrectChoice()', () => {
120+
it('singleSelect: sets only the target as correct and clears others', () => {
121+
const { state, toggleCorrectChoice, qt } = setup([
122+
makeAnswer({ id: 'a', correct: true }),
123+
makeAnswer({ id: 'b', correct: false }),
124+
]);
125+
qt.value = QuestionType.SINGLE_SELECT;
126+
toggleCorrectChoice('b');
127+
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
128+
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
129+
});
130+
131+
it('multiSelect: toggles only the target, leaves others unchanged', () => {
132+
const { state, toggleCorrectChoice, qt } = setup(
133+
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })],
134+
QuestionType.MULTI_SELECT,
135+
);
136+
qt.value = QuestionType.MULTI_SELECT;
137+
toggleCorrectChoice('b');
138+
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
139+
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true);
140+
});
141+
142+
it('multiSelect: toggles correct off when already correct', () => {
143+
const { state, toggleCorrectChoice, qt } = setup(
144+
[makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
145+
QuestionType.MULTI_SELECT,
146+
);
147+
qt.value = QuestionType.MULTI_SELECT;
148+
toggleCorrectChoice('a');
149+
expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
150+
expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
151+
});
152+
});
153+
154+
describe('setPrompt()', () => {
155+
it('updates the prompt field', () => {
156+
const { state, setPrompt } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
157+
setPrompt('<p>New prompt</p>');
158+
expect(state.value.prompt).toBe('<p>New prompt</p>');
159+
});
160+
});
161+
162+
describe('setChoiceContent()', () => {
163+
it('updates content for the target choice only', () => {
164+
const { state, setChoiceContent } = setup([
165+
makeAnswer({ id: 'a', content: 'Old' }),
166+
makeAnswer({ id: 'b', content: 'Unchanged' }),
167+
]);
168+
setChoiceContent('a', 'New content');
169+
expect(state.value.choices.find(a => a.id === 'a').content).toBe('New content');
170+
expect(state.value.choices.find(a => a.id === 'b').content).toBe('Unchanged');
171+
});
172+
});
173+
174+
describe('setShuffle()', () => {
175+
it('updates the shuffle flag', () => {
176+
const { state, setShuffle } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
177+
setShuffle(true);
178+
expect(state.value.shuffle).toBe(true);
179+
});
180+
});
181+
});

0 commit comments

Comments
 (0)