Skip to content

Commit 973482d

Browse files
feat: add ordering interaction type to QTI editor with full implementation and tests
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 90f705f commit 973482d

16 files changed

Lines changed: 1785 additions & 3 deletions

File tree

contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,55 @@ export const FREE_RESPONSE_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
174174
</qti-item-body>
175175
</qti-assessment-item>`;
176176

177+
/**
178+
* Demo item 6: ordering interaction — learner arranges planets in correct order.
179+
* Uses cardinality="ordered" and base-type="identifier" per QTI 3.0 §3.2.10.
180+
*/
181+
export const ORDERING_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
182+
<qti-assessment-item
183+
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
184+
identifier="item-ordering"
185+
title="Order the planets by distance from the Sun"
186+
adaptive="false"
187+
time-dependent="false"
188+
xml:lang="en"
189+
>
190+
<qti-response-declaration
191+
identifier="RESPONSE"
192+
cardinality="ordered"
193+
base-type="identifier"
194+
>
195+
<qti-correct-response>
196+
<qti-value>order_mercury</qti-value>
197+
<qti-value>order_venus</qti-value>
198+
<qti-value>order_earth</qti-value>
199+
<qti-value>order_mars</qti-value>
200+
</qti-correct-response>
201+
</qti-response-declaration>
202+
203+
<qti-item-body>
204+
<qti-order-interaction
205+
response-identifier="RESPONSE"
206+
orientation="vertical"
207+
shuffle="true"
208+
>
209+
<qti-prompt><p>Arrange the planets in order from closest to farthest from the Sun.</p></qti-prompt>
210+
<qti-simple-choice identifier="order_mercury">Mercury</qti-simple-choice>
211+
<qti-simple-choice identifier="order_venus">Venus</qti-simple-choice>
212+
<qti-simple-choice identifier="order_earth">Earth</qti-simple-choice>
213+
<qti-simple-choice identifier="order_mars">Mars</qti-simple-choice>
214+
</qti-order-interaction>
215+
</qti-item-body>
216+
</qti-assessment-item>`;
217+
177218
/**
178219
* Hardcoded items covering different states:
179220
* - item-1: single-select choice interaction
180221
* - item-2: multi-select choice interaction
181222
* - item-numeric: numeric text-entry
182223
* - item-text-entry: string text-entry with case-sensitive answers
183224
* - item-free-response: free-response text-entry (no correct answer)
184-
* - item-blank: no raw_data → shows placeholder (blank new item state)
225+
* - item-ordering: ordering interaction (planets by distance from the Sun)
185226
*/
186227
export const INITIAL_ASSESSMENTS = [
187228
{
@@ -210,7 +251,8 @@ export const INITIAL_ASSESSMENTS = [
210251
raw_data: FREE_RESPONSE_ITEM_XML,
211252
},
212253
{
213-
assessment_id: 'demo-item-blank',
254+
assessment_id: 'demo-item-ordering',
214255
type: AssessmentItemTypes.QTI,
256+
raw_data: ORDERING_ITEM_XML,
215257
},
216258
];

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@
131131
[QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$,
132132
[QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$,
133133
[QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$,
134+
[QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$,
134135
};
135136
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
136137
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { ref } from 'vue';
2+
import { useOrderingInteraction } from '../useOrderingInteraction';
3+
import { ORDERING_XML, ORDERING_DECL_XML } from '../../utils/testingFixtures';
4+
import { QuestionType, ValidationError, Orientation } from '../../constants';
5+
6+
function makeInteractionBlock(bodyXml = ORDERING_XML, declarationXml = ORDERING_DECL_XML) {
7+
return { bodyXml, responseDeclarations: [declarationXml] };
8+
}
9+
10+
describe('useOrderingInteraction', () => {
11+
function setup(bodyXml, declarationXml) {
12+
const questionType = ref(QuestionType.ORDERING);
13+
return useOrderingInteraction(makeInteractionBlock(bodyXml, declarationXml), questionType);
14+
}
15+
16+
describe('initial state', () => {
17+
it('parses items correctly from the fixture XML', () => {
18+
const { state } = setup();
19+
expect(state.value.items).toHaveLength(3);
20+
expect(state.value.items[0].id).toBe('order_aaa11111');
21+
});
22+
23+
it('starts with an empty errors array', () => {
24+
const { errors } = setup();
25+
expect(errors.value).toEqual([]);
26+
});
27+
28+
it('orientation defaults to vertical', () => {
29+
const { state } = setup();
30+
expect(state.value.orientation).toBe(Orientation.VERTICAL);
31+
});
32+
});
33+
34+
describe('addItem()', () => {
35+
it('appends a new item with a generated order_ identifier', () => {
36+
const { state, addItem } = setup();
37+
const before = state.value.items.length;
38+
addItem();
39+
expect(state.value.items).toHaveLength(before + 1);
40+
expect(state.value.items[before].id).toMatch(/^order_/);
41+
});
42+
43+
it('new item starts with empty content', () => {
44+
const { state, addItem } = setup();
45+
addItem();
46+
const last = state.value.items[state.value.items.length - 1];
47+
expect(last.content).toBe('');
48+
});
49+
});
50+
51+
describe('removeItem()', () => {
52+
it('removes the item with the given id', () => {
53+
const { state, removeItem } = setup();
54+
const idToRemove = state.value.items[0].id;
55+
removeItem(idToRemove);
56+
expect(state.value.items.find(i => i.id === idToRemove)).toBeUndefined();
57+
});
58+
59+
it('is a no-op when only one item remains', () => {
60+
const { state, removeItem } = setup();
61+
// Remove until one left
62+
while (state.value.items.length > 1) {
63+
removeItem(state.value.items[0].id);
64+
}
65+
const lastId = state.value.items[0].id;
66+
removeItem(lastId);
67+
expect(state.value.items).toHaveLength(1);
68+
});
69+
});
70+
71+
describe('moveItemUp()', () => {
72+
it('swaps the item at index N with the one at index N-1', () => {
73+
const { state, moveItemUp } = setup();
74+
const [firstId, secondId] = state.value.items.map(i => i.id);
75+
moveItemUp(secondId);
76+
expect(state.value.items[0].id).toBe(secondId);
77+
expect(state.value.items[1].id).toBe(firstId);
78+
});
79+
80+
it('is a no-op when the item is already at the top', () => {
81+
const { state, moveItemUp } = setup();
82+
const firstId = state.value.items[0].id;
83+
moveItemUp(firstId);
84+
expect(state.value.items[0].id).toBe(firstId);
85+
});
86+
});
87+
88+
describe('moveItemDown()', () => {
89+
it('swaps the item at index N with the one at index N+1', () => {
90+
const { state, moveItemDown } = setup();
91+
const [firstId, secondId] = state.value.items.map(i => i.id);
92+
moveItemDown(firstId);
93+
expect(state.value.items[0].id).toBe(secondId);
94+
expect(state.value.items[1].id).toBe(firstId);
95+
});
96+
97+
it('is a no-op when the item is already at the bottom', () => {
98+
const { state, moveItemDown } = setup();
99+
const lastId = state.value.items[state.value.items.length - 1].id;
100+
moveItemDown(lastId);
101+
expect(state.value.items[state.value.items.length - 1].id).toBe(lastId);
102+
});
103+
});
104+
105+
describe('setItemContent()', () => {
106+
it('updates only the targeted item content', () => {
107+
const { state, setItemContent } = setup();
108+
const targetId = state.value.items[1].id;
109+
setItemContent(targetId, '<p>Updated</p>');
110+
expect(state.value.items[1].content).toBe('<p>Updated</p>');
111+
// Other items untouched
112+
expect(state.value.items[0].content).toBe(state.value.items[0].content);
113+
});
114+
});
115+
116+
describe('runValidation()', () => {
117+
it('populates errors for an invalid state', () => {
118+
const { runValidation, errors, setPrompt } = setup();
119+
setPrompt('');
120+
runValidation();
121+
expect(errors.value.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true);
122+
});
123+
});
124+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { readonly } from 'vue';
2+
import { generateRandomSlug } from '../utils/generateRandomSlug';
3+
import { orderingInteractionDescriptor } from '../interactions/ordering/OrderingInteractionDescriptor';
4+
import { useInteraction } from './useInteraction';
5+
6+
/**
7+
* Composable for the ordering interaction editor.
8+
*
9+
* @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock
10+
* @param {import('vue').Ref<string|null>} questionType
11+
*/
12+
export function useOrderingInteraction(interactionBlock, questionType) {
13+
const base = useInteraction(orderingInteractionDescriptor, interactionBlock, questionType);
14+
const { state } = base;
15+
16+
function addItem() {
17+
state.value = {
18+
...state.value,
19+
items: [...state.value.items, { id: generateRandomSlug('order'), content: '', fixed: false }],
20+
};
21+
}
22+
23+
function removeItem(id) {
24+
if (state.value.items.length <= 1) return;
25+
state.value = {
26+
...state.value,
27+
items: state.value.items.filter(item => item.id !== id),
28+
};
29+
}
30+
31+
function moveItemUp(id) {
32+
const items = [...state.value.items];
33+
const idx = items.findIndex(item => item.id === id);
34+
if (idx <= 0) return;
35+
[items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
36+
state.value = { ...state.value, items };
37+
}
38+
39+
function moveItemDown(id) {
40+
const items = [...state.value.items];
41+
const idx = items.findIndex(item => item.id === id);
42+
if (idx === -1 || idx >= items.length - 1) return;
43+
[items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
44+
state.value = { ...state.value, items };
45+
}
46+
47+
function setItemContent(id, html) {
48+
state.value = {
49+
...state.value,
50+
items: state.value.items.map(item => (item.id === id ? { ...item, content: html } : item)),
51+
};
52+
}
53+
54+
function setPrompt(html) {
55+
state.value = { ...state.value, prompt: html };
56+
}
57+
58+
return {
59+
...base,
60+
state: readonly(state),
61+
addItem,
62+
removeItem,
63+
moveItemUp,
64+
moveItemDown,
65+
setItemContent,
66+
setPrompt,
67+
};
68+
}

contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const QuestionType = Object.freeze({
8080
NUMERIC: 'numeric',
8181
TEXT_ENTRY: 'textEntry',
8282
FREE_RESPONSE: 'freeResponse',
83+
ORDERING: 'ordering',
8384
});
8485

8586
/**
@@ -96,6 +97,7 @@ export const ValidationError = Object.freeze({
9697
INVALID_NUMERIC_VALUE: 'INVALID_NUMERIC_VALUE',
9798
EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT',
9899
DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT',
100+
TOO_FEW_CHOICES: 'TOO_FEW_CHOICES',
99101
});
100102

101103
export const RESPONSE_IDENTIFIER = 'RESPONSE';

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { QtiInteraction } from '../constants';
22
import choiceDescriptor from './choice/index';
33
import textEntryDescriptor from './textEntry/index';
4+
import orderingDescriptor from './ordering/index';
45

56
/**
67
* The default interaction type used as fallback when no descriptor matches
@@ -12,7 +13,7 @@ export const DEFAULT_INTERACTION = QtiInteraction.CHOICE;
1213
* Ordered list of all registered interaction descriptors.
1314
* Searched in order; the first whose `matches(el)` returns true wins.
1415
*/
15-
export const descriptors = [choiceDescriptor, textEntryDescriptor];
16+
export const descriptors = [choiceDescriptor, textEntryDescriptor, orderingDescriptor];
1617

1718
/**
1819
* Registry map keyed by descriptor.type for O(1) direct lookup.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
2+
import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse';
3+
import { validateOrderingInteraction } from './validate';
4+
5+
/**
6+
* Owns all ordering-specific interaction logic: schema, parse, buildXML, and validate.
7+
*/
8+
export class OrderingInteractionDescriptor {
9+
constructor({ editorComponent = null } = {}) {
10+
this.type = QtiInteraction.ORDER;
11+
this.placement = 'block';
12+
this.questionTypes = [QuestionType.ORDERING];
13+
this.editorComponent = editorComponent;
14+
this.convertsFrom = [];
15+
}
16+
17+
getTypeOptions(tr) {
18+
return [
19+
{
20+
value: QuestionType.ORDERING,
21+
label: tr.orderingLabel$(),
22+
description: tr.orderingDescription$(),
23+
},
24+
];
25+
}
26+
27+
/** @param {Element} el */
28+
matches(el) {
29+
return el.tagName.toLowerCase() === QtiInteraction.ORDER;
30+
}
31+
32+
/**
33+
* Ordering always has exactly one question type.
34+
*
35+
* @returns {string}
36+
*/
37+
getQuestionType() {
38+
return QuestionType.ORDERING;
39+
}
40+
41+
/**
42+
* @returns {{ baseType: string, cardinality: string }}
43+
*/
44+
getResponseDeclarationSchema() {
45+
return {
46+
baseType: BaseType.IDENTIFIER,
47+
cardinality: Cardinality.ORDERED,
48+
};
49+
}
50+
51+
/**
52+
* Parse <qti-order-interaction> body XML + response declarations → OrderingState.
53+
*
54+
* @param {string} bodyXml
55+
* @param {string[]} responseDeclarations
56+
* @returns {object} OrderingState
57+
*/
58+
parse(bodyXml, responseDeclarations) {
59+
return parseOrderingInteraction(bodyXml, responseDeclarations);
60+
}
61+
62+
/**
63+
* Serialize OrderingState → { bodyXml, responseDeclarations }.
64+
*
65+
* @param {object} state - OrderingState
66+
* @param {string} questionType
67+
* @returns {{ bodyXml: string, responseDeclarations: string[] }}
68+
*/
69+
buildXML(state, questionType) {
70+
return buildOrderingInteractionXML(state, questionType, this.getResponseDeclarationSchema());
71+
}
72+
73+
/**
74+
* Validate OrderingState → ValidationError[].
75+
*
76+
* @param {object} state - OrderingState
77+
* @returns {Array<{ code: string, id?: string }>}
78+
*/
79+
validate(state) {
80+
return validateOrderingInteraction(state);
81+
}
82+
}
83+
84+
/** Singleton — safe to import from any file in the ordering module tree. */
85+
export const orderingInteractionDescriptor = new OrderingInteractionDescriptor();

0 commit comments

Comments
 (0)