child of an interaction element.
- * Returns an empty string when no prompt element is present.
- * Using innerHTML (not textContent) preserves rich inline markup (, , etc.)
- * for round-trip fidelity.
- *
- * @param {Element} interactionEl - The root element
- * @returns {string}
- */
-export function getPromptHTML(interactionEl) {
- const promptEl = interactionEl.querySelector('qti-prompt');
- return promptEl ? promptEl.innerHTML : '';
-}
/**
* Parses a raw QTI XML string into the structured ItemModel.
@@ -55,9 +11,9 @@ export function getPromptHTML(interactionEl) {
* A response declaration belongs to an interaction when the declaration's
* `identifier` matches the interaction's `response-identifier` attribute.
*
- * For descriptors with `placement: 'inline'`, `bodyXml` is the serialized
- * `` rather than the interaction element alone, so the
- * interaction's parse() function can recover prompt content from body siblings.
+ * An interaction its descriptor declares as inline gets the serialized
+ * `` as its `bodyXml` rather than the interaction element alone,
+ * so its parse() can recover prompt content from body siblings.
*
* @param {string} rawData - Raw QTI XML string (the full assessment item XML)
* @returns {{
@@ -93,7 +49,7 @@ export function parseItem(rawData) {
.filter(d => d.getAttribute('identifier') === responseId)
.map(d => serializer.serializeToString(d));
- const isInline = INLINE_INTERACTION_TAGS.has(el.tagName.toLowerCase());
+ const isInline = isInlineInteraction(el.tagName.toLowerCase());
interactions.push({
bodyXml: isInline ? serializer.serializeToString(body) : serializer.serializeToString(el),
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js
index 9941b80309..ac2ef196d1 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js
@@ -330,7 +330,11 @@ export class QTIDeclaration {
attrs['base-type'] = this.baseType;
}
- const children = Object.values(this._capabilities).map(cap => cap.getXML());
+ // A capability returns null when it has nothing valid to serialize (e.g. a correct
+ // response with no values); those are dropped rather than emitted empty.
+ const children = Object.values(this._capabilities)
+ .map(cap => cap.getXML())
+ .filter(Boolean);
return buildXmlNode({ tag: this.tag, attrs, children });
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js
index 7c50c8b240..97736127db 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js
@@ -4,7 +4,7 @@
* @module serialization/qti/QTISanitizer
*/
-import { parseXML } from '../parseItem';
+import { parseXML } from '../xml';
// Valid QTI 3.0 base-type values — https://www.imsglobal.org/spec/qti/v3p0/impl/#h.wq4e8lbs4wa9
const VALID_BASE_TYPES = new Set([
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js
index 7aa7c250fc..3b9f87fe08 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js
@@ -110,10 +110,10 @@ describe('CorrectResponse', () => {
expect(values).toEqual(['ChoiceA', 'ChoiceC']);
});
- it('produces an empty qti-correct-response when values is empty', () => {
- expect(
- new CorrectResponse([], makeDeclaration()).getXML().querySelectorAll('qti-value').length,
- ).toBe(0);
+ it('produces no element at all when values is empty', () => {
+ // The schema requires at least one qti-value inside qti-correct-response, so an
+ // answer-less declaration omits the element instead of emitting an empty one.
+ expect(new CorrectResponse([], makeDeclaration()).getXML()).toBeNull();
});
it('round-trips: qti-value child carries correct text', () => {
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js
index 0d95a0b659..d1d9b50adb 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js
@@ -1,7 +1,7 @@
/**
* Shared XML parse helper for declaration tests.
*/
-import { parseXML as parseXMLDocument } from '../../parseItem';
+import { parseXML as parseXMLDocument } from '../../xml';
const serializer = new XMLSerializer();
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js
index e492ea8c9b..c492e4712d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js
@@ -42,9 +42,16 @@ export default class CorrectResponse {
}
/**
- * @returns {Element}
+ * `qti-correct-response` is optional but must hold at least one `qti-value` when
+ * present, so an answer-less declaration omits the element rather than emitting an
+ * empty one the schema would reject.
+ *
+ * @returns {Element|null}
*/
getXML() {
+ if (!this._values.length) {
+ return null;
+ }
return buildXmlNode({
tag: 'qti-correct-response',
children: this._declaration
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js
index 00432f8e0b..0712c94f42 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js
@@ -40,9 +40,15 @@ export default class DefaultValue {
}
/**
- * @returns {Element}
+ * Like `qti-correct-response`, the element is optional but must hold at least one
+ * `qti-value`, so an empty one is omitted rather than emitted.
+ *
+ * @returns {Element|null}
*/
getXML() {
+ if (!this._values.length) {
+ return null;
+ }
return buildXmlNode({
tag: 'qti-default-value',
children: this._declaration
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
new file mode 100644
index 0000000000..a25a948d47
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
@@ -0,0 +1,50 @@
+/**
+ * DOM helpers for reading QTI XML.
+ */
+
+const parser = new DOMParser();
+
+/**
+ * Parses a QTI XML or HTML string into a Document.
+ *
+ * @param {string} xmlString - Raw QTI XML (or HTML fragment) string
+ * @param {string} [mimeType='text/xml'] - Parse mode. `'text/xml'` runs the
+ * `parsererror` check; `'text/html'` parses leniently and never throws.
+ * @returns {Document} Parsed XML or HTML Document
+ * @throws {Error} If parsing as `'text/xml'` and the input is malformed or
+ * contains a parsererror. HTML parsing never throws.
+ */
+export function parseXML(xmlString, mimeType = 'text/xml') {
+ let input = xmlString;
+ if (mimeType === 'text/xml') {
+ input = xmlString.replace(/ xmlns="[^"]*"/, '');
+ }
+
+ const doc = parser.parseFromString(input, mimeType);
+
+ // DOMParser never throws — it signals failure via a node. This
+ // only applies to XML: the HTML parser recovers silently and never emits one,
+ // so an HTML document literally containing a must not trip it.
+ if (mimeType === 'text/xml') {
+ const error = doc.querySelector('parsererror');
+ if (error) {
+ throw new Error(`QTI XML parse error: ${error.textContent.trim()}`);
+ }
+ }
+
+ return doc;
+}
+
+/**
+ * Extract the inner HTML of the first child of an interaction element.
+ * Returns an empty string when no prompt element is present.
+ * Using innerHTML (not textContent) preserves rich inline markup (, , etc.)
+ * for round-trip fidelity.
+ *
+ * @param {Element} interactionEl - The root element
+ * @returns {string}
+ */
+export function getPromptHTML(interactionEl) {
+ const promptEl = interactionEl.querySelector('qti-prompt');
+ return promptEl ? promptEl.innerHTML : '';
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js
index cd3b77c29a..62fbbb40d1 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/useQTIEditorActions.js
@@ -1,4 +1,5 @@
import { qtiEditorStrings } from './qtiEditorStrings';
+import { AssessmentItemTypes } from './constants';
/**
* Generates the toolbar actions array for a specific QTI item in the list.
@@ -32,7 +33,8 @@ export default function useQTIEditorActions({
label: toolbarLabelEdit$(),
handler: () => openItem(item.assessment_id),
collapsed: false,
- disabled: isEditMode,
+ // Items authored elsewhere (e.g. Perseus) can be moved or removed, but not opened.
+ disabled: isEditMode || item.type !== AssessmentItemTypes.QTI,
});
result.push({
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js
new file mode 100644
index 0000000000..346742fa4e
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/math.spec.js
@@ -0,0 +1,23 @@
+import { floatOrIntRegex } from '../math';
+
+describe('floatOrIntRegex', () => {
+ it('tests true for valid values', () => {
+ [
+ '1.5', // Float
+ '-4.5', // Signed Float
+ '+1', // Signed Int
+ '10e5', // Exponentiation
+ '-15.3e5', // Combo
+ '-12345.67890e98', // Combo 2
+ ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(true));
+ });
+
+ it('tests false for invalid values', () => {
+ [
+ 'i * 1.5', // Math
+ 'one.point.five', // Text
+ '10 5 0 100', // Spaces
+ '1.2.3.4', // IP
+ ].forEach(v => expect(floatOrIntRegex.test(v)).toBe(false));
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index 24c12a77c2..c59c696dfe 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -91,6 +91,120 @@ export const VALID_CHOICE_ITEM_DOCUMENT = `
`;
+export const CHOICE_ITEM_DOCUMENT_NO_PROMPT = `
+
+
+
+ choice-a
+
+
+
+
+
+ A
+ B
+
+
+`;
+
+export const CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER = `
+
+
+
+
+
+ Pick one.
+ A
+ B
+
+
+`;
+
+/**
+ * An ordering item missing its prompt — used to check that an interaction which reports
+ * nothing to the card is still reported as incomplete.
+ */
+export const ORDERING_ITEM_DOCUMENT_NO_PROMPT = `
+
+
+
+ order_aaa11111
+ order_bbb22222
+
+
+
+
+
+ Mercury
+ Venus
+
+
+`;
+
+/**
+ * A text-entry item whose declaration carries no correct response — an open-ended
+ * question, which only surveys accept.
+ */
+export const FREE_RESPONSE_ITEM_DOCUMENT = `
+
+
+
+
+ Tell us what you think.
+
+
+`;
+
+export const NO_INTERACTION_ITEM_DOCUMENT = `
+
+
+ Just some text.
+
+`;
+
export const TWO_INTERACTIONS_DOCUMENT = `
} Empty when the item is valid
+ */
+export function validateQtiItem(rawData, { allowFreeResponse = true } = {}) {
+ if (!rawData) {
+ return [{ code: ValidationError.NO_INTERACTION }];
+ }
+
+ let item;
+ try {
+ item = parseItem(rawData);
+ } catch {
+ return [{ code: ValidationError.PARSE_ERROR }];
+ }
+
+ if (!item.interactions.length) {
+ return [{ code: ValidationError.NO_INTERACTION }];
+ }
+
+ const errors = [];
+ for (const { bodyXml, responseDeclarations } of item.interactions) {
+ const { descriptor, questionType, error } = resolveDescriptor(bodyXml, responseDeclarations);
+ if (error) {
+ errors.push({ code: error });
+ continue;
+ }
+ if (!allowFreeResponse && questionType === QuestionType.FREE_RESPONSE) {
+ errors.push({ code: ValidationError.FREE_RESPONSE_NOT_ALLOWED });
+ }
+ const state = descriptor.parse(bodyXml, responseDeclarations);
+ errors.push(...descriptor.validate(state, questionType));
+ }
+ return errors;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue
index 0e114c49ea..5af52542bb 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue
@@ -126,6 +126,7 @@
import { useMathHandling } from './composables/useMathHandling';
import FormulasMenu from './components/math/FormulasMenu.vue';
import { preprocessMarkdown } from './utils/markdown';
+ import { resolveImageSrcs, toStoredImageSrcs } from './utils/imageSrc';
import MobileTopBar from './components/toolbar/MobileTopBar.vue';
import MobileFormattingBar from './components/toolbar/MobileFormattingBar.vue';
import { getTipTapEditorStrings } from './TipTapEditorStrings';
@@ -195,7 +196,10 @@
const getContent = () => {
if (!editor.value || !isReady.value) return '';
- if (props.format === 'html') return editor.value.getHTML();
+ // Image srcs are resolved for display on the way in, so they are reduced
+ // back to their stored form here — leaving this the one place that reads
+ // content out, whichever form the editor happens to be holding.
+ if (props.format === 'html') return toStoredImageSrcs(editor.value.getHTML());
if (!editor.value.storage?.markdown) return '';
return editor.value.storage.markdown.getMarkdown();
};
@@ -231,7 +235,7 @@
}
const processedContent =
- props.format === 'html' ? newValue : preprocessMarkdown(newValue);
+ props.format === 'html' ? resolveImageSrcs(newValue) : preprocessMarkdown(newValue);
if (!editor.value) {
initializeEditor(processedContent, props.mode, {
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js
new file mode 100644
index 0000000000..38c28c2855
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js
@@ -0,0 +1,71 @@
+// Translates image sources between the two forms they take in HTML content.
+//
+// Stored content references an image by bare `.` filename, which is
+// what publishing rewrites into a package's images/ directory (see the backend's
+// utils/assessment/qti/media.py) and what the QTI Img model accepts — it rejects
+// absolute paths outright. The browser, though, needs a URL it can load, so the
+// filename is resolved to its storage URL on the way into the editor and reduced
+// back to the filename on the way out.
+//
+// The markdown format does the same thing through preprocessMarkdown/paramsToImageMd;
+// these are its counterparts for content that is already HTML.
+import { storageUrl } from 'shared/vuex/file/utils';
+
+// Kept identical to QTI_CHECKSUM_FILENAME_REGEX in media.py, which decides on the
+// backend which references publishing is able to resolve.
+const CHECKSUM_FILENAME = /^([a-f0-9]{32})\.([0-9a-z]+)$/;
+
+const IMG_TAG = /
]*>/gi;
+const SRC_ATTRIBUTE = /\bsrc\s*=\s*(["'])(.*?)\1/i;
+
+/**
+ * Rewrite the src of every
in an HTML string.
+ *
+ * A targeted substitution rather than a parse-and-serialize round trip, so
+ * everything else about the markup — attribute order, self-closing style,
+ * whitespace — survives untouched.
+ *
+ * @param {string} html
+ * @param {function(string): string} mapSrc
+ * @returns {string}
+ */
+function mapImageSrcs(html, mapSrc) {
+ if (!html) {
+ return html;
+ }
+ return html.replace(IMG_TAG, tag =>
+ tag.replace(SRC_ATTRIBUTE, (attribute, quote, src) => {
+ const mapped = mapSrc(src);
+ return mapped === src ? attribute : `src=${quote}${mapped}${quote}`;
+ }),
+ );
+}
+
+/**
+ * Turn stored `.` sources into loadable storage URLs.
+ *
+ * @param {string} html
+ * @returns {string}
+ */
+export function resolveImageSrcs(html) {
+ return mapImageSrcs(html, src => {
+ const match = CHECKSUM_FILENAME.exec(src);
+ return match ? storageUrl(match[1], match[2]) : src;
+ });
+}
+
+/**
+ * Reduce storage URLs back to the `.` filename that gets stored.
+ *
+ * Sources that are not a checksum filename — a data URI, a remote image — are left
+ * as they are.
+ *
+ * @param {string} html
+ * @returns {string}
+ */
+export function toStoredImageSrcs(html) {
+ return mapImageSrcs(html, src => {
+ const filename = src.split('/').pop();
+ return CHECKSUM_FILENAME.test(filename) ? filename : src;
+ });
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js
new file mode 100644
index 0000000000..54b949c355
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js
@@ -0,0 +1,64 @@
+import { resolveImageSrcs, toStoredImageSrcs } from '../TipTapEditor/utils/imageSrc';
+
+const CHECKSUM = '83ab37e959e03fec7be3e1bf834cb169';
+const FILENAME = `${CHECKSUM}.jpg`;
+const STORAGE_URL = `/content/storage/8/3/${FILENAME}`;
+
+describe('resolveImageSrcs', () => {
+ it('turns a stored filename into its storage URL', () => {
+ expect(resolveImageSrcs(`
`)).toBe(
+ `
`,
+ );
+ });
+
+ it('keeps the rest of the tag as it was', () => {
+ expect(resolveImageSrcs(`text
more
`)).toBe(
+ `text
more
`,
+ );
+ });
+
+ it('resolves every image in the content', () => {
+ const html = `
`;
+ expect(resolveImageSrcs(html)).toBe(`
`);
+ });
+
+ it('leaves an already resolved src alone', () => {
+ expect(resolveImageSrcs(`
`)).toBe(`
`);
+ });
+
+ it('leaves a src that is not a checksum filename alone', () => {
+ const html = '
';
+ expect(resolveImageSrcs(html)).toBe(html);
+ });
+
+ it('ignores a src outside an img tag', () => {
+ const html = ``;
+ expect(resolveImageSrcs(html)).toBe(html);
+ });
+
+ it('returns empty content unchanged', () => {
+ expect(resolveImageSrcs('')).toBe('');
+ });
+});
+
+describe('toStoredImageSrcs', () => {
+ it('reduces a storage URL to the filename that gets stored', () => {
+ expect(toStoredImageSrcs(`
`)).toBe(
+ `
`,
+ );
+ });
+
+ it('leaves an already stored src alone', () => {
+ expect(toStoredImageSrcs(`
`)).toBe(`
`);
+ });
+
+ it('leaves a src that is not a checksum filename alone', () => {
+ const html = '
';
+ expect(toStoredImageSrcs(html)).toBe(html);
+ });
+
+ it('is the inverse of resolveImageSrcs', () => {
+ const html = `
`;
+ expect(toStoredImageSrcs(resolveImageSrcs(html))).toBe(html);
+ });
+});
diff --git a/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml
new file mode 100644
index 0000000000..c480798ac6
--- /dev/null
+++ b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py
index 63d5ec32e2..c5f5e7c649 100644
--- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py
+++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py
@@ -117,6 +117,71 @@ def test_true_false(self):
)
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
+ def test_single_selection_no_answers(self):
+ item = _make_item(
+ type=exercises.SINGLE_SELECTION,
+ question="What is 2+2?",
+ answers=[],
+ randomize=True,
+ assessment_id="abcdef1234567890abcdef1234567890",
+ )
+
+ result = convert_legacy_assessment_item_to_qti(item)
+
+ self.assertEqual(result.identifier, "Kq83vEjRWeJCrze8SNFZ4kA")
+ self.assertEqual(
+ _normalize_xml(_load_fixture("single_selection_no_answers.xml")),
+ _normalize_xml(result.xml),
+ )
+ self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
+
+ def test_choice_types_with_no_answers_omit_the_interaction(self):
+ # The guard covers the choice types as a group; test_single_selection_no_answers
+ # pins SINGLE_SELECTION against the fixture.
+ for question_type in (exercises.MULTIPLE_SELECTION, "true_false"):
+ with self.subTest(question_type=question_type):
+ item = _make_item(
+ type=question_type,
+ question="What is 2+2?",
+ answers=[],
+ assessment_id="abcdef1234567890abcdef1234567890",
+ )
+
+ result = convert_legacy_assessment_item_to_qti(item)
+
+ self.assertNotIn("qti-choice-interaction", result.xml)
+ self.assertNotIn("qti-response-declaration", result.xml)
+ self.assertNotIn("qti-response-processing", result.xml)
+ self.assertIn("What is 2+2?
", result.xml)
+ self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
+
+ def test_choice_type_with_no_answers_and_no_question(self):
+ item = _make_item(
+ type=exercises.MULTIPLE_SELECTION,
+ question="",
+ answers=[],
+ assessment_id="abcdef1234567890abcdef1234567890",
+ )
+
+ result = convert_legacy_assessment_item_to_qti(item)
+
+ self.assertIn("", result.xml)
+ self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
+
+ def test_choice_type_with_no_answers_and_block_maths(self):
+ # Validity is not asserted: rendered MathML carries no namespace, the same
+ # gap test_free_response_with_maths lives with.
+ item = _make_item(
+ type=exercises.SINGLE_SELECTION,
+ question="$$\\sum_n^sxa^n$$",
+ answers=[],
+ assessment_id="abcdef1234567890abcdef1234567890",
+ )
+
+ result = convert_legacy_assessment_item_to_qti(item)
+
+ self.assertIn('