From 1110d2b89b3b8cfdc58b002b5878e2da0676c1de Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Wed, 30 Sep 2026 14:11:24 +0000
Subject: [PATCH 1/3] List every QTI 3.0 interaction and add multi/unrecognized
item fixtures
Co-Authored-By: Claude Sonnet 5.5
---
.../shared/views/QTIEditor/constants.js | 23 ++++-
.../serialization/__tests__/parseItem.spec.js | 8 ++
.../views/QTIEditor/utils/testingFixtures.js | 92 ++++++++++++++++++-
3 files changed, 117 insertions(+), 6 deletions(-)
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
index a0645220d4..81c8f973b4 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
@@ -50,13 +50,28 @@ export const Orientation = Object.freeze({
* so they serve as both type keys and CSS selectors for querySelectorAll.
*/
export const QtiInteraction = Object.freeze({
- CHOICE: 'qti-choice-interaction',
- ORDER: 'qti-order-interaction',
- MATCH: 'qti-match-interaction',
ASSOCIATE: 'qti-associate-interaction',
- TEXT_ENTRY: 'qti-text-entry-interaction',
+ CHOICE: 'qti-choice-interaction',
+ CUSTOM: 'qti-custom-interaction',
+ DRAWING: 'qti-drawing-interaction',
+ END_ATTEMPT: 'qti-end-attempt-interaction',
EXTENDED_TEXT: 'qti-extended-text-interaction',
+ GAP_MATCH: 'qti-gap-match-interaction',
+ GRAPHIC_ASSOCIATE: 'qti-graphic-associate-interaction',
+ GRAPHIC_GAP_MATCH: 'qti-graphic-gap-match-interaction',
+ GRAPHIC_ORDER: 'qti-graphic-order-interaction',
+ HOTSPOT: 'qti-hotspot-interaction',
+ HOTTEXT: 'qti-hottext-interaction',
INLINE_CHOICE: 'qti-inline-choice-interaction',
+ MATCH: 'qti-match-interaction',
+ MEDIA: 'qti-media-interaction',
+ ORDER: 'qti-order-interaction',
+ PORTABLE_CUSTOM: 'qti-portable-custom-interaction',
+ POSITION_OBJECT: 'qti-position-object-interaction',
+ SELECT_POINT: 'qti-select-point-interaction',
+ SLIDER: 'qti-slider-interaction',
+ TEXT_ENTRY: 'qti-text-entry-interaction',
+ UPLOAD: 'qti-upload-interaction',
});
export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction));
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
index 28555b966b..adea6f6e72 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
@@ -4,6 +4,8 @@ import { parseXML } from '../xml';
import {
VALID_CHOICE_ITEM_DOCUMENT,
TWO_INTERACTIONS_DOCUMENT,
+ UNDESCRIBED_INTERACTION,
+ UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
} from '../../utils/testingFixtures';
@@ -59,6 +61,12 @@ describe('parseItem — interaction blocks', () => {
expect(model.interactions).toHaveLength(2);
});
+ it('finds an interaction the editor has no descriptor for', () => {
+ const model = parseItem(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT);
+ expect(model.interactions).toHaveLength(1);
+ expect(model.interactions[0].bodyXml).toContain(`<${UNDESCRIBED_INTERACTION}`);
+ });
+
it('merges inline interactions of one type into one block, in body order', () => {
const model = parseItem(MULTI_TEXT_ENTRY_ITEM_DOCUMENT);
expect(model.interactions).toHaveLength(1);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index e5f7dbe10f..aa0309dc8c 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -1,5 +1,8 @@
// Centralized QTI Mock XML Fixtures for Unit Tests
+import { QTI_INTERACTION_TAGS } from '../constants';
+import { registry } from '../interactions/descriptors';
+
export const CHOICE_SINGLE_SELECT_XML = `
Which planet is closest to the Sun?
Mercury
@@ -382,10 +385,95 @@ export const TWO_INTERACTIONS_DOCUMENT = `
`;
+/** Two choice interactions and a hint catalog: parses, but cannot be edited faithfully. */
+export const MULTI_INTERACTION_ITEM_DOCUMENT = `
+
+
+ A
+
+
+ C
+
+
+
+
+ First question
+ Yes
+ No
+
+
+ Second question
+ Yes
+ No
+
+
+
+
+
+ Think about it
+
+
+
+`;
+
+/**
+ * An interaction QTI defines but no registered descriptor handles. Picked from the
+ * registry so a fixture stays unrecognized when a descriptor is added for another type.
+ */
+export const UNDESCRIBED_INTERACTION = QTI_INTERACTION_TAGS.find(tag => !registry[tag]);
+
+export const UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT = `
+
+
+
+
+ <${UNDESCRIBED_INTERACTION} response-identifier="RESPONSE">
+ Describe the capital
+ ${UNDESCRIBED_INTERACTION}>
+
+`;
+
+/**
+ * Inline choice dropdowns, which have a descriptor but no editor yet. The second dropdown
+ * has no correct answer, which the editor's rules would report.
+ */
+export const INLINE_CHOICE_ITEM_DOCUMENT = `
+
+
+
+ blue
+
+
+
+
+
+ The sky is bluered and grass is greenpurple.
+
+`;
+
/**
* Two text entries whose declarations are listed out of body order.
- * Text entry, the only inline interaction parseItem finds until #6180, stands in for
- * inline choice.
*/
export const MULTI_TEXT_ENTRY_ITEM_DOCUMENT = `
Date: Wed, 30 Sep 2026 14:11:24 +0000
Subject: [PATCH 2/3] Resolve no descriptor as null and drop
DEFAULT_INTERACTION
Add isSupportedItem: exactly one interaction block whose descriptor has
an editor and reads its question type without error. Text entry counts
per interaction, since the editor holds one blank per item.
Co-Authored-By: Claude Sonnet 5.5
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../QTIEditor/__tests__/validateItem.spec.js | 36 ++++++++-
.../__tests__/InteractionSection.spec.js | 13 ++--
.../components/InteractionSection/index.vue | 2 +-
.../useInteractionDescriptor.spec.js | 32 ++------
.../composables/useInteractionDescriptor.js | 8 +-
.../interactions/__tests__/registry.spec.js | 12 +--
.../__tests__/resolveDescriptor.spec.js | 76 +++++++++++++++++++
.../QTIEditor/interactions/descriptors.js | 18 +++--
.../views/QTIEditor/interactions/index.js | 2 +-
.../interactions/resolveDescriptor.js | 45 ++++++++---
.../shared/views/QTIEditor/validateItem.js | 10 ++-
11 files changed, 184 insertions(+), 70 deletions(-)
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
index fa3af04e77..4f3c409f17 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
@@ -5,10 +5,13 @@ import {
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
NO_INTERACTION_ITEM_DOCUMENT,
+ INLINE_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ MULTI_INTERACTION_ITEM_DOCUMENT,
+ UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
} from '../utils/testingFixtures';
const codesOf = errors => errors.map(error => error.code);
@@ -30,11 +33,9 @@ describe('validateQtiItem', () => {
);
});
- it('validates an item with several inline interactions as one question', () => {
- // Every inline interaction is handed the whole body, so validating one per interaction
- // would report the same problem once for each of them.
+ it('does not apply editor rules to an item with several text entries', () => {
const xml = MULTI_TEXT_ENTRY_ITEM_DOCUMENT.replace(/>Sun, '><').replace(/>Moon, '><');
- expect(codesOf(validateQtiItem(xml))).toEqual([ValidationError.EMPTY_ANSWER_CONTENT]);
+ expect(validateQtiItem(xml)).toEqual([]);
});
it('reports an item whose body holds no interaction', () => {
@@ -43,6 +44,33 @@ describe('validateQtiItem', () => {
]);
});
+ it('reports nothing for an item with an interaction with no descriptor, which is shown read-only', () => {
+ expect(validateQtiItem(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT)).toEqual([]);
+ expect(
+ validateQtiItem(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT, { allowFreeResponse: false }),
+ ).toEqual([]);
+ });
+
+ it('reports a three-set match interaction inside a multi-interaction item as unparseable', () => {
+ const xml = MULTI_INTERACTION_ITEM_DOCUMENT.replace(
+ //,
+ MATCH_THREE_SETS_XML,
+ );
+ expect(codesOf(validateQtiItem(xml))).toContain(ValidationError.PARSE_ERROR);
+ });
+
+ it('does not apply the editor rules to a multi-interaction item', () => {
+ const xml = MULTI_INTERACTION_ITEM_DOCUMENT.replace(
+ /First question<\/qti-prompt>/,
+ '',
+ );
+ expect(validateQtiItem(xml, { allowFreeResponse: false })).toEqual([]);
+ });
+
+ it('does not apply the editor rules to an interaction with no editor', () => {
+ expect(validateQtiItem(INLINE_CHOICE_ITEM_DOCUMENT)).toEqual([]);
+ });
+
it('reports an item with no raw data at all', () => {
expect(validateQtiItem('')).toEqual([{ code: ValidationError.NO_INTERACTION }]);
expect(validateQtiItem(undefined)).toEqual([{ code: ValidationError.NO_INTERACTION }]);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
index 360d681956..71643822a7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js
@@ -27,6 +27,9 @@ const renderSection = (props = {}) =>
routes: new VueRouter(),
});
+// jest_config/setup.js adds a hidden csrf input to the document.
+const isAuthorInput = el => el.name !== 'csrfmiddlewaretoken';
+
describe('InteractionSection', () => {
describe('choice interaction', () => {
it('renders the prompt from the XML via ChoiceInteractionEditor', () => {
@@ -142,11 +145,11 @@ describe('InteractionSection', () => {
});
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();
+ it('renders nothing editable', () => {
+ renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML), mode: 'edit' });
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ expect(screen.queryAllByRole('textbox').filter(isAuthorInput)).toHaveLength(0);
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
index 080bc5bc7a..0762cf6780 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
@@ -7,7 +7,7 @@
>
{{ parseError }}
-
+
{
});
describe('with an unrecognized interaction type', () => {
- it('falls back to the default descriptor without a parse error', async () => {
+ it('resolves no descriptor and no parse error', async () => {
const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML);
await nextTick();
+ expect(result.descriptor.value).toBeNull();
expect(result.parseError.value).toBeNull();
});
-
- it('still returns a defined fallback descriptor', async () => {
- const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML);
- await nextTick();
- expect(result.descriptor.value).toBeDefined();
- expect(typeof result.descriptor.value.matches).toBe('function');
- });
});
describe('with a null or empty bodyXmlRef', () => {
- it('returns the default descriptor when bodyXmlRef is null', async () => {
- const { result } = renderDescriptor(null);
- await nextTick();
- expect(result.descriptor.value).toBeDefined();
- expect(result.parseError.value).toBeNull();
- });
-
- it('returns null questionType when bodyXmlRef is null', async () => {
- const { result } = renderDescriptor(null);
+ it.each([null, ''])('resolves no descriptor for %p', async xml => {
+ const { result } = renderDescriptor(xml);
await nextTick();
+ expect(result.descriptor.value).toBeNull();
expect(result.questionType.value).toBeNull();
- });
-
- it('returns the default descriptor when bodyXmlRef is an empty string', async () => {
- const { result } = renderDescriptor('');
- await nextTick();
- expect(result.descriptor.value).toBeDefined();
expect(result.parseError.value).toBeNull();
});
});
@@ -106,10 +88,10 @@ describe('useInteractionDescriptor', () => {
expect(result.parseError.value).toBe('This question could not be loaded');
});
- it('still returns a defined fallback descriptor on parse error', async () => {
+ it('resolves no descriptor on parse error', async () => {
const { result } = renderDescriptor('
- descriptors.find(d => d.questionTypes.includes(questionType.value)) ??
- registry[DEFAULT_INTERACTION],
+ () => descriptors.find(d => d.questionTypes.includes(questionType.value)) ?? null,
);
return { descriptor, questionType, parseError };
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js
index 23760422c0..f3499824b7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/registry.spec.js
@@ -1,5 +1,5 @@
-import { descriptors, editors, registry, DEFAULT_INTERACTION } from '../index';
-import { isInlineInteraction } from '../descriptors';
+import { descriptors, editors, registry } from '../index';
+import { HEADLESS_INTERACTIONS, isInlineInteraction } from '../descriptors';
import { Placement, QtiInteraction } from '../../constants';
/**
@@ -10,9 +10,7 @@ import { Placement, QtiInteraction } from '../../constants';
* opens the question.
*/
describe('interaction registry', () => {
- // Descriptors whose editor is not built yet. TODO: #6182 removes this exemption.
- const HEADLESS_DESCRIPTORS = [QtiInteraction.INLINE_CHOICE];
- const editable = descriptors.filter(d => !HEADLESS_DESCRIPTORS.includes(d.type));
+ const editable = descriptors.filter(d => !HEADLESS_INTERACTIONS.includes(d.type));
it('registers an editor for every descriptor', () => {
const missing = editable.filter(d => !editors[d.type]).map(d => d.type);
@@ -32,10 +30,6 @@ describe('interaction registry', () => {
expect(Object.keys(registry).sort()).toEqual(descriptors.map(d => d.type).sort());
});
- it('has a descriptor for the fallback interaction', () => {
- expect(registry[DEFAULT_INTERACTION]).toBeDefined();
- });
-
describe('isInlineInteraction', () => {
it('reports the placement each descriptor declares', () => {
for (const descriptor of descriptors) {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js
new file mode 100644
index 0000000000..0b67080ee0
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js
@@ -0,0 +1,76 @@
+import { parseItem } from '../../serialization/parseItem';
+import { QtiInteraction, ValidationError } from '../../constants';
+import { isSupportedItem, resolveDescriptor } from '../resolveDescriptor';
+import {
+ CHOICE_SINGLE_SELECT_XML,
+ INLINE_CHOICE_ITEM_DOCUMENT,
+ MATCH_THREE_SETS_XML,
+ MULTI_INTERACTION_ITEM_DOCUMENT,
+ MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ TWO_INTERACTIONS_DOCUMENT,
+ UNDESCRIBED_INTERACTION,
+ UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
+ VALID_CHOICE_ITEM_DOCUMENT,
+ VALID_MATCH_ITEM_DOCUMENT,
+} from '../../utils/testingFixtures';
+
+const UNDESCRIBED_XML = `<${UNDESCRIBED_INTERACTION} response-identifier="RESPONSE"/>`;
+
+const interactionsOf = document => parseItem(document).interactions;
+
+describe('resolveDescriptor', () => {
+ it('resolves no descriptor for an interaction nothing is registered for', () => {
+ expect(resolveDescriptor(UNDESCRIBED_XML, [])).toEqual({
+ descriptor: null,
+ questionType: null,
+ error: null,
+ });
+ });
+
+ it('resolves no descriptor for an empty body', () => {
+ expect(resolveDescriptor('', []).descriptor).toBeNull();
+ });
+
+ it('resolves the descriptor of a recognized interaction', () => {
+ const { descriptor, error } = resolveDescriptor(CHOICE_SINGLE_SELECT_XML, []);
+ expect(descriptor.type).toBe(QtiInteraction.CHOICE);
+ expect(error).toBeNull();
+ });
+
+ it('keeps the descriptor when its question type cannot be read', () => {
+ const { descriptor, error } = resolveDescriptor(MATCH_THREE_SETS_XML, []);
+ expect(descriptor.type).toBe(QtiInteraction.MATCH);
+ expect(error).toBe(ValidationError.PARSE_ERROR);
+ });
+
+ it('resolves no descriptor for malformed XML', () => {
+ expect(resolveDescriptor(' {
+ it.each([
+ ['no interactions', []],
+ ['two choice interactions', interactionsOf(MULTI_INTERACTION_ITEM_DOCUMENT)],
+ ['a choice and a text entry', interactionsOf(TWO_INTERACTIONS_DOCUMENT)],
+ ['an interaction with no descriptor', interactionsOf(UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT)],
+ ['an interaction with no editor', interactionsOf(INLINE_CHOICE_ITEM_DOCUMENT)],
+ ['several text entries in one body', interactionsOf(MULTI_TEXT_ENTRY_ITEM_DOCUMENT)],
+ [
+ 'an interaction whose question type cannot be read',
+ [{ bodyXml: MATCH_THREE_SETS_XML, responseDeclarations: [] }],
+ ],
+ ])('rejects %s', (_, interactions) => {
+ expect(isSupportedItem(interactions)).toBe(false);
+ });
+
+ it.each([
+ ['one choice interaction', VALID_CHOICE_ITEM_DOCUMENT],
+ ['a match interaction', VALID_MATCH_ITEM_DOCUMENT],
+ ])('accepts %s', (_, document) => {
+ expect(isSupportedItem(interactionsOf(document))).toBe(true);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js
index bdc145d08a..395196005d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/descriptors.js
@@ -15,12 +15,6 @@ import { inlineChoiceInteractionDescriptor } from './inlineChoice/Descriptor';
* Registering a new interaction means adding its descriptor here and its editor in index.js
*/
-/**
- * The default interaction type used as fallback when no descriptor matches
- * the interaction element found in the XML body.
- */
-export const DEFAULT_INTERACTION = QtiInteraction.CHOICE;
-
/**
* Ordered list of all registered interaction descriptors.
* Searched in order; the first whose `matches(el)` returns true wins.
@@ -39,6 +33,18 @@ export const descriptors = [
*/
export const registry = Object.fromEntries(descriptors.map(d => [d.type, d]));
+/**
+ * Interactions with a descriptor but no editor in index.js yet. Items holding one are
+ * shown read-only. TODO: #6182 removes inline choice.
+ */
+export const HEADLESS_INTERACTIONS = Object.freeze([QtiInteraction.INLINE_CHOICE]);
+
+/**
+ * Inline interactions whose editor holds one per item body. A body with several would
+ * lose all but one on edit, so it is shown read-only.
+ */
+export const SINGLE_INSTANCE_INTERACTIONS = Object.freeze([QtiInteraction.TEXT_ENTRY]);
+
/**
* Whether an interaction is authored inline, and so needs the whole item body to parse
* rather than its own element. Read off the descriptor's placement, so declaring it there
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
index a20d0958f5..312242e5ea 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
@@ -21,4 +21,4 @@ export const editors = Object.freeze({
[QtiInteraction.MATCH]: MatchEditor,
});
-export { DEFAULT_INTERACTION, descriptors, registry } from './descriptors';
+export { descriptors, registry } from './descriptors';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
index c02f778346..de5a8aa17b 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
@@ -1,6 +1,6 @@
import { parseXML } from '../serialization/xml';
import { ValidationError } from '../constants';
-import { descriptors, registry, DEFAULT_INTERACTION } from './descriptors';
+import { HEADLESS_INTERACTIONS, SINGLE_INSTANCE_INTERACTIONS, descriptors } from './descriptors';
/**
* Resolve the interaction descriptor and question type for a single interaction block.
@@ -12,31 +12,52 @@ import { descriptors, registry, DEFAULT_INTERACTION } from './descriptors';
* interactions)
* @param {string[]} [responseDeclarations]
* @returns {{
- * descriptor: object,
+ * descriptor: object|null,
* questionType: string|null,
* error: string|null,
- * }} `error` is a ValidationError code; callers own how it is presented.
+ * }} `descriptor` is null when no descriptor matches the interaction. `error` is a
+ * ValidationError code; callers own how it is presented.
*/
export function resolveDescriptor(bodyXml, responseDeclarations) {
if (!bodyXml) {
- return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null };
+ return { descriptor: null, questionType: null, error: null };
}
+ let descriptor = null;
try {
const interactionEl = parseXML(bodyXml).documentElement;
- const descriptor =
- descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION];
+ descriptor = descriptors.find(d => d.matches(interactionEl)) ?? null;
return {
descriptor,
- questionType: descriptor.getQuestionType(interactionEl, responseDeclarations) ?? null,
+ questionType: descriptor?.getQuestionType(interactionEl, responseDeclarations) ?? null,
error: null,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error('[QTI] Failed to parse interaction XML:', e.message);
- return {
- descriptor: registry[DEFAULT_INTERACTION],
- questionType: null,
- error: ValidationError.PARSE_ERROR,
- };
+ return { descriptor, questionType: null, error: ValidationError.PARSE_ERROR };
+ }
+}
+
+/**
+ * Whether the editor can edit an item faithfully: it holds exactly one interaction block
+ * (one interaction, for descriptors in SINGLE_INSTANCE_INTERACTIONS), and a descriptor
+ * with an editor exists for it that could read the interaction.
+ *
+ * @param {Array<{ bodyXml: string, responseDeclarations: string[] }>} interactions - As
+ * returned by parseItem
+ * @returns {boolean}
+ */
+export function isSupportedItem(interactions) {
+ if (interactions.length !== 1) {
+ return false;
+ }
+ const [{ bodyXml, responseDeclarations }] = interactions;
+ const { descriptor, error } = resolveDescriptor(bodyXml, responseDeclarations);
+ if (descriptor === null || error || HEADLESS_INTERACTIONS.includes(descriptor.type)) {
+ return false;
}
+ return (
+ !SINGLE_INSTANCE_INTERACTIONS.includes(descriptor.type) ||
+ parseXML(bodyXml).getElementsByTagName(descriptor.type).length <= 1
+ );
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js
index 39200af0f5..b8553a9966 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js
@@ -1,6 +1,6 @@
import { QuestionType, ValidationError } from './constants';
import { parseItem } from './serialization/parseItem';
-import { resolveDescriptor } from './interactions/resolveDescriptor';
+import { isSupportedItem, resolveDescriptor } from './interactions/resolveDescriptor';
/**
* Validate what is wrong with an item as a whole, rather than with one of its interactions:
@@ -40,7 +40,8 @@ export function validateItemShape({ interactions, questionTypes = [], allowFreeR
* @param {object} [options]
* @param {boolean} [options.allowFreeResponse] - Whether a free-response question counts
* as valid. Consumers that only accept scorable questions pass false.
- * @returns {Array<{ code: string, id?: string }>} Empty when the item is valid
+ * @returns {Array<{ code: string, id?: string }>} Empty when the item is valid. Items the
+ * editor shows read-only report only unreadable XML or a missing interaction.
*/
export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) {
if (!rawData) {
@@ -59,6 +60,11 @@ export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) {
...resolveDescriptor(interaction.bodyXml, interaction.responseDeclarations),
}));
+ if (item.interactions.length && !isSupportedItem(item.interactions)) {
+ // Shown read-only: the editor's rules don't apply, only unreadable interactions count.
+ return resolved.filter(({ error }) => error).map(({ error }) => ({ code: error }));
+ }
+
const errors = validateItemShape({
interactions: item.interactions,
questionTypes: resolved.map(({ questionType }) => questionType),
From 682c6578c7463b499e137e3cf14c9c6cf446558b Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Wed, 30 Sep 2026 14:11:24 +0000
Subject: [PATCH 3/3] Show the unsupported message for multi- and
unrecognized-interaction items
The card renders no editable controls, including hints, and never
rewrites the item's XML. Unreadable or empty QTI still blocks publishing.
Co-Authored-By: Claude Sonnet 5.5
---
.../__tests__/QTIItemEditor.spec.js | 94 +++++++++++++++----
.../components/QTIItemEditor/index.vue | 54 ++++++-----
.../views/QTIEditor/composables/useQtiItem.js | 5 -
.../views/QTIEditor/utils/testingFixtures.js | 29 ------
4 files changed, 108 insertions(+), 74 deletions(-)
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
index a745ea6fea..419f873f3f 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
@@ -11,10 +11,14 @@ import {
FREE_RESPONSE_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
- NO_INTERACTION_ITEM_WITH_HINTS,
VALID_ASSOCIATE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
+ MATCH_THREE_SETS_XML,
+ MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ MULTI_INTERACTION_ITEM_DOCUMENT,
+ UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
+ INLINE_CHOICE_ITEM_DOCUMENT,
} from '../../../utils/testingFixtures';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
@@ -68,6 +72,9 @@ afterAll(() => {
delete Element.prototype.scrollIntoView;
});
+// jest_config/setup.js adds a hidden csrf input to the document.
+const isAuthorInput = el => el.name !== 'csrfmiddlewaretoken';
+
describe('QTIItemEditor', () => {
beforeEach(() => scrollIntoView.mockClear());
afterEach(() => jest.restoreAllMocks());
@@ -230,6 +237,57 @@ describe('QTIItemEditor', () => {
});
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
});
+
+ describe('items without exactly one editable interaction', () => {
+ const documents = {
+ 'two interactions': MULTI_INTERACTION_ITEM_DOCUMENT,
+ 'an interaction with no descriptor': UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
+ 'an interaction with no editor': INLINE_CHOICE_ITEM_DOCUMENT,
+ 'no interaction': NO_INTERACTION_ITEM_DOCUMENT,
+ 'several blanks in one text entry': MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ };
+ const renderDocument = (raw_data, props = {}) =>
+ renderComponent({
+ item: { assessment_id: 'item-id', type: AssessmentItemTypes.QTI, raw_data },
+ ...props,
+ });
+
+ describe.each(Object.entries(documents))('with %s', (_, raw_data) => {
+ test('shows the unsupported message', () => {
+ renderDocument(raw_data, { mode: 'view' });
+ expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
+ });
+
+ test('offers no editable controls or hints in edit mode', () => {
+ renderDocument(raw_data, { mode: 'edit' });
+ expect(screen.queryByText(hintsLabel$())).not.toBeInTheDocument();
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(1);
+ expect(buttons[0]).toHaveAccessibleName(closeBtnLabel$());
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ expect(screen.queryAllByRole('textbox').filter(isAuthorInput)).toHaveLength(0);
+ });
+ });
+
+ test('leaves raw_data untouched after the card is opened and closed', async () => {
+ const { emitted, updateProps } = renderDocument(MULTI_INTERACTION_ITEM_DOCUMENT, {
+ mode: 'edit',
+ });
+
+ await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() }));
+ await updateProps({ mode: 'view' });
+ await nextTick();
+
+ expect(emitted()['update:rawData']).toBeUndefined();
+ });
+
+ test('shows the hints of an unsupported item when answers are shown', () => {
+ renderDocument(MULTI_INTERACTION_ITEM_DOCUMENT, { mode: 'view', showAnswers: true });
+ expect(screen.getByRole('button', { name: hintsLabel$() })).toBeInTheDocument();
+ });
+ });
});
describe('incomplete indicator', () => {
@@ -257,11 +315,26 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});
- test('is shown for an item with no interaction at all', async () => {
+ test('is shown for an unsupported item with no interaction, which blocks publishing', async () => {
await renderAndValidate(NO_INTERACTION_ITEM_DOCUMENT);
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});
+ test('is shown for an item whose XML cannot be parsed', async () => {
+ await renderAndValidate(' {
+ await renderAndValidate(VALID_MATCH_ITEM_DOCUMENT.replace(MATCH_XML, MATCH_THREE_SETS_XML));
+ expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
+ });
+
+ test('is not shown for a valid item with unsupported interactions', async () => {
+ await renderAndValidate(MULTI_INTERACTION_ITEM_DOCUMENT);
+ expect(screen.queryByText(incompleteItemIndicatorLabel$())).not.toBeInTheDocument();
+ });
+
test('is shown for a free-response question where those are not accepted', async () => {
renderComponent({
allowFreeResponse: false,
@@ -403,23 +476,6 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(hintsLabel$())).toBeInTheDocument();
});
- test('keeps the body of a question that has no interaction when a hint changes', async () => {
- // Nothing mounts an interaction editor here, so the editor holds no body of its own.
- // Assembling from that empty state would replace the question's text with an empty
- // — a hint edit silently deleting the question.
- const { emitted } = renderComponent({
- item: { ...defaultProps.item, raw_data: NO_INTERACTION_ITEM_WITH_HINTS },
- mode: 'edit',
- });
- await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() }));
- await fireEvent.click(screen.getAllByRole('button', { name: 'Delete hint' })[0]);
- await nextTick();
-
- const [xml] = emitted()['update:rawData'].at(-1);
- expect(xml).toContain('What is the capital of France?');
- expect(xml).not.toContain('');
- });
-
test('reports the item XML when a hint changes', async () => {
const { emitted } = renderComponent({
item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_HINTS },
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
index 6e9eef8858..edb1017d5a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -80,7 +80,7 @@
props.item.type === AssessmentItemTypes.QTI);
/**
- * Items authored outside this editor (e.g. Perseus questions) and items whose XML
- * cannot be read are shown as read-only cards.
+ * Whether this editor can edit the item's XML faithfully: it is readable, and it is
+ * either blank or holds exactly one interaction this editor knows.
*/
- const isUnsupported = computed(
- () => props.item.type !== AssessmentItemTypes.QTI || Boolean(parseError.value),
+ const isBlank = !props.item.raw_data;
+ const isEditableQti = computed(
+ () => !parseError.value && (isBlank || isSupportedItem(interactions.value)),
);
+ /**
+ * Items authored outside this editor (e.g. Perseus questions) and QTI it cannot edit
+ * faithfully are shown as read-only cards.
+ */
+ const isUnsupported = computed(() => !isQti.value || !isEditableQti.value);
+
/*
* Seed the editor refs from the parsed item's first block, which for an inline
* passage holds every declaration.
- *
- * The body is seeded even when there is no interaction to edit. Such an item still has
- * content — its own text, and any interaction this editor has no descriptor for — and
- * anything else the author can change, a hint, reassembles the whole item. Leaving the
- * body unseeded would write an empty over that text.
*/
- currentBodyXml.value = interactions.value[0]?.bodyXml ?? itemBodyXml.value;
if (interactions.value.length > 0) {
+ currentBodyXml.value = interactions.value[0].bodyXml;
currentResponseDeclarations.value = interactions.value[0].responseDeclarations;
}
@@ -262,6 +264,10 @@
*/
const hasHints = hints.value.length > 0;
+ const showHints = computed(
+ () => hasHints && (props.mode === 'edit' ? !isUnsupported.value : props.showAnswers),
+ );
+
function onUpdateHints(newHints) {
editedHere = props.mode === 'edit';
hints.value = newHints;
@@ -315,7 +321,13 @@
*/
const isIncomplete = computed(() => {
if (isUnsupported.value) {
- return false;
+ // Unreadable or empty QTI can't be fixed here and blocks publishing; other
+ // unsupported items are publishable.
+ return (
+ isQti.value &&
+ validateQtiItem(props.item.raw_data, { allowFreeResponse: props.allowFreeResponse })
+ .length > 0
+ );
}
const itemErrors = validateItemShape({
interactions: interactions.value,
@@ -343,7 +355,7 @@
onUpdateInteraction,
onUpdateErrors,
hints,
- hasHints,
+ showHints,
onUpdateHints,
};
},
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
index deb8008d21..63847bec11 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
@@ -17,7 +17,6 @@ import { assembleItemXml } from '../serialization/assembleItem';
* identifier: import('vue').Ref,
* title: import('vue').Ref,
* language: import('vue').Ref,
- * itemBodyXml: import('vue').Ref,
* interactions: import('vue').Ref>,
* hints: import('vue').Ref>,
* parseError: import('vue').Ref,
@@ -29,8 +28,6 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
const title = ref('');
const language = ref('');
const interactions = ref([]);
- /** The item's `` as parsed, whether or not it holds an interaction. */
- const itemBodyXml = ref('');
/**
* Hints belong to the item, not to any one interaction, so they live here beside
* identifier and title — mutable, and read back by the rawData computed below.
@@ -45,7 +42,6 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
title.value = model.title;
language.value = model.language;
interactions.value = model.interactions;
- itemBodyXml.value = model.itemBodyXml;
hints.value = model.hints;
} catch (e) {
parseError.value = e.message;
@@ -72,7 +68,6 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
identifier,
title,
language,
- itemBodyXml,
interactions,
hints,
parseError,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index aa0309dc8c..ccf4bfc825 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -302,35 +302,6 @@ export const CHOICE_ITEM_DOCUMENT_WITH_HINTS = `
`;
-/**
- * A converted question with nothing to answer, which still carries its text in the body and
- * hints in its catalog. No interaction means no interaction editor, so the only thing an
- * author can edit here is a hint — and that must not cost the body.
- */
-export const NO_INTERACTION_ITEM_WITH_HINTS = `
-
-
- What is the capital of France?
-
-
-
-
- It is on the Seine
-
-
- Starts with a P
-
-
-
-`;
-
export const VALID_ASSOCIATE_ITEM_DOCUMENT = `