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..845b984f33 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('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', () => { 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..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 @@ -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,11 @@ describe('QTIItemEditor', () => { expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); }); + 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.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..167df94589 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)), ); /** 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').RefQ?
${TEXT_ENTRY_PARAGRAPH}
More.
'), + ], + [ + 'content after the wrapper of its paragraph', + CONVERTED_TEXT_ENTRY_DOCUMENT.replace('/>', '/>More.
'), + ], + [ + 'no paragraph of its own', + FREE_RESPONSE_ITEM_DOCUMENT.replace( + /Q? Q? ` 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
+ * ` Read the passage. Read the passage. Answer: `, which the editor would move before it. */
+export const TEXT_ENTRY_ITEM_DOCUMENT_TRAILING_CONTENT = FREE_RESPONSE_ITEM_DOCUMENT.replace(
+ ' Show your working. `,
+ ),
+ ],
+ [
+ '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('
')],
+ ])('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..49f02599e8 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,46 @@ 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 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 `