Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ export const Orientation = Object.freeze({
* 2. QuestionType -> The type editors will select per assessment item.
* It's different from AssessmentItemType because we will extend this for all
* new question types without confusing it with values stored in the database
* (all of these will be assessment item type: "qti"). Value is related to how
* (all of these will be assessment item type: "QTI"). Value is related to how
* Studio presents different question options to users in the UI.
*
* 3. InteractionType (QtiInteraction) -> The actual interactions defined by QTI,
* and the ones that dictate how to parse and what descriptor we will use.
* Each QTI interaction can have multiple related question types (e.g., choice
* can be singleSelect or multiSelect), but all of them will have assessment
* item type "qti".
* item type "QTI".
*/

/**
Expand All @@ -66,7 +66,8 @@ export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction))
* by the broader Studio assessment system, not by this editor.
*/
export const AssessmentItemTypes = Object.freeze({
QTI: 'qti',
// Matches the value the API stores and returns (le_utils exercises.QTI).
QTI: 'QTI',
});

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,28 @@ describe('buildTextEntryInteractionXML', () => {
expect(doc.querySelector('qti-item-body')).not.toBeNull();
});

it('leaves no xhtml namespace on the prompt markup', () => {
// The prompt comes from the HTML parser; an explicit xmlns on it makes the whole
// item fail schema validation on the server.
const { bodyXml } = buildTextEntryInteractionXML(
{ prompt: '<p>What is <strong>H2O</strong>?</p>', answers: [], expectedLength: 0 },
QuestionType.FREE_RESPONSE,
FREE_SCHEMA,
);
expect(bodyXml).not.toContain('http://www.w3.org/1999/xhtml');
});

it('keeps the prompt before the interaction', () => {
const { bodyXml } = buildTextEntryInteractionXML(
{ prompt: '<p>Question</p>', answers: [], expectedLength: 0 },
QuestionType.FREE_RESPONSE,
FREE_SCHEMA,
);
expect(bodyXml.indexOf('Question')).toBeLessThan(
bodyXml.indexOf('qti-text-entry-interaction'),
);
});

it('contains a <qti-text-entry-interaction> element', () => {
const { bodyXml } = buildTextEntryInteractionXML(
{ prompt: '', answers: [], expectedLength: 0 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,10 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch
children: [interactionEl],
});

// Build body children: prompt HTML nodes (if any) followed by the interaction paragraph.
const bodyChildren = [];
if (prompt) {
const promptDoc = parseXML(`<!DOCTYPE html><body>${prompt}</body>`, 'text/html');
bodyChildren.push(...promptDoc.body.childNodes);
}
bodyChildren.push(interactionParagraph);

const bodyEl = buildXmlNode({ tag: 'qti-item-body', children: bodyChildren });
// The prompt is authored HTML, so it goes in through innerHTML: buildXmlNode parses it
// and adopts the result into the item's namespace.
const bodyEl = buildXmlNode({ tag: 'qti-item-body', innerHTML: prompt || '' });
bodyEl.appendChild(interactionParagraph);
const bodyXml = serializer.serializeToString(bodyEl);

// Build the response declaration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 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 { buildXmlNode, assembleItemXml } from '../assembleItem.js';
import { parseItem } from '../parseItem.js';

const serializer = new XMLSerializer();

Expand Down Expand Up @@ -142,6 +143,38 @@ describe('assembleItem', () => {
buildXmlNode({ tag: 'qti-simple-choice', children: ['x'], innerHTML: '<p>y</p>' }),
).toThrow('mutually exclusive');
});

it('leaves no xhtml namespace on the markup it appends', () => {
// The QTI schema expects inline content in the namespace the item root declares, so
// an explicit xmlns from the HTML parser makes the whole item invalid on the server.
const node = buildXmlNode({
tag: 'qti-simple-choice',
innerHTML: '<p>Lima</p>',
});
expect(new XMLSerializer().serializeToString(node)).toBe(
'<qti-simple-choice><p>Lima</p></qti-simple-choice>',
);
});

it('drops an xhtml namespace already carried by stored content', () => {
const node = buildXmlNode({
tag: 'qti-simple-choice',
innerHTML: '<p xmlns="http://www.w3.org/1999/xhtml">Lima</p>',
});
expect(new XMLSerializer().serializeToString(node)).toBe(
'<qti-simple-choice><p>Lima</p></qti-simple-choice>',
);
});

it('keeps a foreign namespace, which QTI expects declared', () => {
const node = buildXmlNode({
tag: 'qti-prompt',
innerHTML: '<math xmlns="http://www.w3.org/1998/Math/MathML"><mi>x</mi></math>',
});
expect(new XMLSerializer().serializeToString(node)).toContain(
'<math xmlns="http://www.w3.org/1998/Math/MathML">',
);
});
});

describe('innerHTML — HTML5 void elements (TipTap regression)', () => {
Expand Down Expand Up @@ -248,4 +281,79 @@ describe('assembleItemXml', () => {
expect(doc.querySelector('parsererror')).toBeNull();
expect(doc.querySelector('qti-assessment-item').getAttribute('title')).toBe('Plain Title');
});

describe('namespaces', () => {
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
const QTI_NS = 'http://www.imsglobal.org/xsd/imsqtiasi_v3p0';

// buildXmlNode gets this right on its own, but the body is handed here as a string
// and parsed again, so the declaration has to survive that too. A <math> that ends
// up inheriting the QTI namespace makes the server reject the whole item.
it('keeps a foreign namespace declared inside the body', () => {
const prompt = buildXmlNode({
tag: 'qti-prompt',
innerHTML: `<p>What is <math xmlns="${MATHML_NS}"><mi>x</mi></math>?</p>`,
});
const interaction = buildXmlNode({
tag: 'qti-choice-interaction',
attrs: { 'response-identifier': 'RESPONSE' },
children: [prompt],
});

const xml = assembleItemXml({
identifier: 'item-math',
title: 'T',
language: 'en',
bodyXml: serializer.serializeToString(interaction),
responseDeclarations: [],
});

expect(
new DOMParser().parseFromString(xml, 'text/xml').querySelector('math').namespaceURI,
).toBe(MATHML_NS);
});

// An interaction the author did not touch is handed back exactly as parseItem read
// it, still carrying the item's own declaration. Re-declaring the same namespace is
// redundant but valid, and it must not cost the foreign one nested inside.
it('assembles a body whose root already declares the QTI namespace', () => {
const xml = assembleItemXml({
identifier: 'item-math',
title: 'T',
language: 'en',
bodyXml:
`<qti-choice-interaction xmlns="${QTI_NS}" response-identifier="RESPONSE">` +
`<qti-prompt><p>What is <math xmlns="${MATHML_NS}"><mi>x</mi></math>?</p></qti-prompt>` +
`</qti-choice-interaction>`,
responseDeclarations: [],
});

const doc = new DOMParser().parseFromString(xml, 'text/xml');
expect(doc.querySelector('parsererror')).toBeNull();
expect(doc.querySelector('qti-choice-interaction').namespaceURI).toBe(QTI_NS);
expect(doc.querySelector('math').namespaceURI).toBe(MATHML_NS);
});

it('preserves MathML through a parseItem round trip', () => {
const original =
'<?xml version="1.0" encoding="UTF-8"?>\n' +
`<qti-assessment-item xmlns="${QTI_NS}" identifier="item-math" title="T" adaptive="false" time-dependent="false" xml:lang="en">` +
'<qti-item-body><qti-choice-interaction response-identifier="RESPONSE">' +
`<qti-prompt><p>What is <math xmlns="${MATHML_NS}"><mi>x</mi></math>?</p></qti-prompt>` +
'</qti-choice-interaction></qti-item-body></qti-assessment-item>';

const item = parseItem(original);
const xml = assembleItemXml({
identifier: item.identifier,
title: item.title,
language: item.language,
bodyXml: item.interactions[0].bodyXml,
responseDeclarations: item.interactions[0].responseDeclarations,
});

expect(
new DOMParser().parseFromString(xml, 'text/xml').querySelector('math').namespaceURI,
).toBe(MATHML_NS);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* The editor's read/write pair against what the converter actually emits.
*
* These read the converter's own fixtures rather than a copy, because the bug this file
* exists to catch was a disagreement between the two: `testingFixtures.js` is hand-written
* and carries no grading declarations, so nothing noticed that a converted item loses its
* scoring rules on the first save.
*/
// Disabled because jest-dom's matchers are built for HTML elements and reject the strict
// XML nodes this serialization produces — same reason as assembleItem.spec.js.
/* eslint-disable jest-dom/prefer-to-have-attribute */
import fs from 'fs';
import path from 'path';
import { parseItem } from '../parseItem';
import { assembleItemXml } from '../assembleItem';

const FIXTURES = path.join(__dirname, '../../../../../../tests/utils/qti/fixtures');
Comment thread
AlexVelezLl marked this conversation as resolved.

const read = name => fs.readFileSync(path.join(FIXTURES, `${name}.xml`), 'utf8');

const rebuild = item =>
assembleItemXml({
identifier: item.identifier,
title: item.title,
language: item.language,
bodyXml: item.interactions[0].bodyXml,
responseDeclarations: item.interactions[0].responseDeclarations,
hints: item.hints,
});

describe('a converted single-selection item', () => {
const original = read('single_selection');

it('is read with the language the converter wrote', () => {
expect(parseItem(original).language).toBe('en-US');
});

it('writes the language back as xml:lang, the attribute QTI declares', () => {
const xml = rebuild(parseItem(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 doc = new DOMParser().parseFromString(xml, 'text/xml');
expect(doc.querySelector('parsererror')).toBeNull();
expect(doc.querySelector('qti-outcome-declaration').getAttribute('identifier')).toBe('SCORE');
expect(doc.querySelector('qti-response-processing').getAttribute('template')).toBe(
'https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct',
);
});

it('puts the children in the order the schema fixes', () => {
const xml = rebuild(parseItem(original));
const order = [...xml.matchAll(/<(qti-[a-z-]+)/g)]
.map(m => m[1])
.filter(tag =>
[
'qti-response-declaration',
'qti-outcome-declaration',
'qti-item-body',
'qti-response-processing',
].includes(tag),
);
expect(order).toEqual([
'qti-response-declaration',
'qti-outcome-declaration',
'qti-item-body',
'qti-response-processing',
]);
});
});

describe('an item this editor wrote', () => {
it('keeps xml:lang, the spelling this editor uses', () => {
const xml = assembleItemXml({
identifier: 'i',
title: 't',
language: 'es',
bodyXml: '<qti-choice-interaction response-identifier="RESPONSE"/>',
responseDeclarations: ['<qti-response-declaration identifier="RESPONSE"/>'],
});
expect(xml).toContain('xml:lang="es"');
});

it('omits the language rather than inventing one', () => {
const xml = assembleItemXml({
identifier: 'i',
title: 't',
language: '',
bodyXml: '<qti-choice-interaction response-identifier="RESPONSE"/>',
responseDeclarations: [],
});
expect(xml).not.toContain('xml:lang');
expect(xml).not.toContain('language=');
});

it('scores nothing when there is nothing to answer', () => {
const xml = assembleItemXml({
identifier: 'i',
title: 't',
language: 'en',
bodyXml: '<qti-item-body><p>Just text.</p></qti-item-body>',
responseDeclarations: [],
});
expect(xml).toContain('qti-outcome-declaration');
expect(xml).not.toContain('qti-response-processing');
});
});
Loading