From 636c27159a09caab49b5ae0ae7fe1e28977c1823 Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Mon, 5 Oct 2026 16:05:26 +0000
Subject: [PATCH 1/2] feat: show QTI items whose body the editor can't rebuild
as unsupported
- Block interactions: the body must hold only the interaction
- Text entry: the interaction alone in a , nothing after it
- Attributes on the body or the text entry's
make the item unsupported
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../QTIEditor/__tests__/validateItem.spec.js | 11 +
.../__tests__/QTIItemEditor.spec.js | 44 +++-
.../components/QTIItemEditor/index.vue | 19 +-
.../views/QTIEditor/composables/useQtiItem.js | 4 +
.../interactions/InteractionDescriptor.js | 13 ++
.../__tests__/resolveDescriptor.spec.js | 219 +++++++++++++++++-
.../interactions/resolveDescriptor.js | 57 ++++-
.../interactions/textEntry/Descriptor.js | 43 +++-
.../__tests__/convertedItem.spec.js | 13 ++
.../views/QTIEditor/serialization/xml.js | 27 +++
.../views/QTIEditor/utils/testingFixtures.js | 31 +++
.../shared/views/QTIEditor/validateItem.js | 11 +-
12 files changed, 454 insertions(+), 38 deletions(-)
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 a3c397f44b..c0084db52b 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
@@ -7,12 +7,14 @@ import {
VALID_CHOICE_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
+ CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS,
NO_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
MULTI_INTERACTION_ITEM_DOCUMENT,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
} from '../utils/testingFixtures';
@@ -41,6 +43,15 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(xml)).toEqual([]);
});
+ it('applies editor rules to an item whose body the editor cannot reproduce', () => {
+ expect(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS)).toEqual([
+ { code: ValidationError.NO_CORRECT_ANSWER },
+ ]);
+ expect(
+ validateQtiItem(TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH, { allowFreeResponse: false }),
+ ).toEqual([{ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }]);
+ });
+
it('reports an item whose body holds no interaction', () => {
expect(validateQtiItem(NO_INTERACTION_ITEM_DOCUMENT)).toEqual([
{ code: ValidationError.NO_INTERACTION },
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 419f873f3f..dcff115371 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
@@ -7,7 +7,10 @@ import { AssessmentItemTypes } from '../../../constants';
import {
VALID_CHOICE_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
+ CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS,
ORDERING_ITEM_DOCUMENT_NO_PROMPT,
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_STIMULUS,
+ CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
FREE_RESPONSE_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
@@ -16,6 +19,8 @@ import {
MATCH_THREE_SETS_XML,
MATCH_XML,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
+ TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT,
MULTI_INTERACTION_ITEM_DOCUMENT,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
@@ -238,13 +243,18 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
});
- describe('items without exactly one editable interaction', () => {
+ describe('items the editor cannot rebuild', () => {
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,
+ 'a stimulus beside a block interaction': CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
+ 'a stimulus beside a hinted block interaction':
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_STIMULUS,
+ 'text sharing the text entry paragraph': TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
+ 'content after the text entry paragraph': TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT,
};
const renderDocument = (raw_data, props = {}) =>
renderComponent({
@@ -255,7 +265,9 @@ describe('QTIItemEditor', () => {
describe.each(Object.entries(documents))('with %s', (_, raw_data) => {
test('shows the unsupported message', () => {
renderDocument(raw_data, { mode: 'view' });
- expect(screen.getByText(unsupportedItemMessage$())).toBeInTheDocument();
+ expect(screen.getByTestId('unsupportedMessage')).toHaveTextContent(
+ unsupportedItemMessage$(),
+ );
});
test('offers no editable controls or hints in edit mode', () => {
@@ -269,18 +281,16 @@ describe('QTIItemEditor', () => {
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',
- });
+ test('leaves raw_data untouched after the card is opened and closed', async () => {
+ const { emitted, updateProps } = renderDocument(raw_data, { mode: 'edit' });
- await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() }));
- await updateProps({ mode: 'view' });
- await nextTick();
+ await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() }));
+ await updateProps({ mode: 'view' });
+ await nextTick();
- expect(emitted()['update:rawData']).toBeUndefined();
+ expect(emitted()['update:rawData']).toBeUndefined();
+ });
});
test('shows the hints of an unsupported item when answers are shown', () => {
@@ -330,6 +340,18 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});
+ test('is shown for an invalid item whose body the editor cannot reproduce', async () => {
+ await renderAndValidate(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS);
+ expect(screen.getByTestId('incompleteIndicator')).toHaveTextContent(
+ incompleteItemIndicatorLabel$(),
+ );
+ });
+
+ test('is not shown for a valid item whose body the editor cannot reproduce', async () => {
+ await renderAndValidate(CHOICE_ITEM_DOCUMENT_WITH_STIMULUS);
+ expect(screen.queryByTestId('incompleteIndicator')).not.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();
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 edb1017d5a..c83e867d28 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -148,20 +148,22 @@
// Parse the item XML. rawData is a computed inside useQtiItem that
// re-assembles the full XML whenever identifier/title/language or the
// editor refs change — no need to duplicate assembleItemXml here.
- const { interactions, hints, parseError, rawData } = useQtiItem(props.item.raw_data, {
- bodyXml: currentBodyXml,
- responseDeclarations: currentResponseDeclarations,
- });
+ const { interactions, itemBodyXml, hints, parseError, rawData } = useQtiItem(
+ props.item.raw_data,
+ { bodyXml: currentBodyXml, responseDeclarations: currentResponseDeclarations },
+ );
const isQti = computed(() => props.item.type === AssessmentItemTypes.QTI);
/**
* 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.
+ * either blank or holds exactly one interaction this editor knows, in the body shape its
+ * builder writes.
*/
const isBlank = !props.item.raw_data;
const isEditableQti = computed(
- () => !parseError.value && (isBlank || isSupportedItem(interactions.value)),
+ () =>
+ !parseError.value && (isBlank || isSupportedItem(interactions.value, itemBodyXml.value)),
);
/**
@@ -321,8 +323,9 @@
*/
const isIncomplete = computed(() => {
if (isUnsupported.value) {
- // Unreadable or empty QTI can't be fixed here and blocks publishing; other
- // unsupported items are publishable.
+ // Unreadable or empty QTI can't be fixed here and blocks publishing. So does
+ // an item unsupported only for its body shape that breaks the editor's rules;
+ // other unsupported items are publishable.
return (
isQti.value &&
validateQtiItem(props.item.raw_data, { allowFreeResponse: props.allowFreeResponse })
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
index 63847bec11..5ac183c70a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
@@ -17,6 +17,7 @@ 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,
@@ -27,6 +28,7 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
const identifier = ref('');
const title = ref('');
const language = ref('');
+ const itemBodyXml = ref('');
const interactions = ref([]);
/**
* Hints belong to the item, not to any one interaction, so they live here beside
@@ -41,6 +43,7 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
identifier.value = model.identifier;
title.value = model.title;
language.value = model.language;
+ itemBodyXml.value = model.itemBodyXml;
interactions.value = model.interactions;
hints.value = model.hints;
} catch (e) {
@@ -68,6 +71,7 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
identifier,
title,
language,
+ itemBodyXml,
interactions,
hints,
parseError,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js
index 271e0b5ead..3cce104b50 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js
@@ -11,6 +11,7 @@
*/
import { Placement } from '../constants';
+import { isContentNode } from '../serialization/xml';
/**
* Methods a subclass has to implement. `matches` and `getTypeOptions` are not listed
@@ -69,6 +70,18 @@ export class InteractionDescriptor {
return el.tagName.toLowerCase() === this.type;
}
+ /**
+ * Whether the item body has the shape this interaction's builder writes, so that editing
+ * and rebuilding it loses nothing. The default is the block shape: the interaction alone.
+ *
+ * @param {Element} bodyEl
+ * @returns {boolean}
+ */
+ isSupportedBody(bodyEl) {
+ const content = [...bodyEl.childNodes].filter(isContentNode);
+ return content.length === 1 && this.matches(content[0]);
+ }
+
/**
* Options this interaction contributes to the question type selector. An interaction that
* authors are not meant to pick directly contributes none.
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
index 0b67080ee0..517512891f 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/resolveDescriptor.spec.js
@@ -1,22 +1,54 @@
import { parseItem } from '../../serialization/parseItem';
+import { assembleItemXml } from '../../serialization/assembleItem';
import { QtiInteraction, ValidationError } from '../../constants';
import { isSupportedItem, resolveDescriptor } from '../resolveDescriptor';
import {
+ CHOICE_ITEM_DOCUMENT_WITH_STIMULUS,
CHOICE_SINGLE_SELECT_XML,
+ FREE_RESPONSE_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
MULTI_INTERACTION_ITEM_DOCUMENT,
MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+ ORDERING_ITEM_DOCUMENT_NO_PROMPT,
+ TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH,
+ TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT,
TWO_INTERACTIONS_DOCUMENT,
UNDESCRIBED_INTERACTION,
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
+ VALID_ASSOCIATE_ITEM_DOCUMENT,
VALID_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
} from '../../utils/testingFixtures';
const UNDESCRIBED_XML = `<${UNDESCRIBED_INTERACTION} response-identifier="RESPONSE"/>`;
+const TEXT_ENTRY_PARAGRAPH = '
';
const interactionsOf = document => parseItem(document).interactions;
+const isSupported = document => {
+ const { interactions, itemBodyXml } = parseItem(document);
+ return isSupportedItem(interactions, itemBodyXml);
+};
+const withBody = (document, body) =>
+ document.replace(
+ /[\s\S]*<\/qti-item-body>/,
+ `${body}`,
+ );
+const CONVERTED_TEXT_ENTRY_DOCUMENT = withBody(
+ FREE_RESPONSE_ITEM_DOCUMENT,
+ `Q?
${TEXT_ENTRY_PARAGRAPH}
`,
+);
+
+const save = document => {
+ const item = parseItem(document);
+ const [{ bodyXml, responseDeclarations }] = item.interactions;
+ const { descriptor, questionType } = resolveDescriptor(bodyXml, responseDeclarations);
+ const interaction = descriptor.buildXML(
+ descriptor.parse(bodyXml, responseDeclarations),
+ questionType,
+ );
+ return assembleItemXml({ ...item, ...interaction });
+};
describe('resolveDescriptor', () => {
it('resolves no descriptor for an interaction nothing is registered for', () => {
@@ -70,7 +102,192 @@ describe('isSupportedItem', () => {
it.each([
['one choice interaction', VALID_CHOICE_ITEM_DOCUMENT],
['a match interaction', VALID_MATCH_ITEM_DOCUMENT],
+ ['a text entry after its prompt', FREE_RESPONSE_ITEM_DOCUMENT],
])('accepts %s', (_, document) => {
- expect(isSupportedItem(interactionsOf(document))).toBe(true);
+ expect(isSupported(document)).toBe(true);
+ });
+
+ describe('body content beside a block interaction', () => {
+ it.each([
+ ['a stimulus before it', CHOICE_ITEM_DOCUMENT_WITH_STIMULUS],
+ [
+ 'text after it',
+ VALID_CHOICE_ITEM_DOCUMENT.replace('', 'See below.'),
+ ],
+ [
+ 'CDATA text before it',
+ VALID_CHOICE_ITEM_DOCUMENT.replace(
+ '',
+ '',
+ ),
+ ],
+ [
+ 'an image after it',
+ VALID_CHOICE_ITEM_DOCUMENT.replace(
+ '',
+ '
',
+ ),
+ ],
+ ])('rejects %s', (_, document) => {
+ expect(isSupported(document)).toBe(false);
+ });
+
+ it('rejects an interaction inside a wrapper element', () => {
+ const [{ bodyXml }] = interactionsOf(VALID_CHOICE_ITEM_DOCUMENT);
+ expect(isSupported(withBody(VALID_CHOICE_ITEM_DOCUMENT, `${bodyXml}
`))).toBe(
+ false,
+ );
+ });
+
+ it('rejects a no-break space beside it', () => {
+ expect(
+ isSupported(VALID_CHOICE_ITEM_DOCUMENT.replace('', ' ')),
+ ).toBe(false);
+ });
+
+ it('accepts whitespace and comments around it', () => {
+ const [{ bodyXml }] = interactionsOf(VALID_CHOICE_ITEM_DOCUMENT);
+ expect(
+ isSupported(withBody(VALID_CHOICE_ITEM_DOCUMENT, `\n \n${bodyXml}\n`)),
+ ).toBe(true);
+ });
+ });
+
+ describe('body content around a text entry', () => {
+ it.each([
+ ['a paragraph shared with other content', TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH],
+ [
+ 'a paragraph shared with CDATA text',
+ FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ '
', '/>
More.
'),
+ ],
+ [
+ 'content after the wrapper of its paragraph',
+ CONVERTED_TEXT_ENTRY_DOCUMENT.replace('/>
', '/>More.
'),
+ ],
+ [
+ 'no paragraph of its own',
+ FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ /]*)\/><\/p>/,
+ '',
+ ),
+ ],
+ [
+ 'a paragraph with an attribute',
+ FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ '| Q? | ${TEXT_ENTRY_PARAGRAPH} |
`,
+ ),
+ ],
+ [
+ 'a paragraph inside a list',
+ withBody(
+ FREE_RESPONSE_ITEM_DOCUMENT,
+ `- Q?
- ${TEXT_ENTRY_PARAGRAPH}
`,
+ ),
+ ],
+ [
+ 'a paragraph inside a wrapper with an attribute',
+ CONVERTED_TEXT_ENTRY_DOCUMENT.replace('', '
'),
+ ],
+ [
+ 'a wrapper after the prompt',
+ withBody(FREE_RESPONSE_ITEM_DOCUMENT, `
Q?
${TEXT_ENTRY_PARAGRAPH}
`),
+ ],
+ ])('rejects %s', (_, document) => {
+ expect(isSupported(document)).toBe(false);
+ });
+
+ it.each([
+ [
+ 'its paragraph',
+ FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ '
',
+ '',
+ ),
+ ],
+ ])('accepts namespace declarations on %s', (_, document) => {
+ expect(isSupported(document)).toBe(true);
+ });
+ });
+
+ describe('attributes on the item body', () => {
+ it.each([
+ ['dir', 'dir="rtl"'],
+ ['xml:lang', 'xml:lang="ar"'],
+ ['class', 'class="x"'],
+ ])('rejects a choice item whose body has %s', (_, attribute) => {
+ expect(
+ isSupported(
+ VALID_CHOICE_ITEM_DOCUMENT.replace('
', ``),
+ ),
+ ).toBe(false);
+ });
+
+ it('rejects a text entry item whose body has an attribute', () => {
+ expect(
+ isSupported(
+ FREE_RESPONSE_ITEM_DOCUMENT.replace('', ''),
+ ),
+ ).toBe(false);
+ });
+
+ it('accepts namespace declarations on the body', () => {
+ expect(
+ isSupported(
+ VALID_CHOICE_ITEM_DOCUMENT.replace(
+ '',
+ '',
+ ),
+ ),
+ ).toBe(true);
+ });
+ });
+
+ describe('an item the editor has saved', () => {
+ const textEntryAfter = prompt =>
+ withBody(FREE_RESPONSE_ITEM_DOCUMENT, `${prompt}${TEXT_ENTRY_PARAGRAPH}`);
+
+ it.each([
+ ['a choice interaction', VALID_CHOICE_ITEM_DOCUMENT],
+ ['an ordering interaction', ORDERING_ITEM_DOCUMENT_NO_PROMPT],
+ ['an associate interaction', VALID_ASSOCIATE_ITEM_DOCUMENT],
+ ['a match interaction', VALID_MATCH_ITEM_DOCUMENT],
+ ['a text entry after a plain prompt', FREE_RESPONSE_ITEM_DOCUMENT],
+ ['a text entry with no prompt', textEntryAfter('')],
+ ['a text entry inside the converted wrapper', CONVERTED_TEXT_ENTRY_DOCUMENT],
+ ['a text entry after an image', textEntryAfter('
')],
+ [
+ 'a text entry after MathML',
+ textEntryAfter(''),
+ ],
+ ['a text entry after a right-to-left prompt', textEntryAfter('Q?
')],
+ ['a text entry after a list', textEntryAfter('')],
+ ])('stays editable with %s, after one save and two', (_, document) => {
+ const once = save(document);
+ expect(isSupported(once)).toBe(true);
+ expect(isSupported(save(once))).toBe(true);
+ });
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
index de5a8aa17b..c9fe88e186 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
@@ -1,4 +1,4 @@
-import { parseXML } from '../serialization/xml';
+import { hasNonNamespaceAttributes, parseXML } from '../serialization/xml';
import { ValidationError } from '../constants';
import { HEADLESS_INTERACTIONS, SINGLE_INSTANCE_INTERACTIONS, descriptors } from './descriptors';
@@ -39,25 +39,58 @@ export function resolveDescriptor(bodyXml, responseDeclarations) {
}
/**
- * 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
+ * The descriptor that can edit an item's interaction: the item 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}
+ * @param {Array<{ bodyXml: string, responseDeclarations: string[] }>} interactions
+ * @returns {object|null}
*/
-export function isSupportedItem(interactions) {
+function editableDescriptor(interactions) {
if (interactions.length !== 1) {
- return false;
+ return null;
}
const [{ bodyXml, responseDeclarations }] = interactions;
const { descriptor, error } = resolveDescriptor(bodyXml, responseDeclarations);
if (descriptor === null || error || HEADLESS_INTERACTIONS.includes(descriptor.type)) {
+ return null;
+ }
+ if (
+ SINGLE_INSTANCE_INTERACTIONS.includes(descriptor.type) &&
+ parseXML(bodyXml).getElementsByTagName(descriptor.type).length > 1
+ ) {
+ return null;
+ }
+ return descriptor;
+}
+
+/**
+ * Whether the editor's rules apply to an item's interaction, whatever surrounds it in the
+ * body: see editableDescriptor.
+ *
+ * @param {Array<{ bodyXml: string, responseDeclarations: string[] }>} interactions - As
+ * returned by parseItem
+ * @returns {boolean}
+ */
+export function isSupportedInteraction(interactions) {
+ return editableDescriptor(interactions) !== null;
+}
+
+/**
+ * Whether the editor can edit an item faithfully: its interaction is supported, and the body
+ * has the shape that interaction's builder writes.
+ *
+ * @param {Array<{ bodyXml: string, responseDeclarations: string[] }>} interactions - As
+ * returned by parseItem
+ * @param {string} itemBodyXml - The item's ``, as returned by parseItem
+ * @returns {boolean}
+ */
+export function isSupportedItem(interactions, itemBodyXml) {
+ const descriptor = editableDescriptor(interactions);
+ if (descriptor === null) {
return false;
}
- return (
- !SINGLE_INSTANCE_INTERACTIONS.includes(descriptor.type) ||
- parseXML(bodyXml).getElementsByTagName(descriptor.type).length <= 1
- );
+ const bodyEl = parseXML(itemBodyXml).documentElement;
+ // The builders write a bare , so any attribute would be dropped.
+ return !hasNonNamespaceAttributes(bodyEl) && descriptor.isSupportedBody(bodyEl);
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
index 934189eea7..1bc64c7100 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
@@ -1,9 +1,22 @@
import { QtiInteraction, QuestionType, BaseType, Cardinality, Placement } from '../../constants';
-import { parseXML } from '../../serialization/xml';
+import { hasNonNamespaceAttributes, isContentNode, parseXML } from '../../serialization/xml';
import { InteractionDescriptor } from '../InteractionDescriptor';
import { parseTextEntryInteraction, buildTextEntryInteractionXML } from './parse';
import { validateTextEntryInteraction } from './validation';
+function contentOf(el) {
+ return [...el.childNodes].filter(isContentNode);
+}
+
+function hasContentAfter(node) {
+ for (let sibling = node.nextSibling; sibling; sibling = sibling.nextSibling) {
+ if (isContentNode(sibling)) {
+ return true;
+ }
+ }
+ return false;
+}
+
/**
* Owns all text-entry-specific interaction logic: schema, parse, buildXML, validate.
*
@@ -46,6 +59,34 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
return !!el.querySelector(QtiInteraction.TEXT_ENTRY);
}
+ /**
+ * The builder writes the prompt, then a `` holding only the interaction. Anything else
+ * would be dropped from the interaction's `
`, or moved ahead of it if it follows.
+ * Legacy conversion wraps the same shape in a bare `
`, the body's only content.
+ *
+ * @param {Element} bodyEl
+ * @returns {boolean}
+ */
+ isSupportedBody(bodyEl) {
+ const paragraph = bodyEl.querySelector(this.type).parentElement;
+ if (
+ paragraph.localName !== 'p' ||
+ hasNonNamespaceAttributes(paragraph) ||
+ contentOf(paragraph).length > 1 ||
+ hasContentAfter(paragraph)
+ ) {
+ return false;
+ }
+ const container = paragraph.parentElement;
+ return (
+ container === bodyEl ||
+ (container.localName === 'div' &&
+ !hasNonNamespaceAttributes(container) &&
+ container.parentElement === bodyEl &&
+ contentOf(bodyEl).length === 1)
+ );
+ }
+
/**
* Reads base-type from the response declaration to determine question type.
*
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js
index 271700c0f6..9881ac94e1 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/convertedItem.spec.js
@@ -13,6 +13,7 @@ import fs from 'fs';
import path from 'path';
import { parseItem } from '../parseItem';
import { assembleItemXml } from '../assembleItem';
+import { isSupportedItem } from '../../interactions/resolveDescriptor';
const FIXTURES = path.join(__dirname, '../../../../../../tests/utils/qti/fixtures');
@@ -28,6 +29,18 @@ const rebuild = item =>
hints: item.hints,
});
+describe('converted items the editor opens', () => {
+ it.each(
+ fs
+ .readdirSync(FIXTURES)
+ .filter(file => file.endsWith('.xml'))
+ .map(file => path.basename(file, '.xml')),
+ )('%s', name => {
+ const { interactions, itemBodyXml } = parseItem(read(name));
+ expect(isSupportedItem(interactions, itemBodyXml)).toBe(true);
+ });
+});
+
describe('a converted single-selection item', () => {
const original = read('single_selection');
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
index 3bb4333cb6..fcedc997ae 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
@@ -107,6 +107,33 @@ function adoptNode(node, doc = xmlDoc, plainNamespace = XHTML_NS) {
return el;
}
+const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
+
+/**
+ * Whether an element has attributes beyond namespace declarations.
+ *
+ * @param {Element} el
+ * @returns {boolean}
+ */
+export function hasNonNamespaceAttributes(el) {
+ return [...el.attributes].some(attr => attr.namespaceURI !== XMLNS_NS);
+}
+
+/**
+ * Whether a node is content: an element or text (CDATA included) beyond whitespace. Comments
+ * and the like carry none.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+export function isContentNode(node) {
+ return (
+ node.nodeType === Node.ELEMENT_NODE ||
+ ((node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE) &&
+ /[^ \t\r\n]/.test(node.nodeValue))
+ );
+}
+
/**
* Serialize XML nodes as an HTML string, for state that a rich text editor parses as HTML.
*
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index ccf4bfc825..99aa0a4769 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -241,6 +241,32 @@ export const FREE_RESPONSE_ITEM_DOCUMENT = `
`;
+const TEXT_ENTRY_PARAGRAPH = '
';
+
+/** A block interaction with a stimulus beside it, which the editor would delete. */
+export const CHOICE_ITEM_DOCUMENT_WITH_STIMULUS = VALID_CHOICE_ITEM_DOCUMENT.replace(
+ '
',
+ 'Read the passage.
',
+);
+
+/** An incomplete item the editor cannot reproduce, which validation still reports. */
+export const CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS =
+ CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER.replace(
+ '',
+ 'Read the passage.
',
+ );
+
+export const TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH = FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ TEXT_ENTRY_PARAGRAPH,
+ 'Answer: km
',
+);
+
+/** Content after the interaction's ``, which the editor would move before it. */
+export const TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT = FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ '
',
+ 'Show your working.
',
+);
+
export const NO_INTERACTION_ITEM_DOCUMENT = `
} Empty when the item is valid. Items the
- * editor shows read-only report only unreadable XML or a missing interaction.
+ * @returns {Array<{ code: string, id?: string }>} Empty when the item is valid. Items whose
+ * interactions the editor can't edit report only unreadable XML or a missing interaction.
*/
export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) {
if (!rawData) {
@@ -60,8 +60,9 @@ 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.
+ if (item.interactions.length && !isSupportedInteraction(item.interactions)) {
+ // The editor's rules don't apply, only unreadable interactions count. Body shape alone
+ // doesn't exempt an item: its interaction is still one those rules cover.
return resolved.filter(({ error }) => error).map(({ error }) => ({ code: error }));
}
From 3895ab84dcd114eb1eba0b902c84e60007a59373 Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Tue, 6 Oct 2026 23:15:41 +0000
Subject: [PATCH 2/2] fix: exempt QTI items the editor can't rebuild from its
rules
- validateQtiItem uses isSupportedItem, so read-only items aren't left Incomplete
- Move the text entry body-shape check into parse.js
Co-Authored-By: Claude Opus 5.5
---
.../QTIEditor/__tests__/validateItem.spec.js | 14 ++---
.../__tests__/QTIItemEditor.spec.js | 9 +---
.../components/QTIItemEditor/index.vue | 5 +-
.../interactions/resolveDescriptor.js | 12 -----
.../interactions/textEntry/Descriptor.js | 48 +++--------------
.../QTIEditor/interactions/textEntry/parse.js | 51 ++++++++++++++++++-
.../views/QTIEditor/utils/testingFixtures.js | 2 +-
.../shared/views/QTIEditor/validateItem.js | 11 ++--
8 files changed, 74 insertions(+), 78 deletions(-)
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 c0084db52b..845b984f33 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
@@ -43,13 +43,13 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(xml)).toEqual([]);
});
- it('applies editor rules to an item whose body the editor cannot reproduce', () => {
- expect(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS)).toEqual([
- { code: ValidationError.NO_CORRECT_ANSWER },
- ]);
- expect(
- validateQtiItem(TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH, { allowFreeResponse: false }),
- ).toEqual([{ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED }]);
+ it('does not apply editor rules to an item whose body the editor cannot reproduce', () => {
+ const noPrompt = TEXT_ENTRY_ITEM_DOCUMENT_SHARED_PARAGRAPH.replace(
+ 'Tell us what you think.
',
+ '',
+ );
+ expect(validateQtiItem(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS)).toEqual([]);
+ expect(validateQtiItem(noPrompt, { allowFreeResponse: false })).toEqual([]);
});
it('reports an item whose body holds no interaction', () => {
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 dcff115371..661e0d7712 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
@@ -340,15 +340,8 @@ describe('QTIItemEditor', () => {
expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument();
});
- test('is shown for an invalid item whose body the editor cannot reproduce', async () => {
+ test('is not shown for an item whose body the editor cannot reproduce', async () => {
await renderAndValidate(CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS);
- expect(screen.getByTestId('incompleteIndicator')).toHaveTextContent(
- incompleteItemIndicatorLabel$(),
- );
- });
-
- test('is not shown for a valid item whose body the editor cannot reproduce', async () => {
- await renderAndValidate(CHOICE_ITEM_DOCUMENT_WITH_STIMULUS);
expect(screen.queryByTestId('incompleteIndicator')).not.toBeInTheDocument();
});
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 c83e867d28..167df94589 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -323,9 +323,8 @@
*/
const isIncomplete = computed(() => {
if (isUnsupported.value) {
- // Unreadable or empty QTI can't be fixed here and blocks publishing. So does
- // an item unsupported only for its body shape that breaks the editor's rules;
- // other unsupported items are publishable.
+ // 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 })
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
index c9fe88e186..49f02599e8 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/resolveDescriptor.js
@@ -64,18 +64,6 @@ function editableDescriptor(interactions) {
return descriptor;
}
-/**
- * Whether the editor's rules apply to an item's interaction, whatever surrounds it in the
- * body: see editableDescriptor.
- *
- * @param {Array<{ bodyXml: string, responseDeclarations: string[] }>} interactions - As
- * returned by parseItem
- * @returns {boolean}
- */
-export function isSupportedInteraction(interactions) {
- return editableDescriptor(interactions) !== null;
-}
-
/**
* Whether the editor can edit an item faithfully: its interaction is supported, and the body
* has the shape that interaction's builder writes.
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
index 1bc64c7100..f411f39f74 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
@@ -1,22 +1,13 @@
import { QtiInteraction, QuestionType, BaseType, Cardinality, Placement } from '../../constants';
-import { hasNonNamespaceAttributes, isContentNode, parseXML } from '../../serialization/xml';
+import { parseXML } from '../../serialization/xml';
import { InteractionDescriptor } from '../InteractionDescriptor';
-import { parseTextEntryInteraction, buildTextEntryInteractionXML } from './parse';
+import {
+ parseTextEntryInteraction,
+ buildTextEntryInteractionXML,
+ isSupportedTextEntryBody,
+} from './parse';
import { validateTextEntryInteraction } from './validation';
-function contentOf(el) {
- return [...el.childNodes].filter(isContentNode);
-}
-
-function hasContentAfter(node) {
- for (let sibling = node.nextSibling; sibling; sibling = sibling.nextSibling) {
- if (isContentNode(sibling)) {
- return true;
- }
- }
- return false;
-}
-
/**
* Owns all text-entry-specific interaction logic: schema, parse, buildXML, validate.
*
@@ -59,32 +50,9 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
return !!el.querySelector(QtiInteraction.TEXT_ENTRY);
}
- /**
- * The builder writes the prompt, then a `` holding only the interaction. Anything else
- * would be dropped from the interaction's `
`, or moved ahead of it if it follows.
- * Legacy conversion wraps the same shape in a bare `
`, the body's only content.
- *
- * @param {Element} bodyEl
- * @returns {boolean}
- */
+ /** @param {Element} bodyEl */
isSupportedBody(bodyEl) {
- const paragraph = bodyEl.querySelector(this.type).parentElement;
- if (
- paragraph.localName !== 'p' ||
- hasNonNamespaceAttributes(paragraph) ||
- contentOf(paragraph).length > 1 ||
- hasContentAfter(paragraph)
- ) {
- return false;
- }
- const container = paragraph.parentElement;
- return (
- container === bodyEl ||
- (container.localName === 'div' &&
- !hasNonNamespaceAttributes(container) &&
- container.parentElement === bodyEl &&
- contentOf(bodyEl).length === 1)
- );
+ return isSupportedTextEntryBody(bodyEl);
}
/**
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
index 25bcf89cde..af520ec309 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
@@ -1,5 +1,12 @@
import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
-import { buildXmlNode, parseXML, serializeAsHtml, wrapInlineRuns } from '../../serialization/xml';
+import {
+ buildXmlNode,
+ hasNonNamespaceAttributes,
+ isContentNode,
+ parseXML,
+ serializeAsHtml,
+ wrapInlineRuns,
+} from '../../serialization/xml';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import Mapping from '../../serialization/qti/declarations/mapping';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
@@ -44,6 +51,48 @@ export function _defaultState() {
};
}
+function contentOf(el) {
+ return [...el.childNodes].filter(isContentNode);
+}
+
+function hasContentAfter(node) {
+ for (let sibling = node.nextSibling; sibling; sibling = sibling.nextSibling) {
+ if (isContentNode(sibling)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Whether the item body has the shape buildTextEntryInteractionXML writes: the prompt, then a
+ * `
` holding only the interaction. Anything else would be dropped from the interaction's
+ * `
`, or moved ahead of it if it follows. Legacy conversion wraps the same shape in a bare
+ * `
`, the body's only content.
+ *
+ * @param {Element} bodyEl - The `
` element
+ * @returns {boolean}
+ */
+export function isSupportedTextEntryBody(bodyEl) {
+ const paragraph = bodyEl.querySelector('qti-text-entry-interaction').parentElement;
+ if (
+ paragraph.localName !== 'p' ||
+ hasNonNamespaceAttributes(paragraph) ||
+ contentOf(paragraph).length > 1 ||
+ hasContentAfter(paragraph)
+ ) {
+ return false;
+ }
+ const container = paragraph.parentElement;
+ return (
+ container === bodyEl ||
+ (container.localName === 'div' &&
+ !hasNonNamespaceAttributes(container) &&
+ container.parentElement === bodyEl &&
+ contentOf(bodyEl).length === 1)
+ );
+}
+
/**
* Serializes the body element children to an HTML string, excluding the
* element that directly contains the ``.
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index 99aa0a4769..4096e3684e 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -249,7 +249,7 @@ export const CHOICE_ITEM_DOCUMENT_WITH_STIMULUS = VALID_CHOICE_ITEM_DOCUMENT.rep
'Read the passage.
',
);
-/** An incomplete item the editor cannot reproduce, which validation still reports. */
+/** An item the editor cannot reproduce, which would break its rules if they applied. */
export const CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS =
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER.replace(
'',
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/validateItem.js
index 862b708502..4dfad5ecc5 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 { isSupportedInteraction, 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,8 +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. Items whose
- * interactions the editor can't edit report only unreadable XML or a missing interaction.
+ * @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) {
@@ -60,9 +60,8 @@ export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) {
...resolveDescriptor(interaction.bodyXml, interaction.responseDeclarations),
}));
- if (item.interactions.length && !isSupportedInteraction(item.interactions)) {
- // The editor's rules don't apply, only unreadable interactions count. Body shape alone
- // doesn't exempt an item: its interaction is still one those rules cover.
+ if (item.interactions.length && !isSupportedItem(item.interactions, item.itemBodyXml)) {
+ // Shown read-only: the editor's rules don't apply, only unreadable interactions count.
return resolved.filter(({ error }) => error).map(({ error }) => ({ code: error }));
}