From 0f4e0d15fd4b792f4accab76afac65c1a39a9787 Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Wed, 7 Oct 2026 22:15:18 -0700
Subject: [PATCH] fix(qti): show items with content an edit would drop as
read-only
An item opens read-only when assembling it unedited would not write
back every root attribute and child outside the body unchanged. Edits
keep xsi:schemaLocation and the converter's root metadata. Like other
read-only items, these skip the editor's validation rules.
Co-Authored-By: Claude Opus 5.5
---
.../frontend/shared/views/QTIEditor/README.md | 2 +-
.../QTIEditor/__tests__/validateItem.spec.js | 10 +
.../__tests__/QTIItemEditor.spec.js | 26 +++
.../components/QTIItemEditor/index.vue | 14 +-
.../views/QTIEditor/composables/useQtiItem.js | 2 +
.../__tests__/assembleItem.spec.js | 210 ++++++++++++++++-
.../__tests__/convertedItem.spec.js | 50 +++--
.../QTIEditor/serialization/assembleItem.js | 212 +++++++++++++++++-
.../views/QTIEditor/serialization/xml.js | 12 +-
.../views/QTIEditor/utils/testingFixtures.js | 44 ++++
.../shared/views/QTIEditor/validateItem.js | 15 +-
11 files changed, 573 insertions(+), 24 deletions(-)
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md
index 95a7373557..009b76f6bc 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md
@@ -7,7 +7,7 @@ Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org
2. `composables/useQtiItem.js` parses `raw_data` with `serialization/parseItem.js` into interaction blocks (`bodyXml` + `responseDeclarations`), hints and item metadata.
3. `components/InteractionSection` resolves each block's descriptor and question type with `composables/useInteractionDescriptor.js`.
4. The interaction's `Editor.vue` (`interactions/index.js`) edits a plain state object through `composables/useInteraction.js`: `descriptor.parse` → state → `descriptor.buildXML` → `descriptor.validate`.
-5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`; `QTIItemEditor` emits `update:rawData`.
+5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`, keeping `xsi:schemaLocation` and the converter's metadata from `raw_data`; `QTIItemEditor` emits `update:rawData`.
`validateItem.js` `validateQtiItem` runs the same parse and validation headless, without the Vue editors.
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 58d645f9be..c167cb540e 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js
@@ -9,6 +9,8 @@ import {
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER,
CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER_WITH_STIMULUS,
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS,
+ CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET,
NO_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
@@ -53,6 +55,14 @@ describe('validateQtiItem', () => {
expect(validateQtiItem(noPrompt, { allowFreeResponse: false })).toEqual([]);
});
+ it('does not apply editor rules to an item with content an edit would drop', () => {
+ const noPrompt = xml => xml.replace('Pick one.', '');
+ expect(codesOf(validateQtiItem(noPrompt(CHOICE_ITEM_DOCUMENT_WITH_HINTS)))).toContain(
+ ValidationError.PROMPT_REQUIRED,
+ );
+ expect(validateQtiItem(noPrompt(CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET))).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 dbc33766d4..60328085c1 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
@@ -14,6 +14,10 @@ import {
FREE_RESPONSE_ITEM_DOCUMENT,
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_GLOSSARY,
+ CHOICE_ITEM_DOCUMENT_WITH_MODAL_FEEDBACK,
+ CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION,
+ CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET,
VALID_ASSOCIATE_ITEM_DOCUMENT,
VALID_MATCH_ITEM_DOCUMENT,
MATCH_THREE_SETS_XML,
@@ -25,6 +29,7 @@ import {
UNRECOGNIZED_INTERACTION_ITEM_DOCUMENT,
INLINE_CHOICE_ITEM_DOCUMENT,
} from '../../../utils/testingFixtures';
+import { XSI_NS } from '../../../serialization/xml';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
@@ -47,6 +52,7 @@ const {
questionNumberAndTypeLabel$,
unknownTypeLabel$,
responsePoolLabel$,
+ deleteHintBtn$,
} = qtiEditorStrings;
const defaultProps = {
@@ -252,6 +258,13 @@ describe('QTIItemEditor', () => {
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,
+ 'a stylesheet': CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET,
+ 'a stylesheet and no prompt': CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET.replace(
+ 'Pick one.',
+ '',
+ ),
+ 'modal feedback': CHOICE_ITEM_DOCUMENT_WITH_MODAL_FEEDBACK,
+ 'a catalog beside the hints': CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_GLOSSARY,
};
const documents = {
...publishableDocuments,
@@ -532,6 +545,19 @@ describe('QTIItemEditor', () => {
});
});
+ test('keeps xsi:schemaLocation through an edit', async () => {
+ const { emitted } = renderComponent({
+ item: { ...defaultProps.item, raw_data: CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION },
+ mode: 'edit',
+ });
+ await fireEvent.click(screen.getByRole('button', { name: hintsLabel$() }));
+ await fireEvent.click(screen.getAllByRole('button', { name: deleteHintBtn$() })[0]);
+ await nextTick();
+ const [xml] = emitted()['update:rawData'].at(-1);
+ const root = new DOMParser().parseFromString(xml, 'text/xml').documentElement;
+ expect(root.getAttributeNS(XSI_NS, 'schemaLocation')).toContain('imsqti_asiv3p0p1_v1p0.xsd');
+ });
+
describe('associate interaction', () => {
const renderAssociateItem = () =>
renderComponent({
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 db003f725d..c0498bdf76 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -115,8 +115,7 @@
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { AssessmentItemTypes, QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
- import { validateItemShape, validateQtiItem } from '../../validateItem';
- import { isSupportedItem } from '../../interactions/resolveDescriptor';
+ import { isEditableItem, validateItemShape, validateQtiItem } from '../../validateItem';
import InteractionSection from '../InteractionSection/index.vue';
import HintsSection from '../HintsSection/index.vue';
@@ -159,12 +158,17 @@
/**
* 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, in the body shape its
- * builder writes.
+ * builder writes, and nothing outside the body that an edit would drop.
*/
- const isBlank = !props.item.raw_data;
+ const sourceXml = props.item.raw_data;
const isEditableQti = computed(
() =>
- !parseError.value && (isBlank || isSupportedItem(interactions.value, itemBodyXml.value)),
+ !parseError.value &&
+ (!sourceXml ||
+ isEditableItem(
+ { interactions: interactions.value, itemBodyXml: itemBodyXml.value },
+ sourceXml,
+ )),
);
/**
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
index 5ac183c70a..ac29d0de3d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js
@@ -64,6 +64,8 @@ export default function useQtiItem(rawXml, { bodyXml, responseDeclarations } = {
bodyXml: bodyXml?.value ?? '',
responseDeclarations: responseDeclarations?.value ?? [],
hints: hints.value,
+ // parseXML throws on XML that failed to parse, which would break this computed.
+ sourceXml: parseError.value ? null : rawXml,
}),
);
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js
index 8278c26045..d63a2028bf 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js
@@ -1,12 +1,16 @@
// Disabled because jest-dom matchers (toHaveAttribute/toHaveTextContent) are designed for
// HTML and do not work reliably on strict XML elements generated by serialization.
/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */
-import { assembleItemXml } from '../assembleItem.js';
+import { assembleItemXml, keepsItemContent } from '../assembleItem.js';
import { parseItem } from '../parseItem.js';
import { buildXmlNode, parseXML } from '../xml.js';
import { normalizeXML } from '../qti/__tests__/testUtils.js';
import { ResponseProcessingTemplate } from '../../constants';
-import { MULTI_TEXT_ENTRY_ITEM_DOCUMENT } from '../../utils/testingFixtures';
+import {
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS,
+ CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION,
+ MULTI_TEXT_ENTRY_ITEM_DOCUMENT,
+} from '../../utils/testingFixtures';
const serializer = new XMLSerializer();
@@ -360,4 +364,206 @@ describe('assembleItemXml', () => {
expect(parseXML(xml).querySelectorAll('qti-response-processing')).toHaveLength(1);
});
});
+
+ describe('with the source item', () => {
+ const sourceXml = CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION;
+ const reassemble = (params = {}) => {
+ const item = parseItem(sourceXml);
+ return assembleItemXml({
+ identifier: item.identifier,
+ title: item.title,
+ language: item.language,
+ bodyXml: item.interactions[0].bodyXml,
+ responseDeclarations: item.interactions[0].responseDeclarations,
+ hints: item.hints,
+ sourceXml,
+ ...params,
+ });
+ };
+
+ it('keeps no source prefix declaration that its own elements would take', () => {
+ const xml = reassemble({
+ sourceXml: sourceXml.replace(
+ 'xmlns:xsi=',
+ 'xmlns:qti="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"\n xmlns:xsi=',
+ ),
+ });
+ expect(xml).not.toContain('qti:');
+ expect(xml).toContain('xsi:schemaLocation');
+ expect(parseItem(xml).interactions).toHaveLength(1);
+ });
+
+ it('writes its own root attributes over the source ones', () => {
+ const root = parseXML(reassemble({ identifier: 'renamed', language: '' })).documentElement;
+ expect(root.getAttribute('identifier')).toBe('renamed');
+ expect(root.hasAttribute('xml:lang')).toBe(false);
+ });
+ });
+});
+
+describe('keepsItemContent', () => {
+ const withRoot = attrs =>
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace('xml:lang="en"', `xml:lang="en" ${attrs}`);
+ const SCORE_DECLARATION =
+ '';
+ const withScoring = (outcomeDeclaration, responseProcessing = '') =>
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ '\n\n ',
+ `\n ${outcomeDeclaration}\n `,
+ ).replace('\n', `\n ${responseProcessing}\n`);
+ const withDefaultScore = (value, identifier = 'SCORE') =>
+ withScoring(
+ `
+ ${value}
+ `,
+ );
+ const withTemplate = attrs =>
+ withScoring(SCORE_DECLARATION, ``);
+ const withHintCard = card =>
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ '',
+ `\n ${card}`,
+ );
+ const EMPTY_HINT_CARD = '';
+
+ it.each([
+ ['hints', CHOICE_ITEM_DOCUMENT_WITH_HINTS],
+ ['xsi:schemaLocation', CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION],
+ ['converter metadata', withRoot('label="L" tool-name="kolibri" tool-version="0.1"')],
+ [
+ 'scoring the editor regenerates',
+ withTemplate(`template="${ResponseProcessingTemplate.MATCH_CORRECT}"`),
+ ],
+ [
+ 'a standard template with no .xml',
+ withTemplate('template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct"'),
+ ],
+ [
+ 'a standard template in its imsglobal.org form',
+ withTemplate(
+ 'template="http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct"',
+ ),
+ ],
+ [
+ 'a standard template by template-location',
+ withTemplate(`template-location="${ResponseProcessingTemplate.MATCH_CORRECT}"`),
+ ],
+ [
+ 'a standard template other than the one the editor writes',
+ withTemplate(`template="${ResponseProcessingTemplate.MAP_RESPONSE}"`),
+ ],
+ ['a zero default SCORE', withDefaultScore(0)],
+ ['a zero default RAW_SCORE that one response leaves unread', withDefaultScore(0, 'RAW_SCORE')],
+ [
+ 'math in a hint, in the namespace the converter leaves it',
+ withHintCard(
+ '',
+ ),
+ ],
+ [
+ 'a pretty-printed bare-text hint',
+ withHintCard(`
+
+ Try halving it first
+
+ `),
+ ],
+ ...[
+ ['a table', ''],
+ ['CDATA', ''],
+ ['a data attribute', 'a
'],
+ ['a div in a paragraph', 'a
'],
+ ].map(([name, html]) => [
+ `${name} in a hint, which the hint editor rewrites`,
+ withHintCard(
+ `${html}`,
+ ),
+ ]),
+ ['an empty hint card', withHintCard(EMPTY_HINT_CARD)],
+ [
+ 'only empty hint cards',
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ /[^]*<\/qti-catalog>/,
+ `${EMPTY_HINT_CARD}`,
+ ),
+ ],
+ ])('is true for an item with %s', (_, xml) => {
+ expect(keepsItemContent(xml)).toBe(true);
+ });
+
+ it.each([
+ [
+ 'hint cards in a catalog the body references by another id',
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ '',
+ '',
+ ).replace('', 'Term'),
+ ],
+ [
+ 'a non-hint card in the hint catalog',
+ withHintCard(
+ 'Term
',
+ ),
+ ],
+ [
+ 'a hint card in two languages',
+ withHintCard(`
+ Hi
+ Hola
+ `),
+ ],
+ [
+ 'a hint card pointing at a file',
+ withHintCard(
+ 'hint.html',
+ ),
+ ],
+ [
+ 'a response declaration no interaction uses',
+ withScoring(
+ '',
+ ),
+ ],
+ ['an unknown root attribute', withRoot('foo="bar"')],
+ [
+ 'adaptive="true"',
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace('adaptive="false"', 'adaptive="true"'),
+ ],
+ [
+ 'a language on a hint card',
+ CHOICE_ITEM_DOCUMENT_WITH_HINTS.replaceAll(
+ '',
+ '',
+ ),
+ ],
+ [
+ 'a language on hint content',
+ withHintCard(
+ 'Hola
',
+ ),
+ ],
+ [
+ 'a score maximum',
+ withScoring(
+ '',
+ ),
+ ],
+ ['a non-zero default SCORE', withDefaultScore(1)],
+ ['a non-zero default RAW_SCORE', withDefaultScore(1, 'RAW_SCORE')],
+ [
+ 'response processing the editor does not write',
+ withScoring(
+ '',
+ `
+ 2
+ `,
+ ),
+ ],
+ [
+ 'a template that is not a standard one',
+ withTemplate('template="https://example.com/rptemplates/match_correct.xml"'),
+ ],
+ ])('is false for an item with %s', (_, xml) => {
+ expect(keepsItemContent(xml)).toBe(false);
+ });
});
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 9881ac94e1..7d6793c221 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
@@ -12,32 +12,50 @@
import fs from 'fs';
import path from 'path';
import { parseItem } from '../parseItem';
-import { assembleItemXml } from '../assembleItem';
+import { assembleItemXml, keepsItemContent } from '../assembleItem';
import { isSupportedItem } from '../../interactions/resolveDescriptor';
+import { XSI_NS } from '../xml';
const FIXTURES = path.join(__dirname, '../../../../../../tests/utils/qti/fixtures');
const read = name => fs.readFileSync(path.join(FIXTURES, `${name}.xml`), 'utf8');
-const rebuild = item =>
- assembleItemXml({
+const rebuild = original => {
+ const item = parseItem(original);
+ return assembleItemXml({
identifier: item.identifier,
title: item.title,
language: item.language,
bodyXml: item.interactions[0].bodyXml,
responseDeclarations: item.interactions[0].responseDeclarations,
hints: item.hints,
+ sourceXml: original,
});
+};
+
+const FIXTURE_NAMES = fs
+ .readdirSync(FIXTURES)
+ .filter(file => file.endsWith('.xml'))
+ .map(file => path.basename(file, '.xml'));
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));
+ it.each(FIXTURE_NAMES)('%s', name => {
+ const xml = read(name);
+ const { interactions, itemBodyXml } = parseItem(xml);
expect(isSupportedItem(interactions, itemBodyXml)).toBe(true);
+ expect(keepsItemContent(xml)).toBe(true);
+ });
+
+ // Before 90b0a08b1 the converter wrote every template as match_correct with no .xml.
+ it.each(FIXTURE_NAMES)('%s, as converted before 90b0a08b1', name => {
+ const stored = read(name).replace(/rptemplates\/\w+\.xml/, 'rptemplates/match_correct');
+ expect(keepsItemContent(stored)).toBe(true);
+ });
+
+ // Before c51c5e035 the converter wrote the root language as `language`.
+ it.each(FIXTURE_NAMES)('%s, as converted before c51c5e035', name => {
+ const stored = read(name).replace(' xml:lang="', ' language="');
+ expect(keepsItemContent(stored)).toBe(true);
});
});
@@ -49,13 +67,13 @@ describe('a converted single-selection item', () => {
});
it('writes the language back as xml:lang, the attribute QTI declares', () => {
- const xml = rebuild(parseItem(original));
+ const xml = rebuild(original);
expect(xml).toContain('xml:lang="en-US"');
expect(xml).not.toContain(' language="');
});
it('keeps its scoring outcome and response processing', () => {
- const xml = rebuild(parseItem(original));
+ const xml = rebuild(original);
const doc = new DOMParser().parseFromString(xml, 'text/xml');
expect(doc.querySelector('parsererror')).toBeNull();
expect(doc.querySelector('qti-outcome-declaration').getAttribute('identifier')).toBe('SCORE');
@@ -64,8 +82,14 @@ describe('a converted single-selection item', () => {
);
});
+ it('keeps the root attributes the editor does not write', () => {
+ const root = new DOMParser().parseFromString(rebuild(original), 'text/xml').documentElement;
+ expect(root.getAttributeNS(XSI_NS, 'schemaLocation')).toContain('imsqti_asiv3p0p1_v1p0.xsd');
+ expect(root.getAttribute('tool-name')).toBe('kolibri');
+ });
+
it('puts the children in the order the schema fixes', () => {
- const xml = rebuild(parseItem(original));
+ const xml = rebuild(original);
const order = [...xml.matchAll(/<(qti-[a-z-]+)/g)]
.map(m => m[1])
.filter(tag =>
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js
index cee17841e5..b129e536bb 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js
@@ -3,9 +3,19 @@
* body, hints, and the outcome declarations and response processing that score it.
*/
+import { ResponseProcessingTemplate } from '../constants';
import { HINT_CATALOG_ID, HINT_SUPPORT, hintHasContent } from './hints';
+import { parseItem } from './parseItem';
import { QTIDeclaration } from './qti/QTIDeclaration';
-import { buildFloatNode, buildXmlNode, parseXML } from './xml';
+import {
+ XSI_NS,
+ attributesOf,
+ buildFloatNode,
+ buildXmlNode,
+ hasNonNamespaceAttributes,
+ isContentNode,
+ parseXML,
+} from './xml';
const serializer = new XMLSerializer();
@@ -153,6 +163,192 @@ function buildScoringNodes(declNodes) {
};
}
+/** Root metadata the converter writes and nothing in the item points at. */
+const METADATA_ROOT_ATTRIBUTES = ['label', 'tool-name', 'tool-version', 'language'];
+
+/**
+ * Whether an edit copies this source root attribute, which the editor doesn't write.
+ *
+ * @param {Attr} attr
+ * @returns {boolean}
+ */
+function isCarriedRootAttribute(attr) {
+ return (
+ (attr.namespaceURI === XSI_NS && attr.localName === 'schemaLocation') ||
+ (!attr.namespaceURI && METADATA_ROOT_ATTRIBUTES.includes(attr.localName))
+ );
+}
+
+/** @returns {Node[]} The node's children, less whitespace-only text */
+function contentChildrenOf(node) {
+ return [...node.childNodes].filter(isContentNode);
+}
+
+/**
+ * Whether this is a hint card in the shape the editor writes, whose content the hint editor
+ * owns.
+ *
+ * @param {Element} card
+ * @returns {boolean}
+ */
+function isHintCard(card) {
+ const attrs = attributesOf(card);
+ const [content, ...rest] = contentChildrenOf(card);
+ return (
+ attrs.length === 1 &&
+ attrs[0].name === 'support' &&
+ attrs[0].value === HINT_SUPPORT &&
+ !rest.length &&
+ (!content || (content.localName === 'qti-html-content' && !hasNonNamespaceAttributes(content)))
+ );
+}
+
+/**
+ * Whether an edit drops nothing the editor doesn't own by not writing this node as it is:
+ * - a hint card;
+ * - the hint catalog, holding only hint cards;
+ * - a zero default SCORE or RAW_SCORE, which every processing the editor writes sets first;
+ * - a RAW_SCORE declared as the editor declares it; the editor redeclares it wherever it reads it.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+function holdsNothing(node) {
+ switch (node.localName) {
+ case 'qti-card':
+ return isHintCard(node);
+ case 'qti-catalog':
+ return (
+ attributesOf(node).length === 1 &&
+ node.getAttribute('id') === HINT_CATALOG_ID &&
+ contentChildrenOf(node).every(holdsNothing)
+ );
+ case 'qti-catalog-info':
+ return contentChildrenOf(node).every(holdsNothing);
+ case 'qti-outcome-declaration':
+ return (
+ attributesOf(node).length === 3 &&
+ node.getAttribute('identifier') === RAW_SCORE &&
+ node.getAttribute('cardinality') === 'single' &&
+ node.getAttribute('base-type') === 'float' &&
+ contentChildrenOf(node).every(holdsNothing)
+ );
+ case 'qti-default-value':
+ return (
+ node.parentNode.localName === 'qti-outcome-declaration' &&
+ [SCORE, RAW_SCORE].includes(node.parentNode.getAttribute('identifier')) &&
+ node.children.length === 1 &&
+ parseFloat(node.textContent) === 0
+ );
+ default:
+ return false;
+ }
+}
+
+/**
+ * @param {Node} a
+ * @param {Node} b
+ * @returns {boolean} Whether two nodes hold the same content
+ */
+function isSameContent(a, b) {
+ if (a.nodeType !== Node.ELEMENT_NODE || b.nodeType !== Node.ELEMENT_NODE) {
+ return a.nodeType === b.nodeType && a.nodeValue === b.nodeValue;
+ }
+ const attrs = attributesOf(a);
+ const children = contentChildrenOf(a).filter(child => !holdsNothing(child));
+ const otherChildren = contentChildrenOf(b).filter(child => !holdsNothing(child));
+ return (
+ a.namespaceURI === b.namespaceURI &&
+ a.localName === b.localName &&
+ attrs.length === attributesOf(b).length &&
+ attrs.every(attr => b.getAttributeNS(attr.namespaceURI, attr.localName) === attr.value) &&
+ children.length === otherChildren.length &&
+ children.every((child, i) => isSameContent(child, otherChildren[i]))
+ );
+}
+
+/** Every spelling of a standard template that Kolibri resolves. */
+const STANDARD_TEMPLATE_URIS = new Set(
+ Object.values(ResponseProcessingTemplate).flatMap(uri => {
+ const withoutSuffix = uri.replace(/\.xml$/, '');
+ const name = withoutSuffix.split('/').pop();
+ return [uri, withoutSuffix, `http://www.imsglobal.org/question/qti_v3p0/rptemplates/${name}`];
+ }),
+);
+
+/**
+ * @param {Element} el
+ * @returns {boolean} Whether this is response processing by a standard template alone
+ */
+function isStandardTemplate(el) {
+ const attrs = attributesOf(el);
+ return (
+ el.localName === 'qti-response-processing' &&
+ attrs.length === 1 &&
+ ['template', 'template-location'].includes(attrs[0].localName) &&
+ STANDARD_TEMPLATE_URIS.has(attrs[0].value) &&
+ !el.children.length
+ );
+}
+
+/**
+ * @param {string} sourceXml
+ * @returns {boolean} Whether assembling the item unedited writes back each source root
+ * attribute and child as it was
+ */
+function writesBackItemContent(sourceXml) {
+ const item = parseItem(sourceXml);
+ const [interaction] = item.interactions;
+ const source = parseXML(sourceXml).documentElement;
+ const written = parseXML(
+ assembleItemXml({
+ identifier: item.identifier,
+ title: item.title,
+ language: item.language,
+ bodyXml: interaction.bodyXml,
+ responseDeclarations: interaction.responseDeclarations,
+ hints: item.hints,
+ sourceXml,
+ }),
+ ).documentElement;
+ return (
+ attributesOf(source).every(
+ attr => written.getAttributeNS(attr.namespaceURI, attr.localName) === attr.value,
+ ) &&
+ [...source.children].every(
+ el =>
+ el.localName === 'qti-item-body' ||
+ holdsNothing(el) ||
+ // Response processing is regenerated on every save, and a standard template is
+ // all of what it was.
+ isStandardTemplate(el) ||
+ [...written.children].some(writtenEl => isSameContent(el, writtenEl)),
+ )
+ );
+}
+
+/** Verdicts by source XML; each card and the validation getter ask about the same item. */
+const keepsItemContentCache = new Map();
+const KEEPS_ITEM_CONTENT_CACHE_SIZE = 500;
+
+/**
+ * Whether an edit keeps everything in this supported item outside its body. The editor shows
+ * any other item read-only: what an edit drops or rewrites could leave the rest pointing at
+ * nothing.
+ *
+ * @param {string} sourceXml
+ * @returns {boolean}
+ */
+export function keepsItemContent(sourceXml) {
+ if (!keepsItemContentCache.has(sourceXml)) {
+ if (keepsItemContentCache.size >= KEEPS_ITEM_CONTENT_CACHE_SIZE) {
+ keepsItemContentCache.delete(keepsItemContentCache.keys().next().value);
+ }
+ keepsItemContentCache.set(sourceXml, writesBackItemContent(sourceXml));
+ }
+ return keepsItemContentCache.get(sourceXml);
+}
+
/**
* Assembles a full QTI assessment-item XML string from its constituent parts.
*
@@ -168,6 +364,8 @@ function buildScoringNodes(declNodes) {
* @param {string} params.bodyXml - Serialized interaction element XML string
* @param {string[]} params.responseDeclarations - Array of serialized declaration XML strings
* @param {Array<{ content: string }>} [params.hints] - Item hints, in order
+ * @param {string} [params.sourceXml] - The item's XML before this edit, whose
+ * `xsi:schemaLocation` and converter metadata attributes are kept
* @returns {string} Full QTI XML string
*/
export function assembleItemXml({
@@ -177,6 +375,7 @@ export function assembleItemXml({
bodyXml,
responseDeclarations,
hints = [],
+ sourceXml,
}) {
// Parse each serialized declaration string back into a DOM node so it can be
// adopted into the assessment item tree via buildXmlNode's importNode logic.
@@ -221,6 +420,17 @@ export function assembleItemXml({
...(responseProcessing ? [responseProcessing] : []),
],
});
+ const sourceAttrs = sourceXml ? [...parseXML(sourceXml).documentElement.attributes] : [];
+ const carriedAttrs = sourceAttrs.filter(isCarriedRootAttribute);
+ // A root prefix declaration would make the serializer prefix the editor's own elements
+ // (qti:qti-item-body), so only those a carried attribute uses are copied.
+ const usedPrefixes = new Set(carriedAttrs.map(attr => attr.prefix));
+ const usedPrefixDeclarations = sourceAttrs.filter(
+ attr => attr.prefix === 'xmlns' && usedPrefixes.has(attr.localName),
+ );
+ for (const attr of [...usedPrefixDeclarations, ...carriedAttrs]) {
+ assessmentItemNode.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
+ }
return `\n${serializer.serializeToString(assessmentItemNode)}`;
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
index b5223546b4..08e8721b12 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js
@@ -133,6 +133,16 @@ function adoptNode(node, doc = xmlDoc, plainNamespace = XHTML_NS) {
const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
+export const XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance';
+
+/**
+ * @param {Element} el
+ * @returns {Attr[]} The element's attributes, less namespace declarations
+ */
+export function attributesOf(el) {
+ return [...el.attributes].filter(attr => attr.namespaceURI !== XMLNS_NS);
+}
+
/**
* Whether an element has attributes beyond namespace declarations.
*
@@ -140,7 +150,7 @@ const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
* @returns {boolean}
*/
export function hasNonNamespaceAttributes(el) {
- return [...el.attributes].some(attr => attr.namespaceURI !== XMLNS_NS);
+ return attributesOf(el).length > 0;
}
/**
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index 4096e3684e..fa332e0f8a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -2,6 +2,7 @@
import { QTI_INTERACTION_TAGS } from '../constants';
import { registry } from '../interactions/descriptors';
+import { XSI_NS } from '../serialization/xml';
export const CHOICE_SINGLE_SELECT_XML = `
Which planet is closest to the Sun?
@@ -333,6 +334,49 @@ export const CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_STIMULUS = CHOICE_ITEM_DOCUMENT
'Read the passage.
',
);
+/** A glossary catalog beside the hints, which the prompt points at. */
+export const CHOICE_ITEM_DOCUMENT_WITH_HINTS_AND_GLOSSARY = CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ 'Pick one.',
+ 'Pick one.',
+).replace(
+ ' ',
+ `
+
+ Exactly one.
+
+
+ `,
+);
+
+export const CHOICE_ITEM_DOCUMENT_WITH_SCHEMA_LOCATION = CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ 'xml:lang="en"\n>',
+ `xml:lang="en"
+ xmlns:xsi="${XSI_NS}"
+ xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd"
+>`,
+);
+
+export const CHOICE_ITEM_DOCUMENT_WITH_STYLESHEET = CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ '\n\n ',
+ `
+
+ `,
+);
+
+export const CHOICE_ITEM_DOCUMENT_WITH_MODAL_FEEDBACK = CHOICE_ITEM_DOCUMENT_WITH_HINTS.replace(
+ '\n\n ',
+ `
+
+ `,
+).replace(
+ '\n',
+ `
+
+ Well done.
+
+`,
+);
+
export const VALID_ASSOCIATE_ITEM_DOCUMENT = `
error).map(({ error }) => ({ code: error }));
}