Skip to content

Commit bf22397

Browse files
authored
Merge pull request learningequality#5981 from Abhishek-Punhani/Issue5964
feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor.
2 parents 648224a + 2dcf2b2 commit bf22397

21 files changed

Lines changed: 1383 additions & 93 deletions

File tree

contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
<div>
44
<div style="padding: 16px 24px 0">
55
<div
6-
style="
7-
padding: 16px;
8-
color: #2196f3;
9-
background-color: transparent;
10-
border: 1px solid #2196f3;
11-
border-radius: 4px;
12-
"
6+
:style="{
7+
padding: '16px',
8+
color: $themePalette.blue.v_600,
9+
backgroundColor: 'transparent',
10+
border: `1px solid ${$themePalette.blue.v_600}`,
11+
borderRadius: '4px',
12+
}"
1313
>
1414
<strong>QTI Editor — Dev Demo</strong>
1515
&nbsp;Hardcoded items. Changes are local only and not persisted.
@@ -28,28 +28,34 @@
2828
<script>
2929
3030
import { ref, defineComponent } from 'vue';
31+
import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData';
3132
import QTIEditor from 'shared/views/QTIEditor/index';
32-
import { QtiInteraction } from 'shared/views/QTIEditor/constants';
33+
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';
3334
3435
/**
35-
* Hardcoded items covering three interaction types so the closed-card
36-
* type label can be visually verified.
36+
* Hardcoded items covering different states:
37+
* - item-1: has raw_data (real QTI XML) → exercises the full load path
38+
* - item-2: no raw_data → shows placeholder (blank new item state)
39+
* - item-3: no raw_data → shows placeholder
3740
*/
3841
const INITIAL_ASSESSMENTS = [
3942
{
40-
id: 'demo-item-1',
41-
type: QtiInteraction.CHOICE,
42-
title: 'Which planet is closest to the Sun?',
43+
assessment_id: 'demo-item-1',
44+
type: AssessmentItemTypes.QTI,
45+
raw_data: CHOICE_ITEM_XML,
4346
},
4447
{
45-
id: 'demo-item-2',
46-
type: QtiInteraction.EXTENDED_TEXT,
47-
title: 'Describe the water cycle in your own words.',
48+
assessment_id: 'demo-item-2',
49+
type: AssessmentItemTypes.QTI,
50+
raw_data: MULTI_CHOICE_ITEM_XML,
4851
},
4952
{
50-
id: 'demo-item-3',
51-
type: QtiInteraction.ORDER,
52-
title: 'Arrange these events in chronological order.',
53+
assessment_id: 'demo-item-3',
54+
type: AssessmentItemTypes.QTI,
55+
},
56+
{
57+
assessment_id: 'demo-item-4',
58+
type: AssessmentItemTypes.QTI,
5359
},
5460
];
5561
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants';
2+
3+
/**
4+
* Demo item 1: a real choice interaction XML so the full load path can
5+
* be verified end-to-end (parseItem → useQtiItem → InteractionSection →
6+
* ChoiceInteractionEditor).
7+
*/
8+
export const CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
9+
<qti-assessment-item
10+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
11+
identifier="item-1"
12+
title="Which planet is closest to the Sun?"
13+
adaptive="false"
14+
time-dependent="false"
15+
xml:lang="en"
16+
>
17+
<qti-response-declaration
18+
identifier="RESPONSE"
19+
cardinality="single"
20+
base-type="identifier"
21+
>
22+
<qti-correct-response>
23+
<qti-value>mercury</qti-value>
24+
</qti-correct-response>
25+
</qti-response-declaration>
26+
27+
<qti-item-body>
28+
<qti-choice-interaction
29+
response-identifier="RESPONSE"
30+
max-choices="1"
31+
>
32+
<qti-prompt>Which planet is closest to the Sun?</qti-prompt>
33+
<qti-simple-choice identifier="mercury">Mercury</qti-simple-choice>
34+
<qti-simple-choice identifier="venus">Venus</qti-simple-choice>
35+
<qti-simple-choice identifier="earth">Earth</qti-simple-choice>
36+
<qti-simple-choice identifier="mars">Mars</qti-simple-choice>
37+
</qti-choice-interaction>
38+
</qti-item-body>
39+
</qti-assessment-item>`;
40+
41+
/**
42+
* Demo item 2: a multi-select choice interaction XML (max-choices > 1).
43+
*/
44+
export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
45+
<qti-assessment-item
46+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
47+
identifier="item-2"
48+
title="Select all the prime numbers."
49+
adaptive="false"
50+
time-dependent="false"
51+
xml:lang="en"
52+
>
53+
<qti-response-declaration
54+
identifier="RESPONSE"
55+
cardinality="multiple"
56+
base-type="identifier"
57+
>
58+
<qti-correct-response>
59+
<qti-value>two</qti-value>
60+
<qti-value>three</qti-value>
61+
<qti-value>five</qti-value>
62+
</qti-correct-response>
63+
</qti-response-declaration>
64+
65+
<qti-item-body>
66+
<qti-choice-interaction
67+
response-identifier="RESPONSE"
68+
max-choices="4"
69+
>
70+
<qti-prompt>Select all the prime numbers.</qti-prompt>
71+
<qti-simple-choice identifier="one">1</qti-simple-choice>
72+
<qti-simple-choice identifier="two">2</qti-simple-choice>
73+
<qti-simple-choice identifier="three">3</qti-simple-choice>
74+
<qti-simple-choice identifier="four">4</qti-simple-choice>
75+
<qti-simple-choice identifier="five">5</qti-simple-choice>
76+
</qti-choice-interaction>
77+
</qti-item-body>
78+
</qti-assessment-item>`;
79+
80+
/**
81+
* Hardcoded items covering different states:
82+
* - item-1: has raw_data (real QTI XML) → exercises the full load path
83+
* - item-2: no raw_data → shows placeholder (blank new item state)
84+
* - item-3: no raw_data → shows placeholder
85+
*/
86+
export const INITIAL_ASSESSMENTS = [
87+
{
88+
assessment_id: 'demo-item-1',
89+
type: AssessmentItemTypes.QTI,
90+
raw_data: CHOICE_ITEM_XML,
91+
},
92+
{
93+
assessment_id: 'demo-item-2',
94+
type: AssessmentItemTypes.QTI,
95+
raw_data: MULTI_CHOICE_ITEM_XML,
96+
},
97+
{
98+
assessment_id: 'demo-item-3',
99+
type: AssessmentItemTypes.QTI,
100+
},
101+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { render, screen } from '@testing-library/vue';
2+
import { nextTick } from 'vue';
3+
import VueRouter from 'vue-router';
4+
import InteractionSection from '../index.vue';
5+
6+
import {
7+
CHOICE_SINGLE_SELECT_XML,
8+
UNKNOWN_INTERACTION_XML,
9+
mockInteractionBlock as interactionBlock,
10+
} from '../../../utils/testingFixtures';
11+
12+
const renderSection = (props = {}) =>
13+
render(InteractionSection, {
14+
props: { mode: 'edit', ...props },
15+
routes: new VueRouter(),
16+
});
17+
18+
// ---------------------------------------------------------------------------
19+
// Tests
20+
// ---------------------------------------------------------------------------
21+
22+
describe('InteractionSection', () => {
23+
describe('choice interaction', () => {
24+
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
25+
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
26+
expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
27+
});
28+
29+
it('renders radio buttons for a single-select choice interaction', async () => {
30+
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
31+
await nextTick();
32+
const radios = screen.getAllByRole('radio');
33+
expect(radios).toHaveLength(3);
34+
});
35+
36+
it('renders the choice labels', () => {
37+
renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) });
38+
expect(screen.getByText('Mercury')).toBeInTheDocument();
39+
expect(screen.getByText('Venus')).toBeInTheDocument();
40+
});
41+
});
42+
43+
describe('parse error handling', () => {
44+
it('shows a parse error message and no interaction when XML is malformed', () => {
45+
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();
50+
});
51+
});
52+
53+
describe('unknown interaction type', () => {
54+
it('falls back silently when the interaction tag is unrecognized', () => {
55+
// Should not throw — just renders the fallback component
56+
expect(() =>
57+
renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML) }),
58+
).not.toThrow();
59+
});
60+
});
61+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<template>
2+
3+
<div>
4+
<p
5+
v-if="parseError"
6+
:style="{ color: $themePalette.red.v_700, margin: 0 }"
7+
>
8+
{{ parseError }}
9+
</p>
10+
<component
11+
:is="descriptor.editorComponent"
12+
v-else
13+
:key="descriptor.type"
14+
:questionType="questionType"
15+
:interaction="interaction"
16+
:mode="mode"
17+
:showAnswers="showAnswers"
18+
/>
19+
</div>
20+
21+
</template>
22+
23+
24+
<script>
25+
26+
import { computed, watch } from 'vue';
27+
import useInteractionDescriptor from '../../composables/useInteractionDescriptor';
28+
29+
export default {
30+
name: 'InteractionSection',
31+
32+
setup(props, { emit }) {
33+
const bodyXmlRef = computed(() => props.interaction?.bodyXml);
34+
const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef);
35+
36+
watch(
37+
questionType,
38+
newType => {
39+
if (newType) emit('update:questionType', newType);
40+
},
41+
{ immediate: true },
42+
);
43+
44+
return { descriptor, questionType, parseError };
45+
},
46+
47+
props: {
48+
/**
49+
* The raw interaction block.
50+
* Expected shape: { bodyXml: string, responseDeclarations: string[] }
51+
*/
52+
interaction: {
53+
type: Object,
54+
required: true,
55+
validator: val => typeof val.bodyXml === 'string',
56+
},
57+
/** View or edit mode */
58+
mode: {
59+
type: String,
60+
default: 'view',
61+
validator: val => ['view', 'edit'].includes(val),
62+
},
63+
/** Whether to display correct answers (used in view mode previews) */
64+
showAnswers: {
65+
type: Boolean,
66+
default: false,
67+
},
68+
},
69+
70+
emits: ['update:questionType'],
71+
};
72+
73+
</script>

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

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@ import { render, screen, fireEvent } from '@testing-library/vue';
22
import VueRouter from 'vue-router';
33
import QTIItemEditor from '../index.vue';
44
import { qtiEditorStrings } from '../../../qtiEditorStrings';
5-
import { QtiInteraction } from '../../../constants';
5+
import { AssessmentItemTypes } from '../../../constants';
66

77
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
88

99
const defaultProps = {
1010
item: {
11-
id: 'test-item-id',
12-
type: QtiInteraction.CHOICE,
13-
title: 'Test Choice Interaction',
11+
assessment_id: 'test-item-id',
12+
type: AssessmentItemTypes.QTI,
1413
},
1514
index: 0,
1615
total: 5,
1716
mode: 'view',
18-
displayAnswersPreview: false,
17+
showAnswers: false,
1918
};
2019

2120
const renderComponent = (props = {}, slots = {}) => {
@@ -28,9 +27,9 @@ const renderComponent = (props = {}, slots = {}) => {
2827

2928
describe('QTIItemEditor', () => {
3029
describe('view mode', () => {
31-
test('does not show the card body', () => {
30+
test('shows the card body (placeholder) even in view mode', () => {
3231
renderComponent({ mode: 'view' });
33-
expect(screen.queryByText(questionContentPlaceholder$())).not.toBeInTheDocument();
32+
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
3433
});
3534

3635
test('does not show the close button', () => {
@@ -57,14 +56,14 @@ describe('QTIItemEditor', () => {
5756
});
5857
});
5958

60-
describe('displayAnswersPreview', () => {
61-
test('shows the card body in view mode when displayAnswersPreview is true', () => {
62-
renderComponent({ mode: 'view', displayAnswersPreview: true });
59+
describe('showAnswers', () => {
60+
test('shows the card body in view mode when showAnswers is true', () => {
61+
renderComponent({ mode: 'view', showAnswers: true });
6362
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
6463
});
6564

66-
test('does not show the close button even when displayAnswersPreview is true', () => {
67-
renderComponent({ mode: 'view', displayAnswersPreview: true });
65+
test('does not show the close button even when showAnswers is true', () => {
66+
renderComponent({ mode: 'view', showAnswers: true });
6867
expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument();
6968
});
7069
});

0 commit comments

Comments
 (0)