-
-
Notifications
You must be signed in to change notification settings - Fork 301
feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor. #5981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: unstable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants'; | ||
|
|
||
| /** | ||
| * Demo item 1: a real choice interaction XML so the full load path can | ||
| * be verified end-to-end (parseItem → useQtiItem → InteractionSection → | ||
| * ChoiceInteractionEditor). | ||
| */ | ||
| export const CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?> | ||
| <qti-assessment-item | ||
| xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" | ||
| identifier="item-1" | ||
| title="Which planet is closest to the Sun?" | ||
| adaptive="false" | ||
| time-dependent="false" | ||
| xml:lang="en" | ||
| > | ||
| <qti-response-declaration | ||
| identifier="RESPONSE" | ||
| cardinality="single" | ||
| base-type="identifier" | ||
| > | ||
| <qti-correct-response> | ||
| <qti-value>mercury</qti-value> | ||
| </qti-correct-response> | ||
| </qti-response-declaration> | ||
|
|
||
| <qti-item-body> | ||
| <qti-choice-interaction | ||
| response-identifier="RESPONSE" | ||
| max-choices="1" | ||
| > | ||
| <qti-prompt>Which planet is closest to the Sun?</qti-prompt> | ||
| <qti-simple-choice identifier="mercury">Mercury</qti-simple-choice> | ||
| <qti-simple-choice identifier="venus">Venus</qti-simple-choice> | ||
| <qti-simple-choice identifier="earth">Earth</qti-simple-choice> | ||
| <qti-simple-choice identifier="mars">Mars</qti-simple-choice> | ||
| </qti-choice-interaction> | ||
| </qti-item-body> | ||
| </qti-assessment-item>`; | ||
|
|
||
| /** | ||
| * Demo item 2: a multi-select choice interaction XML (max-choices > 1). | ||
| */ | ||
| export const MULTI_CHOICE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?> | ||
| <qti-assessment-item | ||
| xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" | ||
| identifier="item-2" | ||
| title="Select all the prime numbers." | ||
| adaptive="false" | ||
| time-dependent="false" | ||
| xml:lang="en" | ||
| > | ||
| <qti-response-declaration | ||
| identifier="RESPONSE" | ||
| cardinality="multiple" | ||
| base-type="identifier" | ||
| > | ||
| <qti-correct-response> | ||
| <qti-value>two</qti-value> | ||
| <qti-value>three</qti-value> | ||
| <qti-value>five</qti-value> | ||
| </qti-correct-response> | ||
| </qti-response-declaration> | ||
|
|
||
| <qti-item-body> | ||
| <qti-choice-interaction | ||
| response-identifier="RESPONSE" | ||
| max-choices="4" | ||
| > | ||
| <qti-prompt>Select all the prime numbers.</qti-prompt> | ||
| <qti-simple-choice identifier="one">1</qti-simple-choice> | ||
| <qti-simple-choice identifier="two">2</qti-simple-choice> | ||
| <qti-simple-choice identifier="three">3</qti-simple-choice> | ||
| <qti-simple-choice identifier="four">4</qti-simple-choice> | ||
| <qti-simple-choice identifier="five">5</qti-simple-choice> | ||
| </qti-choice-interaction> | ||
| </qti-item-body> | ||
| </qti-assessment-item>`; | ||
|
|
||
| /** | ||
| * Hardcoded items covering different states: | ||
| * - item-1: has raw_data (real QTI XML) → exercises the full load path | ||
| * - item-2: no raw_data → shows placeholder (blank new item state) | ||
| * - item-3: no raw_data → shows placeholder | ||
| */ | ||
| export const INITIAL_ASSESSMENTS = [ | ||
| { | ||
| assessment_id: 'demo-item-1', | ||
| type: AssessmentItemTypes.QTI, | ||
| raw_data: CHOICE_ITEM_XML, | ||
| }, | ||
| { | ||
| assessment_id: 'demo-item-2', | ||
| type: AssessmentItemTypes.QTI, | ||
| raw_data: MULTI_CHOICE_ITEM_XML, | ||
| }, | ||
| { | ||
| assessment_id: 'demo-item-3', | ||
| type: AssessmentItemTypes.QTI, | ||
| }, | ||
| ]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { render, screen } from '@testing-library/vue'; | ||
| import VueRouter from 'vue-router'; | ||
| import InteractionSection from '../index.vue'; | ||
|
|
||
| import { | ||
| CHOICE_SINGLE_SELECT_XML, | ||
| UNKNOWN_INTERACTION_XML, | ||
| mockInteractionBlock as interactionBlock, | ||
| } from '../../../utils/testingFixtures'; | ||
|
|
||
| const renderSection = (props = {}) => | ||
| render(InteractionSection, { | ||
| props: { mode: 'edit', ...props }, | ||
| routes: new VueRouter(), | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tests | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('InteractionSection', () => { | ||
| describe('choice interaction', () => { | ||
| it('renders the prompt from the XML via ChoiceInteractionEditor', () => { | ||
| renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) }); | ||
| expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders radio buttons for a single-select choice interaction', () => { | ||
| renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) }); | ||
| const radios = screen.getAllByRole('radio'); | ||
| expect(radios).toHaveLength(3); | ||
| }); | ||
|
|
||
| it('renders the choice labels', () => { | ||
| renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) }); | ||
| expect(screen.getByText('Mercury')).toBeInTheDocument(); | ||
| expect(screen.getByText('Venus')).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('parse error handling', () => { | ||
| it('shows a parse error message and no interaction when XML is malformed', () => { | ||
| renderSection({ interaction: interactionBlock('not-xml<{{') }); | ||
| expect(screen.queryByRole('radio')).not.toBeInTheDocument(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: The |
||
| // At minimum no interactive elements render | ||
| expect(screen.queryByRole('radio')).not.toBeInTheDocument(); | ||
| expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('unknown interaction type', () => { | ||
| it('falls back silently when the interaction tag is unrecognized', () => { | ||
| // Should not throw — just renders the fallback component | ||
| expect(() => | ||
| renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML) }), | ||
| ).not.toThrow(); | ||
| }); | ||
| }); | ||
| }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Along with the |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| <template> | ||
|
|
||
| <div> | ||
| <p | ||
| v-if="parseError" | ||
| :style="{ color: $themePalette.red.v_700, margin: 0 }" | ||
| > | ||
| {{ parseError }} | ||
| </p> | ||
| <component | ||
| :is="descriptor.editorComponent" | ||
| v-else | ||
| :key="descriptor.type" | ||
| :questionType="questionType" | ||
| :interaction="interaction" | ||
| :mode="mode" | ||
| :showAnswers="showAnswers" | ||
| /> | ||
| </div> | ||
|
|
||
| </template> | ||
|
|
||
|
|
||
| <script> | ||
|
|
||
| import { computed, watch } from 'vue'; | ||
| import useInteractionDescriptor from '../../composables/useInteractionDescriptor'; | ||
|
|
||
| export default { | ||
| name: 'InteractionSection', | ||
|
|
||
| setup(props, { emit }) { | ||
| const bodyXmlRef = computed(() => props.interaction?.bodyXml); | ||
| const { descriptor, questionType, parseError } = useInteractionDescriptor(bodyXmlRef); | ||
|
|
||
| watch( | ||
| questionType, | ||
| newType => { | ||
| if (newType) emit('update:questionType', newType); | ||
| }, | ||
| { immediate: true }, | ||
| ); | ||
|
|
||
| return { descriptor, questionType, parseError }; | ||
| }, | ||
|
|
||
| props: { | ||
| /** The raw XML block representing an interaction and its response declarations */ | ||
| interaction: { | ||
| type: Object, | ||
| required: true, | ||
| }, | ||
|
AlexVelezLl marked this conversation as resolved.
|
||
| /** View or edit mode */ | ||
| mode: { | ||
| type: String, | ||
| default: 'view', | ||
| }, | ||
|
Comment on lines
+48
to
+57
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we add some validators here? Both for |
||
| /** Whether to display correct answers (used in view mode previews) */ | ||
| showAnswers: { | ||
| type: Boolean, | ||
| default: false, | ||
| }, | ||
| }, | ||
|
|
||
| emits: ['update:questionType'], | ||
| }; | ||
|
|
||
| </script> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, apologies, I didn't catch this in the first PR. The assessments array here will have the same structure we get from the AssessmentItem Django model, so, it'd be better to have the same structure. So, a couple of changes:
idlet's useassessment_id.titlefield, as it's not defined in our assessment item model.type, in this array, let's copy this AssessmentItemTypes to our constants. And let's add aQTI: 'qti'type; that will be the type of these ⬆️ questions.We will need to be careful about the difference between "question/assessment item types" and interaction types, because assessment types are what we will save on the backend, and interaction types are the ones we will infer from the XML. We will potentially use the assessment item types for the type selector to not create a new constant.