diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js index 10fd096bab..2b3a42d659 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js @@ -9,6 +9,7 @@ const makeValidDescriptor = (overrides = {}) => ({ convertsFrom: [], matches: () => false, getQuestionType: () => null, + getDeclarationSchema: () => ({ baseType: 'string', cardinality: 'single' }), parse: () => ({}), validate: () => [], ...overrides, @@ -28,6 +29,7 @@ describe('defineInteraction', () => { 'convertsFrom', 'matches', 'getQuestionType', + 'getDeclarationSchema', 'parse', 'validate', ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js index b7ffc94d70..ec11abe297 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js @@ -1,5 +1,5 @@ import defineInteraction from '../defineInteraction'; -import { QtiInteraction, QuestionType } from '../../constants'; +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; import ChoiceInteractionEditor from './ChoiceInteractionEditor.vue'; /** @@ -48,6 +48,21 @@ export default defineInteraction({ : QuestionType.MULTI_SELECT; }, + /** + * Defines the structural schema for the response declaration of this interaction. + * Returns baseType and cardinality, which can depend on the selected questionType. + * + * @param {string} questionType + * @returns {{ baseType: string, cardinality: string }} + */ + getDeclarationSchema(questionType) { + return { + baseType: BaseType.IDENTIFIER, + cardinality: + questionType === QuestionType.SINGLE_SELECT ? Cardinality.SINGLE : Cardinality.MULTIPLE, + }; + }, + /** * Extracts a structured state object from the interaction element. * Stub — full parsing is a future task. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js index 2c76726529..ae910c7c6c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js @@ -10,6 +10,7 @@ const REQUIRED_KEYS = [ 'convertsFrom', 'matches', 'getQuestionType', + 'getDeclarationSchema', 'parse', 'validate', ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index d43baf93e0..e951f2b8ae 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -21,3 +21,13 @@ export const descriptors = [choiceDescriptor]; * @type {Object.} */ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); + +/** + * Find the interaction descriptor that supports a given question type. + * + * @param {string} questionType + * @returns {import('./defineInteraction').InteractionDescriptor|undefined} + */ +export function getDescriptorForQuestionType(questionType) { + return descriptors.find(d => d.questionTypes.includes(questionType)); +} 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 new file mode 100644 index 0000000000..24357fe5c7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js @@ -0,0 +1,111 @@ +// 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 { buildXmlNode } from '../assembleItem.js'; + +const serializer = new XMLSerializer(); + +describe('assembleItem', () => { + describe('tag and attributes', () => { + it('creates an element with the given tag name', () => { + const node = buildXmlNode({ tag: 'qti-response-declaration' }); + expect(node.tagName).toBe('qti-response-declaration'); + }); + + it('sets string attributes', () => { + const node = buildXmlNode({ + tag: 'qti-response-declaration', + attrs: { identifier: 'RESPONSE', cardinality: 'single', 'base-type': 'identifier' }, + }); + expect(node.getAttribute('identifier')).toBe('RESPONSE'); + expect(node.getAttribute('cardinality')).toBe('single'); + expect(node.getAttribute('base-type')).toBe('identifier'); + }); + + it('coerces numeric attribute values to strings', () => { + const node = buildXmlNode({ tag: 'qti-map-entry', attrs: { 'mapped-value': 1.5 } }); + expect(node.getAttribute('mapped-value')).toBe('1.5'); + }); + + it('skips null attribute values', () => { + const node = buildXmlNode({ tag: 'qti-response-declaration', attrs: { 'base-type': null } }); + expect(node.hasAttribute('base-type')).toBe(false); + }); + + it('skips undefined attribute values', () => { + const node = buildXmlNode({ + tag: 'qti-response-declaration', + attrs: { 'base-type': undefined }, + }); + expect(node.hasAttribute('base-type')).toBe(false); + }); + + it('produces an element with no attributes when attrs is omitted', () => { + const node = buildXmlNode({ tag: 'qti-correct-response' }); + expect(node.attributes.length).toBe(0); + }); + }); + + describe('string children', () => { + it('appends a text node for a string child', () => { + const node = buildXmlNode({ tag: 'qti-value', children: ['ChoiceA'] }); + expect(node.textContent).toBe('ChoiceA'); + }); + + it('handles multiple string children as separate text nodes', () => { + const node = buildXmlNode({ tag: 'qti-value', children: ['hello', ' ', 'world'] }); + expect(node.textContent).toBe('hello world'); + }); + }); + + describe('element children', () => { + it('appends child element nodes', () => { + const child = buildXmlNode({ tag: 'qti-value', children: ['ChoiceA'] }); + const parent = buildXmlNode({ tag: 'qti-correct-response', children: [child] }); + const childEls = parent.getElementsByTagName('qti-value'); + expect(childEls.length).toBe(1); + expect(childEls[0].textContent).toBe('ChoiceA'); + }); + + it('appends multiple element children in order', () => { + const childA = buildXmlNode({ tag: 'qti-value', children: ['A'] }); + const childB = buildXmlNode({ tag: 'qti-value', children: ['B'] }); + const parent = buildXmlNode({ tag: 'qti-correct-response', children: [childA, childB] }); + const values = [...parent.getElementsByTagName('qti-value')].map(n => n.textContent); + expect(values).toEqual(['A', 'B']); + }); + }); + + describe('mixed children', () => { + it('handles a mix of element and string children', () => { + const child = buildXmlNode({ tag: 'qti-value', children: ['X'] }); + const parent = buildXmlNode({ tag: 'qti-correct-response', children: ['prefix', child] }); + // First child is a text node + expect(parent.childNodes[0].nodeType).toBe(Node.TEXT_NODE); + expect(parent.childNodes[0].textContent).toBe('prefix'); + // Second child is the element + expect(parent.childNodes[1].tagName).toBe('qti-value'); + }); + }); + + describe('serialization', () => { + it('round-trips through XMLSerializer', () => { + const node = buildXmlNode({ + tag: 'qti-response-declaration', + attrs: { identifier: 'RESPONSE', cardinality: 'single', 'base-type': 'identifier' }, + children: [ + buildXmlNode({ + tag: 'qti-correct-response', + children: [buildXmlNode({ tag: 'qti-value', children: ['ChoiceA'] })], + }), + ], + }); + + const xml = serializer.serializeToString(node); + expect(xml).toContain('qti-response-declaration'); + expect(xml).toContain('identifier="RESPONSE"'); + expect(xml).toContain('qti-correct-response'); + expect(xml).toContain('ChoiceA'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js new file mode 100644 index 0000000000..693c84f2b3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -0,0 +1,41 @@ +/** + * XML node builder for QTI serialization. + * + * Used by QTIDeclaration.getXML() and all declaration strategy classes + * to produce DOM nodes. A module-level XML document is created once so + * all nodes share the same owner document, avoiding adoptNode requirements + * when assembling trees. Callers serialize to a string only at the boundary + * (e.g. XMLSerializer.serializeToString). + */ + +const xmlDoc = new DOMParser().parseFromString('', 'text/xml'); + +/** + * Build an XML element node. + * + * @param {object} options + * @param {string} options.tag - Element tag name (e.g. 'qti-response-declaration') + * @param {Object.} [options.attrs] - Attribute name→value pairs; + * null/undefined values are skipped + * @param {Array.} [options.children] - Child nodes or plain strings + * @returns {Element} + */ +export function buildXmlNode({ tag, attrs = {}, children = [] }) { + const el = xmlDoc.createElement(tag); + + for (const [name, value] of Object.entries(attrs)) { + if (value !== null && value !== undefined) { + el.setAttribute(name, String(value)); + } + } + + for (const child of children) { + if (typeof child === 'string') { + el.appendChild(xmlDoc.createTextNode(child)); + } else { + el.appendChild(child); + } + } + + return 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 new file mode 100644 index 0000000000..3b028d22a5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -0,0 +1,363 @@ +/** + * QTIDeclaration — authoring-side structural model of a QTI declaration element. + * + * Reads identifier, base-type, cardinality and capability children (correctResponse, + * defaultValue, mapping, areaMapping) from XML, holds them as plain JS data with + * native JS types (number, boolean, string) based on the declaration's base-type, + * and serializes back to XML on demand. Carries no runtime value state or scoring logic. + * + */ +import { buildXmlNode } from '../assembleItem.js'; +import { getDescriptorForQuestionType } from '../../interactions/index.js'; +import { BaseType, Cardinality } from '../../constants.js'; +import { declarationParsers, CAPABILITY } from './declarations/index.js'; + +/** + * Base types whose single-cardinality values are represented as 2-element arrays + * in JS (e.g., point [100, 200], pair ["A", "B"], directedPair ["A", "B"]). + * Distinguished from container arrays (multiple/ordered cardinality, N elements). + */ +const COMPOUND_VALUE_TYPES = new Set([BaseType.POINT, BaseType.PAIR, BaseType.DIRECTED_PAIR]); + +export class QTIDeclaration { + /** + * @param {object} options + * @param {string} options.identifier - The QTI identifier attribute + * @param {string|null} [options.baseType] - The QTI base-type attribute (null when absent) + * @param {string} [options.cardinality] - 'single' | 'multiple' | 'ordered' | 'record' + * @param {string} [options.tag] - Element tag name used when serializing + */ + constructor({ + identifier, + baseType = null, + cardinality = Cardinality.SINGLE, + tag = 'qti-response-declaration', + }) { + const IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_.-]*$/u; + if (!identifier || !IDENTIFIER_RE.test(identifier)) { + throw new Error( + `QTIDeclaration: invalid identifier "${identifier}". ` + + `Must start with a letter or underscore and contain only word characters, hyphens, or dots.`, + ); + } + + if (cardinality === Cardinality.RECORD) { + throw new Error('cardinality="record" is not yet supported'); + } + + const validCardinalities = new Set( + Object.values(Cardinality).filter(c => c !== Cardinality.RECORD), + ); + if (!validCardinalities.has(cardinality)) { + throw new Error( + `QTIDeclaration: invalid cardinality "${cardinality}". ` + + `Must be one of: ${[...validCardinalities].join(', ')}.`, + ); + } + + if (baseType !== null && !Object.values(BaseType).includes(baseType)) { + throw new Error( + `QTIDeclaration: invalid base-type "${baseType}". ` + + `Must be one of: ${Object.values(BaseType).join(', ')}.`, + ); + } + + if (tag !== 'qti-response-declaration' && tag !== 'qti-outcome-declaration') { + throw new Error( + `QTIDeclaration: invalid tag "${tag}". ` + + `Must be 'qti-response-declaration' or 'qti-outcome-declaration'.`, + ); + } + + this.tag = tag; + this.identifier = identifier; + this.baseType = baseType; + this.cardinality = cardinality; + + /** @type {Object.} */ + this._capabilities = {}; + } + + // --------------------------------------------------------------------------- + // Capability registration + // --------------------------------------------------------------------------- + + /** + * Register a named capability on this declaration. + * Called as a side-effect by declaration strategy classes during their constructors. + * + * @param {string} name - One of the CAPABILITY constants + * @param {{ get(): *, getXML(): Element }} declarationObject + */ + registerCapability(name, declarationObject) { + this._capabilities[name] = declarationObject; + } + + // --------------------------------------------------------------------------- + // Convenience getters + // --------------------------------------------------------------------------- + + /** @type {Array|null} */ + get correctResponse() { + return this._capabilities[CAPABILITY.CORRECT_RESPONSE]?.get() ?? null; + } + + /** @type {Array|null} */ + get defaultValue() { + return this._capabilities[CAPABILITY.DEFAULT_VALUE]?.get() ?? null; + } + + /** @type {object|null} */ + get mapping() { + return this._capabilities[CAPABILITY.MAPPING]?.get() ?? null; + } + + /** @type {object|null} */ + get areaMapping() { + return this._capabilities[CAPABILITY.AREA_MAPPING]?.get() ?? null; + } + + // --------------------------------------------------------------------------- + // Value coercion and formatting + // --------------------------------------------------------------------------- + + /** + * Whether this declaration's base-type uses a 2-element array as its + * native JS representation (point, pair, directedPair). + * + * Useful for callers that need to distinguish compound values from containers. + * + * @returns {boolean} + */ + get isCompoundType() { + return COMPOUND_VALUE_TYPES.has(this.baseType); + } + + /** + * Coerce a single raw XML string value to its native JS type. + * + * Follows the Kolibri QTI implementation — each base-type maps to a + * specific JS primitive or 2-element array (point/pair/directedPair). + * Returns null for empty, null, undefined, or 'NULL' inputs. + * + * @param {string|null|undefined} raw - Raw text from a element + * @param {string|null} baseType - One of BaseType.* constants + * @returns {string|number|boolean|Array|null} + * @throws {TypeError} If the raw value is structurally incompatible with baseType + */ + static coerceValue(raw, baseType) { + // QTI treats empty / null as NULL — see qti-is-null spec + if (raw === null || raw === undefined || raw === '' || raw === 'NULL') { + return null; + } + + switch (baseType) { + case BaseType.BOOLEAN: + if (raw !== 'true' && raw !== 'false') { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to boolean`); + } + return raw === 'true'; + + case BaseType.INTEGER: { + const n = parseInt(raw, 10); + if (Number.isNaN(n)) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to integer`); + } + return n; + } + + case BaseType.FLOAT: { + const n = parseFloat(raw); + if (Number.isNaN(n)) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to float`); + } + return n; + } + + case BaseType.POINT: { + const parts = Array.isArray(raw) + ? raw.map(v => parseInt(v, 10)) + : raw + .trim() + .split(/\s+/) + .map(v => parseInt(v, 10)); + if (parts.length !== 2 || parts.some(Number.isNaN)) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to point`); + } + return parts; + } + + case BaseType.PAIR: + case BaseType.DIRECTED_PAIR: { + const parts = Array.isArray(raw) ? raw.map(String) : raw.trim().split(/\s+/); + if (parts.length !== 2) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to ${baseType}`); + } + return parts; + } + + case BaseType.DURATION: { + const n = parseFloat(raw); + if (Number.isNaN(n) || n < 0) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to duration`); + } + return n; + } + + case BaseType.STRING: + case BaseType.IDENTIFIER: + case BaseType.URI: + if (typeof raw !== 'string') { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to ${baseType}`); + } + return raw; + + case BaseType.FILE: + // In the authoring editor, files are stored as raw strings (e.g. base64/URI). + return raw; + + default: + return raw; + } + } + + /** + * Format a native JS value back to its QTI XML string representation. + * + * This is the inverse of coerceValue — safe to call on any JS primitive. + * Compound types (point, pair, directedPair) stored as 2-element arrays + * are joined with a space, matching the QTI XML format (e.g. "100 200"). + * + * @param {string|number|boolean|Array|null} value + * @returns {string} + */ + static formatValue(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (Array.isArray(value)) return value.join(' '); // point / pair / directedPair + return String(value); + } + + /** + * Coerce a raw XML string value to its native JS type based on this declaration's baseType. + * + * @param {string|null|undefined} raw + * @returns {string|number|boolean|Array|null} + */ + coerceValue(raw) { + return QTIDeclaration.coerceValue(raw, this.baseType); + } + + /** + * Format a native JS value back to its QTI XML string representation. + * + * @param {string|number|boolean|Array|null} value + * @returns {string} + */ + formatValue(value) { + return QTIDeclaration.formatValue(value); + } + + /** + * Coerce an array of raw XML strings using this declaration's baseType. + * + * @param {string[]} rawStrings + * @returns {Array} + */ + coerceValues(rawStrings) { + return rawStrings.map(v => this.coerceValue(v)); + } + + /** + * Format an array of native JS values to QTI XML strings. + * + * @param {Array} values + * @returns {string[]} + */ + formatValues(values) { + return values.map(v => this.formatValue(v)); + } + + // --------------------------------------------------------------------------- + // XML entry point + // --------------------------------------------------------------------------- + + /** + * Build a QTIDeclaration from a QTI declaration XML element. + * + * Reads identifier / base-type / cardinality from attributes, then iterates + * child elements and delegates each to the declarationParsers registry. + * Each parser registers itself as a capability as a side-effect. + * + * @param {Element} xmlNode - e.g. or + * @returns {QTIDeclaration} + */ + static fromXML(xmlNode) { + const identifier = xmlNode.getAttribute('identifier') ?? ''; + const baseType = xmlNode.getAttribute('base-type') ?? null; + const cardinality = xmlNode.getAttribute('cardinality') ?? Cardinality.SINGLE; + const tag = xmlNode.tagName.toLowerCase(); + + const declaration = new QTIDeclaration({ identifier, baseType, cardinality, tag }); + + for (const child of xmlNode.children) { + const childTag = child.tagName.toLowerCase(); + const Decl = declarationParsers[childTag]; + if (Decl) { + Decl.fromXML(child, declaration); + } + } + + return declaration; + } + + // --------------------------------------------------------------------------- + // Serialization + // --------------------------------------------------------------------------- + + /** + * Serialize this declaration to an XML element, including all registered + * capability child nodes. + * + * @returns {Element} + */ + getXML() { + const attrs = { + identifier: this.identifier, + cardinality: this.cardinality, + }; + // base-type is omitted when null (e.g. some outcome declarations) + if (this.baseType !== null) { + attrs['base-type'] = this.baseType; + } + + const children = Object.values(this._capabilities).map(cap => cap.getXML()); + + return buildXmlNode({ tag: this.tag, attrs, children }); + } + + // --------------------------------------------------------------------------- + // Type-schema factory and conversion + // --------------------------------------------------------------------------- + + /** + * Create a blank QTIDeclaration shaped for the given question type. + * Delegates to the factory registered by each interaction module. + * + * @param {string} questionType - One of QuestionType.* (must have a registered factory) + * @param {string} [identifier] - Response identifier, defaults to 'RESPONSE' + * @param {*} [itemData] - Optional item data forwarded to the factory + * @returns {QTIDeclaration} + */ + static forType(questionType, identifier = 'RESPONSE', itemData = null) { + const descriptor = getDescriptorForQuestionType(questionType); + if (!descriptor) throw new Error(`Unknown question type: ${questionType}`); + const schema = descriptor.getDeclarationSchema(questionType, itemData); + return new QTIDeclaration({ + identifier, + baseType: schema.baseType, + cardinality: schema.cardinality, + tag: 'qti-response-declaration', + }); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js new file mode 100644 index 0000000000..b5e26e9b4f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js @@ -0,0 +1,128 @@ +/** + * QTISanitizer — input validation and sanitization at every data entry boundary. + * + * @module serialization/qti/QTISanitizer + */ + +// Valid QTI 3.0 base-type values — https://www.imsglobal.org/spec/qti/v3p0/impl/#h.wq4e8lbs4wa9 +const VALID_BASE_TYPES = new Set([ + 'boolean', + 'directedPair', + 'duration', + 'file', + 'float', + 'identifier', + 'integer', + 'pair', + 'point', + 'string', + 'uri', +]); + +// Valid QTI 3.0 cardinality values — https://www.imsglobal.org/spec/qti/v3p0/impl/#h.wq4e8lbs4wa9 +const VALID_CARDINALITIES = new Set(['single', 'multiple', 'ordered', 'record']); + +// QTI identifier pattern — Unicode-aware NCName (\w is ASCII-only in JS) +const IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_.-]*$/u; + +export class QTISanitizer { + /** + * Validate a QTI base-type string. + * Throws if the value is not in the spec-defined set. + * + * @param {string} baseType + * @returns {string} The same value if valid + * @throws {Error} + */ + static validateBaseType(baseType) { + if (baseType === null || baseType === undefined) return baseType; + if (!VALID_BASE_TYPES.has(baseType)) { + throw new Error( + `QTISanitizer: invalid base-type "${baseType}". ` + + `Must be one of: ${[...VALID_BASE_TYPES].join(', ')}.`, + ); + } + return baseType; + } + + /** + * Validate a QTI cardinality string. + * Throws if the value is not in the spec-defined set. + * + * @param {string} cardinality + * @returns {string} The same value if valid + * @throws {Error} + */ + static validateCardinality(cardinality) { + if (!VALID_CARDINALITIES.has(cardinality)) { + throw new Error( + `QTISanitizer: invalid cardinality "${cardinality}". ` + + `Must be one of: ${[...VALID_CARDINALITIES].join(', ')}.`, + ); + } + return cardinality; + } + + /** + * Validate a QTI identifier (NCName-based). + * Throws if the value does not match the QTI identifier pattern. + * + * @param {string} identifier + * @returns {string} The same value if valid + * @throws {Error} + */ + static validateIdentifier(identifier) { + if (!identifier || !IDENTIFIER_RE.test(identifier)) { + throw new Error( + `QTISanitizer: invalid QTI identifier "${identifier}". ` + + `Must start with a letter or underscore and contain only word characters, hyphens, or dots.`, + ); + } + return identifier; + } + + /** + * Strip HTML tags from a string value. + * + * @param {string} value - Raw string (identifier, float string, etc.) + * @returns {string} Value with all HTML tags stripped and text content extracted + */ + static stripTags(value) { + if (typeof value !== 'string') return String(value ?? ''); + // Fast path: if no `<` is present there is nothing to strip. + if (!value.includes('<')) return value; + const doc = new DOMParser().parseFromString(value, 'text/html'); + // Remove script and style elements entirely — we do NOT want their text content. + doc.querySelectorAll('script, style').forEach(el => el.remove()); + return doc.body.textContent ?? ''; + } + + /** + * Parse a string as a float and validate it is a finite number. + * Throws if the value is not a valid finite number. + * + * @param {string|number} value + * @param {string} [fieldName] - Label for error messages + * @returns {number} + * @throws {Error} + */ + static parseFloat(value, fieldName = 'value') { + const n = Number(value); + if (!isFinite(n)) { + throw new Error(`QTISanitizer: "${fieldName}" must be a finite number, got "${value}".`); + } + return n; + } + + /** + * Sanitize an array of string values (e.g. correctResponse, defaultValue). + * Trims whitespace and strips HTML tags from each entry. + * Removes entries that are empty after sanitization. + * + * @param {string[]} values + * @returns {string[]} + */ + static sanitizeValues(values) { + return values.map(v => QTISanitizer.stripTags(String(v).trim())).filter(v => v.length > 0); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js new file mode 100644 index 0000000000..81d257bf4d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js @@ -0,0 +1,377 @@ +// 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 { QTIDeclaration } from '../QTIDeclaration.js'; +import { CAPABILITY } from '../declarations/index.js'; +import { parseXML, reparse, serializeXML } from './testUtils.js'; +import { + DECLARATION_WITH_MAPPING, + MULTI_SELECT_DECLARATION, + NO_BASETYPE_DECLARATION, + OUTCOME_DECLARATION, + SINGLE_SELECT_DECLARATION, +} from './fixtures/declarations.js'; + +describe('QTIDeclaration constructor', () => { + it('stores scalar fields', () => { + const d = new QTIDeclaration({ + identifier: 'RESPONSE', + baseType: 'identifier', + cardinality: 'single', + }); + expect(d.identifier).toBe('RESPONSE'); + expect(d.baseType).toBe('identifier'); + expect(d.cardinality).toBe('single'); + }); + + it('defaults baseType to null and cardinality to single', () => { + const d = new QTIDeclaration({ identifier: 'SCORE' }); + expect(d.baseType).toBeNull(); + expect(d.cardinality).toBe('single'); + }); + + it('throws an error if cardinality is "record"', () => { + expect(() => { + new QTIDeclaration({ identifier: 'X', cardinality: 'record' }); + }).toThrow('cardinality="record" is not yet supported'); + }); + + it('starts with no capabilities', () => { + const d = new QTIDeclaration({ identifier: 'X' }); + expect(d.correctResponse).toBeNull(); + expect(d.defaultValue).toBeNull(); + expect(d.mapping).toBeNull(); + expect(d.areaMapping).toBeNull(); + }); + + it('defaults tag to qti-response-declaration', () => { + const d = new QTIDeclaration({ identifier: 'X' }); + expect(d.tag).toBe('qti-response-declaration'); + }); +}); + +describe('QTIDeclaration.registerCapability', () => { + it('stores and exposes a capability via its getter', () => { + const d = new QTIDeclaration({ identifier: 'X' }); + const fakeCR = { get: () => ['A'], getXML: () => null }; + d.registerCapability(CAPABILITY.CORRECT_RESPONSE, fakeCR); + expect(d.correctResponse).toEqual(['A']); + }); +}); + +describe('QTIDeclaration.fromXML', () => { + it('reads identifier from XML', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.identifier).toBe('RESPONSE'); + }); + + it('reads base-type from XML', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.baseType).toBe('identifier'); + }); + + it('reads cardinality from XML', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.cardinality).toBe('single'); + }); + + it('stores the original tag name', () => { + const d = QTIDeclaration.fromXML(parseXML(OUTCOME_DECLARATION)); + expect(d.tag).toBe('qti-outcome-declaration'); + }); + + it('registers a correctResponse capability from XML', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.correctResponse).toEqual(['ChoiceA']); + }); + + it('registers multiple correct response values', () => { + const d = QTIDeclaration.fromXML(parseXML(MULTI_SELECT_DECLARATION)); + expect(d.correctResponse).toEqual(['ChoiceA', 'ChoiceC']); + }); + + it('registers a mapping capability when qti-mapping is present', () => { + const d = QTIDeclaration.fromXML(parseXML(DECLARATION_WITH_MAPPING)); + expect(d.mapping).not.toBeNull(); + expect(d.mapping.entries).toHaveLength(2); + }); + + it('handles null base-type gracefully', () => { + const d = QTIDeclaration.fromXML(parseXML(NO_BASETYPE_DECLARATION)); + expect(d.baseType).toBeNull(); + expect(d.correctResponse).toEqual(['true']); + }); + + it('ignores unrecognized child elements', () => { + const xml = ` + + + + `.trim(); + expect(() => QTIDeclaration.fromXML(parseXML(xml))).not.toThrow(); + }); + + it('produces no capabilities for a declaration with no known children', () => { + const d = QTIDeclaration.fromXML(parseXML(OUTCOME_DECLARATION)); + expect(d.correctResponse).toBeNull(); + expect(d.defaultValue).toBeNull(); + expect(d.mapping).toBeNull(); + expect(d.areaMapping).toBeNull(); + }); +}); + +describe('QTIDeclaration.getXML', () => { + it('produces an element with the correct tag', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.getXML().tagName).toBe('qti-response-declaration'); + }); + + it('preserves the outcome-declaration tag on round-trip', () => { + const d = QTIDeclaration.fromXML(parseXML(OUTCOME_DECLARATION)); + expect(d.getXML().tagName).toBe('qti-outcome-declaration'); + }); + + it('preserves identifier on round-trip', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.getXML().getAttribute('identifier')).toBe('RESPONSE'); + }); + + it('preserves base-type on round-trip', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(d.getXML().getAttribute('base-type')).toBe('identifier'); + }); + + it('omits base-type attr when null', () => { + const d = QTIDeclaration.fromXML(parseXML(NO_BASETYPE_DECLARATION)); + expect(d.getXML().hasAttribute('base-type')).toBe(false); + }); + + it('includes qti-correct-response child on round-trip', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + const xml = serializeXML(d.getXML()); + expect(xml).toContain('qti-correct-response'); + expect(xml).toContain('ChoiceA'); + }); + + it('includes qti-mapping child when mapping is present', () => { + const d = QTIDeclaration.fromXML(parseXML(DECLARATION_WITH_MAPPING)); + const xml = serializeXML(d.getXML()); + expect(xml).toContain('qti-mapping'); + expect(xml).toContain('map-key="ChoiceA"'); + }); + + it('full round-trip: fromXML → getXML preserves structure', () => { + const d = QTIDeclaration.fromXML(parseXML(MULTI_SELECT_DECLARATION)); + const node = d.getXML(); + const values = [...node.querySelectorAll('qti-correct-response qti-value')].map( + n => n.textContent, + ); + expect(values).toEqual(['ChoiceA', 'ChoiceC']); + }); +}); + +describe('QTIDeclaration full XML output (QTI compatibility)', () => { + it('serializes a single-select declaration to well-formed XML', () => { + const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); + expect(() => reparse(d.getXML())).not.toThrow(); + }); + + it('re-parsed XML has qti-response-declaration root tag', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)).getXML()); + expect(reparsed.tagName).toBe('qti-response-declaration'); + }); + + it('re-parsed XML preserves identifier attribute', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)).getXML()); + expect(reparsed.getAttribute('identifier')).toBe('RESPONSE'); + }); + + it('re-parsed XML preserves base-type attribute', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)).getXML()); + expect(reparsed.getAttribute('base-type')).toBe('identifier'); + }); + + it('re-parsed XML preserves cardinality attribute', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(MULTI_SELECT_DECLARATION)).getXML()); + expect(reparsed.getAttribute('cardinality')).toBe('multiple'); + }); + + it('re-parsed XML contains qti-correct-response child', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)).getXML()); + expect(reparsed.querySelector('qti-correct-response')).not.toBeNull(); + }); + + it('re-parsed XML correct-response values are intact', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(MULTI_SELECT_DECLARATION)).getXML()); + const values = [...reparsed.querySelectorAll('qti-correct-response qti-value')].map( + n => n.textContent, + ); + expect(values).toEqual(['ChoiceA', 'ChoiceC']); + }); + + it('re-parsed XML with qti-mapping has correct map entries', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(DECLARATION_WITH_MAPPING)).getXML()); + const entries = [...reparsed.querySelectorAll('qti-mapping qti-map-entry')]; + expect(entries).toHaveLength(2); + expect(entries[0].getAttribute('map-key')).toBe('ChoiceA'); + expect(entries[0].getAttribute('mapped-value')).toBe('1'); + }); + + it('omits base-type attr when null on re-parse', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(NO_BASETYPE_DECLARATION)).getXML()); + expect(reparsed.hasAttribute('base-type')).toBe(false); + }); + + it('preserves outcome-declaration tag on full re-parse', () => { + const reparsed = reparse(QTIDeclaration.fromXML(parseXML(OUTCOME_DECLARATION)).getXML()); + expect(reparsed.tagName).toBe('qti-outcome-declaration'); + }); + + // Identifier values can contain non-ASCII chars in i18n item banks + it('handles non-ASCII identifiers in serialized XML (i18n)', () => { + const d = new QTIDeclaration({ + identifier: 'RÉSPONSE_日本語', + baseType: 'identifier', + cardinality: 'single', + }); + const reparsed = reparse(d.getXML()); + expect(reparsed.getAttribute('identifier')).toBe('RÉSPONSE_日本語'); + }); +}); + +describe('QTIDeclaration.coerceValue', () => { + describe('null / empty handling', () => { + it.each([null, undefined, '', 'NULL'])('returns null for %p', raw => { + expect(QTIDeclaration.coerceValue(raw, 'identifier')).toBeNull(); + }); + }); + + describe('boolean', () => { + it('coerces "true" to true', () => { + expect(QTIDeclaration.coerceValue('true', 'boolean')).toBe(true); + }); + it('coerces "false" to false', () => { + expect(QTIDeclaration.coerceValue('false', 'boolean')).toBe(false); + }); + it('throws for invalid boolean string', () => { + expect(() => QTIDeclaration.coerceValue('yes', 'boolean')).toThrow(TypeError); + }); + }); + + describe('integer', () => { + it('coerces "42" to 42', () => { + expect(QTIDeclaration.coerceValue('42', 'integer')).toBe(42); + }); + it('throws for non-numeric string', () => { + expect(() => QTIDeclaration.coerceValue('abc', 'integer')).toThrow(TypeError); + }); + }); + + describe('float', () => { + it('coerces "3.14" to 3.14', () => { + expect(QTIDeclaration.coerceValue('3.14', 'float')).toBeCloseTo(3.14); + }); + it('throws for non-numeric string', () => { + expect(() => QTIDeclaration.coerceValue('abc', 'float')).toThrow(TypeError); + }); + }); + + describe('point', () => { + it('coerces "100 200" to [100, 200]', () => { + expect(QTIDeclaration.coerceValue('100 200', 'point')).toEqual([100, 200]); + }); + it('coerces a 2-element array of strings', () => { + expect(QTIDeclaration.coerceValue(['10', '20'], 'point')).toEqual([10, 20]); + }); + it('throws for a string with wrong number of parts', () => { + expect(() => QTIDeclaration.coerceValue('100', 'point')).toThrow(TypeError); + }); + }); + + describe('pair', () => { + it('coerces "A B" to ["A", "B"]', () => { + expect(QTIDeclaration.coerceValue('A B', 'pair')).toEqual(['A', 'B']); + }); + it('coerces a 2-element array', () => { + expect(QTIDeclaration.coerceValue(['A', 'B'], 'pair')).toEqual(['A', 'B']); + }); + it('throws for a string with wrong number of parts', () => { + expect(() => QTIDeclaration.coerceValue('A', 'pair')).toThrow(TypeError); + }); + }); + + describe('directedPair', () => { + it('coerces "A B" to ["A", "B"]', () => { + expect(QTIDeclaration.coerceValue('A B', 'directedPair')).toEqual(['A', 'B']); + }); + it('throws for a string with wrong number of parts', () => { + expect(() => QTIDeclaration.coerceValue('A', 'directedPair')).toThrow(TypeError); + }); + }); + + describe('duration', () => { + it('coerces "3600" to 3600', () => { + expect(QTIDeclaration.coerceValue('3600', 'duration')).toBe(3600); + }); + it('throws for a negative duration', () => { + expect(() => QTIDeclaration.coerceValue('-1', 'duration')).toThrow(TypeError); + }); + it('throws for a non-numeric duration', () => { + expect(() => QTIDeclaration.coerceValue('abc', 'duration')).toThrow(TypeError); + }); + }); + + describe('identifier / string / uri', () => { + it('returns the raw string for identifier', () => { + expect(QTIDeclaration.coerceValue('ChoiceA', 'identifier')).toBe('ChoiceA'); + }); + it('returns the raw string for string', () => { + expect(QTIDeclaration.coerceValue('hello', 'string')).toBe('hello'); + }); + it('returns the raw string for uri', () => { + expect(QTIDeclaration.coerceValue('https://example.com', 'uri')).toBe('https://example.com'); + }); + }); +}); + +describe('QTIDeclaration.formatValue', () => { + it('formats null to empty string', () => { + expect(QTIDeclaration.formatValue(null)).toBe(''); + }); + it('formats undefined to empty string', () => { + expect(QTIDeclaration.formatValue(undefined)).toBe(''); + }); + it('formats true to "true"', () => { + expect(QTIDeclaration.formatValue(true)).toBe('true'); + }); + it('formats false to "false"', () => { + expect(QTIDeclaration.formatValue(false)).toBe('false'); + }); + it('formats a number to its string representation', () => { + expect(QTIDeclaration.formatValue(3.14)).toBe('3.14'); + }); + it('formats an array (point/pair) to space-separated string', () => { + expect(QTIDeclaration.formatValue([100, 200])).toBe('100 200'); + expect(QTIDeclaration.formatValue(['A', 'B'])).toBe('A B'); + }); + it('returns strings unchanged', () => { + expect(QTIDeclaration.formatValue('ChoiceA')).toBe('ChoiceA'); + }); +}); + +describe('coerceValue → formatValue round-trip', () => { + it.each([ + ['boolean', 'true', true], + ['integer', '42', 42], + ['float', '3.14', 3.14], + ['point', '100 200', [100, 200]], + ['pair', 'A B', ['A', 'B']], + ['directedPair', 'A B', ['A', 'B']], + ['duration', '3600', 3600], + ['identifier', 'ChoiceA', 'ChoiceA'], + ['string', 'hello', 'hello'], + ])('round-trips %s value', (baseType, raw, coerced) => { + expect(QTIDeclaration.coerceValue(raw, baseType)).toEqual(coerced); + expect(QTIDeclaration.formatValue(coerced)).toBe(raw); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTISanitizer.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTISanitizer.spec.js new file mode 100644 index 0000000000..687c704e97 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTISanitizer.spec.js @@ -0,0 +1,173 @@ +import { QTISanitizer } from '../QTISanitizer.js'; + +describe('QTISanitizer.validateBaseType', () => { + it.each([ + 'boolean', + 'float', + 'identifier', + 'integer', + 'string', + 'point', + 'pair', + 'directedPair', + 'duration', + 'file', + 'uri', + ])('accepts valid base-type "%s"', bt => expect(QTISanitizer.validateBaseType(bt)).toBe(bt)); + + it('throws for an unknown base-type', () => { + expect(() => QTISanitizer.validateBaseType('foo')).toThrow('invalid base-type'); + }); + + it('throws for an empty string', () => { + expect(() => QTISanitizer.validateBaseType('')).toThrow(); + }); + + it('returns null/undefined unchanged (base-type is optional on outcome declarations)', () => { + expect(QTISanitizer.validateBaseType(null)).toBeNull(); + expect(QTISanitizer.validateBaseType(undefined)).toBeUndefined(); + }); +}); + +describe('QTISanitizer.validateCardinality', () => { + it.each(['single', 'multiple', 'ordered', 'record'])('accepts valid cardinality "%s"', c => + expect(QTISanitizer.validateCardinality(c)).toBe(c), + ); + + it('throws for an unknown cardinality', () => { + expect(() => QTISanitizer.validateCardinality('many')).toThrow('invalid cardinality'); + }); + + it('throws for an empty string', () => { + expect(() => QTISanitizer.validateCardinality('')).toThrow(); + }); +}); + +describe('QTISanitizer.validateIdentifier', () => { + it('accepts a plain ASCII identifier', () => { + expect(QTISanitizer.validateIdentifier('RESPONSE')).toBe('RESPONSE'); + }); + + it('accepts identifiers starting with underscore', () => { + expect(QTISanitizer.validateIdentifier('_choice1')).toBe('_choice1'); + }); + + it('accepts identifiers with hyphens and dots', () => { + expect(QTISanitizer.validateIdentifier('choice-A.1')).toBe('choice-A.1'); + }); + + it('accepts non-ASCII Unicode identifiers (i18n item banks)', () => { + expect(QTISanitizer.validateIdentifier('RÉSPONSE')).toBe('RÉSPONSE'); + }); + + it('throws for an identifier starting with a digit', () => { + expect(() => QTISanitizer.validateIdentifier('1choice')).toThrow('invalid QTI identifier'); + }); + + it('throws for an identifier containing spaces', () => { + expect(() => QTISanitizer.validateIdentifier('choice A')).toThrow(); + }); + + it('throws for an empty string', () => { + expect(() => QTISanitizer.validateIdentifier('')).toThrow(); + }); + + it('throws for an identifier that is just a number', () => { + expect(() => QTISanitizer.validateIdentifier('123')).toThrow(); + }); +}); + +describe('QTISanitizer.stripTags — XSS defence', () => { + it('passes through a plain string unchanged', () => { + expect(QTISanitizer.stripTags('ChoiceA')).toBe('ChoiceA'); + }); + + it('strips a simple ')).toBe(''); + }); + + it('strips HTML bold tags and keeps text content', () => { + expect(QTISanitizer.stripTags('bold')).toBe('bold'); + }); + + it('strips nested tags', () => { + expect(QTISanitizer.stripTags('
inner
')).toBe('inner'); + }); + + it('handles img onerror XSS payload', () => { + const payload = ''; + // After stripping tags, no html remains — textContent of is empty + expect(QTISanitizer.stripTags(payload)).toBe(''); + }); + + it('preserves non-ASCII text (i18n)', () => { + expect(QTISanitizer.stripTags('选择甲')).toBe('选择甲'); + }); + + it('preserves numbers', () => { + expect(QTISanitizer.stripTags('3.14')).toBe('3.14'); + }); + + it('preserves ampersand entity text (not HTML)', () => { + // A raw `&` without a tag — not HTML injection, passes through + expect(QTISanitizer.stripTags('A & B')).toBe('A & B'); + }); + + it('handles non-string input by coercing to string', () => { + expect(QTISanitizer.stripTags(42)).toBe('42'); + expect(QTISanitizer.stripTags(null)).toBe(''); + }); +}); + +describe('QTISanitizer.parseFloat', () => { + it('parses a string float', () => { + expect(QTISanitizer.parseFloat('3.14')).toBe(3.14); + }); + + it('parses a string integer', () => { + expect(QTISanitizer.parseFloat('0')).toBe(0); + }); + + it('parses a negative number', () => { + expect(QTISanitizer.parseFloat('-0.5')).toBe(-0.5); + }); + + it('passes through a number', () => { + expect(QTISanitizer.parseFloat(1)).toBe(1); + }); + + it('throws for NaN input', () => { + expect(() => QTISanitizer.parseFloat('not-a-number')).toThrow('finite number'); + }); + + it('throws for Infinity', () => { + expect(() => QTISanitizer.parseFloat(Infinity)).toThrow(); + }); + + it('includes the field name in the error', () => { + expect(() => QTISanitizer.parseFloat('x', 'mapped-value')).toThrow('mapped-value'); + }); +}); + +describe('QTISanitizer.sanitizeValues', () => { + it('trims whitespace from each entry', () => { + expect(QTISanitizer.sanitizeValues([' A ', 'B'])).toEqual(['A', 'B']); + }); + + it('strips HTML tags from each entry', () => { + expect(QTISanitizer.sanitizeValues(['Choice', 'B'])).toEqual(['Choice', 'B']); + }); + + it('removes entries that are empty after sanitization', () => { + expect(QTISanitizer.sanitizeValues(['', 'Valid'])).toEqual(['Valid']); + }); + + it('returns an empty array for an all-empty input', () => { + expect(QTISanitizer.sanitizeValues(['
', ' '])).toEqual([]); + }); + + it('preserves i18n values', () => { + expect(QTISanitizer.sanitizeValues(['选择甲', 'اختيار'])).toEqual(['选择甲', 'اختيار']); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/areaMapping.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/areaMapping.spec.js new file mode 100644 index 0000000000..4ed275d5c9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/areaMapping.spec.js @@ -0,0 +1,165 @@ +// 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 AreaMapping from '../../declarations/areaMapping.js'; +import { QTIDeclaration } from '../../QTIDeclaration.js'; +import { CAPABILITY } from '../../declarations/index.js'; +import { parseXML, reparse } from '../testUtils.js'; +import { AREA_MAPPING_WITH_BOUNDS_XML, AREA_MAPPING_XML } from './fixtures.js'; + +function makeDeclaration() { + return new QTIDeclaration({ identifier: 'RESPONSE', baseType: 'point', cardinality: 'single' }); +} + +function parseAreaMappingXml(xmlString) { + return parseXML(xmlString).querySelector('qti-area-mapping'); +} + +describe('AreaMapping', () => { + describe('constructor', () => { + it('stores the provided data', () => { + const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; + const am = new AreaMapping(data, makeDeclaration()); + expect(am.get()).toEqual(data); + }); + }); + + describe('fromXML', () => { + it('parses default-value', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + expect(am.get().defaultValue).toBe(0); + }); + + it('parses lower-bound and upper-bound', () => { + const node = parseAreaMappingXml(AREA_MAPPING_WITH_BOUNDS_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + expect(am.get().lowerBound).toBe(-2); + expect(am.get().upperBound).toBe(3); + }); + + it('sets bounds to null when absent', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + expect(am.get().lowerBound).toBeNull(); + expect(am.get().upperBound).toBeNull(); + }); + + it('parses area entries', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + expect(am.get().entries).toHaveLength(2); + }); + + it('stores shape as string', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + expect(am.get().entries[0].shape).toBe('circle'); + expect(am.get().entries[1].shape).toBe('rect'); + }); + + it('stores coords as an opaque string (not parsed)', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + // Opaque string — not converted to numbers + expect(am.get().entries[0].coords).toBe('100,100,50'); + expect(typeof am.get().entries[0].coords).toBe('string'); + }); + + it('parses mappedValue as a number', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + expect(am.get().entries[0].mappedValue).toBe(1); + expect(am.get().entries[1].mappedValue).toBe(0.5); + }); + + it('registers itself as AREA_MAPPING capability', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const declaration = makeDeclaration(); + AreaMapping.fromXML(node, declaration); + expect(declaration._capabilities[CAPABILITY.AREA_MAPPING]).toBeDefined(); + expect(declaration.areaMapping).not.toBeNull(); + }); + }); + + describe('getXML', () => { + it('produces a qti-area-mapping element', () => { + const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; + expect(new AreaMapping(data, makeDeclaration()).getXML().tagName).toBe('qti-area-mapping'); + }); + + it('emits qti-area-map-entry children', () => { + const data = { + defaultValue: 0, + lowerBound: null, + upperBound: null, + entries: [{ shape: 'circle', coords: '100,100,50', mappedValue: 1 }], + }; + const node = new AreaMapping(data, makeDeclaration()).getXML(); + const entry = node.querySelector('qti-area-map-entry'); + expect(entry.getAttribute('shape')).toBe('circle'); + expect(entry.getAttribute('coords')).toBe('100,100,50'); + expect(entry.getAttribute('mapped-value')).toBe('1'); + }); + + it('preserves coords exactly on round-trip (no float reformatting)', () => { + const node = parseAreaMappingXml(AREA_MAPPING_WITH_BOUNDS_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + const entry = am.getXML().querySelector('qti-area-map-entry'); + expect(entry.getAttribute('coords')).toBe('10,10,50,10,50,50'); + }); + + it('round-trips fromXML → getXML', () => { + const node = parseAreaMappingXml(AREA_MAPPING_XML); + const am = AreaMapping.fromXML(node, makeDeclaration()); + const out = am.getXML(); + expect(out.tagName).toBe('qti-area-mapping'); + const entry = out.querySelector('qti-area-map-entry'); + expect(entry.getAttribute('shape')).toBe('circle'); + expect(entry.getAttribute('coords')).toBe('100,100,50'); + expect(entry.getAttribute('mapped-value')).toBe('1'); + }); + }); + + describe('full XML output (QTI compatibility)', () => { + it('serializes to well-formed XML that re-parses without error', () => { + const am = AreaMapping.fromXML(parseAreaMappingXml(AREA_MAPPING_XML), makeDeclaration()); + expect(() => reparse(am.getXML())).not.toThrow(); + }); + + it('re-parsed XML has qti-area-mapping root tag', () => { + const reparsed = reparse( + AreaMapping.fromXML(parseAreaMappingXml(AREA_MAPPING_XML), makeDeclaration()).getXML(), + ); + expect(reparsed.tagName).toBe('qti-area-mapping'); + }); + + it('re-parsed XML has correct number of qti-area-map-entry children', () => { + const reparsed = reparse( + AreaMapping.fromXML(parseAreaMappingXml(AREA_MAPPING_XML), makeDeclaration()).getXML(), + ); + expect(reparsed.querySelectorAll('qti-area-map-entry').length).toBe(2); + }); + + it('re-parsed XML preserves shape, coords, and mapped-value attributes', () => { + const reparsed = reparse( + AreaMapping.fromXML(parseAreaMappingXml(AREA_MAPPING_XML), makeDeclaration()).getXML(), + ); + const entry = reparsed.querySelector('qti-area-map-entry'); + expect(entry.getAttribute('shape')).toBe('circle'); + expect(entry.getAttribute('coords')).toBe('100,100,50'); + expect(entry.getAttribute('mapped-value')).toBe('1'); + }); + + it('re-parsed XML preserves bounds', () => { + const reparsed = reparse( + AreaMapping.fromXML( + parseAreaMappingXml(AREA_MAPPING_WITH_BOUNDS_XML), + makeDeclaration(), + ).getXML(), + ); + expect(reparsed.getAttribute('lower-bound')).toBe('-2'); + expect(reparsed.getAttribute('upper-bound')).toBe('3'); + }); + }); +}); 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 new file mode 100644 index 0000000000..7aa7c250fc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js @@ -0,0 +1,193 @@ +// 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 CorrectResponse from '../../declarations/correctResponse.js'; +import { QTIDeclaration } from '../../QTIDeclaration.js'; +import { CAPABILITY } from '../../declarations/index.js'; +import { parseXML, reparse } from '../testUtils.js'; + +function makeDeclaration() { + return new QTIDeclaration({ + identifier: 'RESPONSE', + baseType: 'identifier', + cardinality: 'single', + }); +} + +function parseCorrectResponse(xmlString) { + return parseXML(xmlString).querySelector('qti-correct-response'); +} + +describe('CorrectResponse', () => { + describe('constructor', () => { + it('stores values', () => { + const cr = new CorrectResponse(['A', 'B'], makeDeclaration()); + expect(cr.get()).toEqual(['A', 'B']); + }); + + it('stores an empty array', () => { + const cr = new CorrectResponse([], makeDeclaration()); + expect(cr.get()).toEqual([]); + }); + }); + + describe('fromXML', () => { + it('parses a single qti-value', () => { + const xmlNode = parseCorrectResponse(` + + + ChoiceA + + + `); + const cr = CorrectResponse.fromXML(xmlNode, makeDeclaration()); + expect(cr.get()).toEqual(['ChoiceA']); + }); + + it('parses multiple qti-value children', () => { + const xmlNode = parseCorrectResponse(` + + + ChoiceA + ChoiceC + + + `); + const cr = CorrectResponse.fromXML(xmlNode, makeDeclaration()); + expect(cr.get()).toEqual(['ChoiceA', 'ChoiceC']); + }); + + it('trims whitespace from values', () => { + const xmlNode = parseCorrectResponse(` + + + hello + + + `); + const cr = CorrectResponse.fromXML(xmlNode, makeDeclaration()); + expect(cr.get()).toEqual(['hello']); + }); + + it('registers itself on the parent declaration as CORRECT_RESPONSE capability', () => { + const xmlNode = parseCorrectResponse(` + + + ChoiceA + + + `); + const declaration = makeDeclaration(); + CorrectResponse.fromXML(xmlNode, declaration); + expect(declaration._capabilities[CAPABILITY.CORRECT_RESPONSE]).toBeDefined(); + expect(declaration.correctResponse).toEqual(['ChoiceA']); + }); + + it('returns an empty array for a qti-correct-response with no values', () => { + const xmlNode = parseCorrectResponse(` + + + + `); + const cr = CorrectResponse.fromXML(xmlNode, makeDeclaration()); + expect(cr.get()).toEqual([]); + }); + }); + + describe('getXML', () => { + it('produces a qti-correct-response element', () => { + expect(new CorrectResponse(['ChoiceA'], makeDeclaration()).getXML().tagName).toBe( + 'qti-correct-response', + ); + }); + + it('contains a qti-value child for each value', () => { + const values = [ + ...new CorrectResponse(['ChoiceA', 'ChoiceC'], makeDeclaration()) + .getXML() + .querySelectorAll('qti-value'), + ].map(n => n.textContent); + 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('round-trips: qti-value child carries correct text', () => { + const xmlNode = parseCorrectResponse(` + + ChoiceA + + `); + const values = [ + ...CorrectResponse.fromXML(xmlNode, makeDeclaration()) + .getXML() + .querySelectorAll('qti-value'), + ].map(n => n.textContent); + expect(values).toEqual(['ChoiceA']); + }); + + it('round-trips multiple values preserving order', () => { + const xmlNode = parseCorrectResponse(` + + + ChoiceB + ChoiceC + + + `); + const values = [ + ...CorrectResponse.fromXML(xmlNode, makeDeclaration()) + .getXML() + .querySelectorAll('qti-value'), + ].map(n => n.textContent); + expect(values).toEqual(['ChoiceB', 'ChoiceC']); + }); + }); + + describe('full XML output (QTI compatibility)', () => { + it('serializes to well-formed XML that re-parses without error', () => { + const cr = new CorrectResponse(['ChoiceA', 'ChoiceC'], makeDeclaration()); + expect(() => reparse(cr.getXML())).not.toThrow(); + }); + + it('re-parsed XML has the correct qti-correct-response root tag', () => { + const reparsed = reparse(new CorrectResponse(['ChoiceA'], makeDeclaration()).getXML()); + expect(reparsed.tagName).toBe('qti-correct-response'); + }); + + it('re-parsed XML has the correct number of qti-value children', () => { + const reparsed = reparse( + new CorrectResponse(['ChoiceA', 'ChoiceC'], makeDeclaration()).getXML(), + ); + expect(reparsed.querySelectorAll('qti-value').length).toBe(2); + }); + + it('re-parsed XML qti-value text content is preserved exactly', () => { + const reparsed = reparse( + new CorrectResponse(['ChoiceA', 'ChoiceC'], makeDeclaration()).getXML(), + ); + const values = [...reparsed.querySelectorAll('qti-value')].map(n => n.textContent); + expect(values).toEqual(['ChoiceA', 'ChoiceC']); + }); + + // QTI values can be non-ASCII (e.g. Arabic/CJK choice identifiers authored by i18n users) + it('correctly encodes non-ASCII value content (i18n)', () => { + const cr = new CorrectResponse(['选择甲', 'اختيار_أ'], makeDeclaration()); + const reparsed = reparse(cr.getXML()); + const values = [...reparsed.querySelectorAll('qti-value')].map(n => n.textContent); + expect(values).toEqual(['选择甲', 'اختيار_أ']); + }); + + // XML special characters in values must be entity-escaped by the serializer + it('correctly escapes XML special characters in values', () => { + // Ampersand is a common edge case in authored content (e.g. "A & B") + const cr = new CorrectResponse(['A & B'], makeDeclaration()); + const reparsed = reparse(cr.getXML()); + expect(reparsed.querySelector('qti-value').textContent).toBe('A & B'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/defaultValue.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/defaultValue.spec.js new file mode 100644 index 0000000000..5ab4603985 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/defaultValue.spec.js @@ -0,0 +1,103 @@ +// 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 DefaultValue from '../../declarations/defaultValue.js'; +import { QTIDeclaration } from '../../QTIDeclaration.js'; +import { CAPABILITY } from '../../declarations/index.js'; +import { parseXML, reparse } from '../testUtils.js'; + +function makeDeclaration() { + return new QTIDeclaration({ identifier: 'SCORE', baseType: 'float', cardinality: 'single' }); +} + +function parseDefaultValue(xmlString) { + return parseXML(xmlString).querySelector('qti-default-value'); +} + +describe('DefaultValue', () => { + describe('constructor', () => { + it('stores values', () => { + const dv = new DefaultValue(['0'], makeDeclaration()); + expect(dv.get()).toEqual(['0']); + }); + }); + + describe('fromXML', () => { + it('parses and coerces qti-value children according to the declaration base type', () => { + const xmlNode = parseDefaultValue(` + + + 0.5 + + + `); + const dv = DefaultValue.fromXML(xmlNode, makeDeclaration()); + expect(dv.get()).toEqual([0.5]); + }); + + it('registers itself as DEFAULT_VALUE capability', () => { + const xmlNode = parseDefaultValue(` + + + 1 + + + `); + const declaration = makeDeclaration(); + DefaultValue.fromXML(xmlNode, declaration); + expect(declaration._capabilities[CAPABILITY.DEFAULT_VALUE]).toBeDefined(); + expect(declaration.defaultValue).toEqual([1]); + }); + }); + + describe('getXML', () => { + it('produces a qti-default-value element', () => { + expect(new DefaultValue(['0'], makeDeclaration()).getXML().tagName).toBe('qti-default-value'); + }); + + it('contains a qti-value for each value', () => { + const values = [ + ...new DefaultValue(['true', 'false'], makeDeclaration()) + .getXML() + .querySelectorAll('qti-value'), + ].map(n => n.textContent); + expect(values).toEqual(['true', 'false']); + }); + + it('round-trips through XML preserving value text', () => { + const xmlNode = parseDefaultValue(` + + + 3.14 + + + `); + const values = [ + ...DefaultValue.fromXML(xmlNode, makeDeclaration()).getXML().querySelectorAll('qti-value'), + ].map(n => n.textContent); + expect(values).toEqual(['3.14']); + }); + }); + + describe('full XML output (QTI compatibility)', () => { + it('serializes to well-formed XML that re-parses without error', () => { + expect(() => reparse(new DefaultValue(['0.5'], makeDeclaration()).getXML())).not.toThrow(); + }); + + it('re-parsed XML has qti-default-value root tag', () => { + const reparsed = reparse(new DefaultValue(['0.5'], makeDeclaration()).getXML()); + expect(reparsed.tagName).toBe('qti-default-value'); + }); + + it('re-parsed XML preserves qti-value text content', () => { + const reparsed = reparse(new DefaultValue(['3.14'], makeDeclaration()).getXML()); + expect(reparsed.querySelector('qti-value').textContent).toBe('3.14'); + }); + + // DefaultValue can hold non-ASCII strings (e.g. CJK default text in i18n items) + it('correctly encodes non-ASCII value content (i18n)', () => { + const reparsed = reparse(new DefaultValue(['默认值'], makeDeclaration()).getXML()); + expect(reparsed.querySelector('qti-value').textContent).toBe('默认值'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js new file mode 100644 index 0000000000..2b19137e8a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js @@ -0,0 +1,44 @@ +/** QTI XML fixtures shared across the Mapping and AreaMapping test suites. */ + +export const SIMPLE_MAPPING_XML = ` + + + + + + +`.trim(); + +export const MAPPING_WITH_BOUNDS_XML = ` + + + + + +`.trim(); + +export const MAPPING_WITH_CI_XML = ` + + + + + + +`.trim(); + +export const AREA_MAPPING_XML = ` + + + + + + +`.trim(); + +export const AREA_MAPPING_WITH_BOUNDS_XML = ` + + + + + +`.trim(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js new file mode 100644 index 0000000000..1ead5d6831 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js @@ -0,0 +1,239 @@ +// 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 Mapping from '../../declarations/mapping.js'; +import { QTIDeclaration } from '../../QTIDeclaration.js'; +import { CAPABILITY } from '../../declarations/index.js'; +import { parseXML, reparse } from '../testUtils.js'; +import { MAPPING_WITH_BOUNDS_XML, MAPPING_WITH_CI_XML, SIMPLE_MAPPING_XML } from './fixtures.js'; + +function makeDeclaration() { + return new QTIDeclaration({ + identifier: 'RESPONSE', + baseType: 'identifier', + cardinality: 'single', + }); +} + +function parseMappingXml(xmlString) { + return parseXML(xmlString).querySelector('qti-mapping'); +} + +describe('Mapping', () => { + describe('constructor', () => { + it('stores the provided data', () => { + const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; + const m = new Mapping(data, makeDeclaration()); + expect(m.get()).toEqual(data); + }); + }); + + describe('fromXML', () => { + it('parses default-value attribute', () => { + const node = parseMappingXml(SIMPLE_MAPPING_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().defaultValue).toBe(0); + }); + + it('defaults default-value to 0 when absent', () => { + const node = parseMappingXml(` + + + + `); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().defaultValue).toBe(0); + }); + + it('parses lower-bound attribute', () => { + const node = parseMappingXml(MAPPING_WITH_BOUNDS_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().lowerBound).toBe(-2); + }); + + it('parses upper-bound attribute', () => { + const node = parseMappingXml(MAPPING_WITH_BOUNDS_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().upperBound).toBe(5); + }); + + it('sets lowerBound to null when absent', () => { + const node = parseMappingXml(SIMPLE_MAPPING_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().lowerBound).toBeNull(); + }); + + it('sets upperBound to null when absent', () => { + const node = parseMappingXml(SIMPLE_MAPPING_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().upperBound).toBeNull(); + }); + + it('parses map entries', () => { + const node = parseMappingXml(SIMPLE_MAPPING_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().entries).toHaveLength(2); + expect(m.get().entries[0]).toEqual({ + mapKey: 'ChoiceA', + mappedValue: 1, + caseSensitive: true, + }); + expect(m.get().entries[1]).toEqual({ + mapKey: 'ChoiceB', + mappedValue: -0.5, + caseSensitive: true, + }); + }); + + it('parses case-sensitive="false" on entries', () => { + const node = parseMappingXml(MAPPING_WITH_CI_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + expect(m.get().entries[0].caseSensitive).toBe(false); + expect(m.get().entries[1].caseSensitive).toBe(true); + }); + + it('registers itself as MAPPING capability', () => { + const node = parseMappingXml(SIMPLE_MAPPING_XML); + const declaration = makeDeclaration(); + Mapping.fromXML(node, declaration); + expect(declaration._capabilities[CAPABILITY.MAPPING]).toBeDefined(); + expect(declaration.mapping).not.toBeNull(); + }); + }); + + describe('getXML', () => { + it('produces a qti-mapping element', () => { + const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; + expect(new Mapping(data, makeDeclaration()).getXML().tagName).toBe('qti-mapping'); + }); + + it('emits default-value attribute', () => { + const data = { defaultValue: -1, lowerBound: null, upperBound: null, entries: [] }; + expect(new Mapping(data, makeDeclaration()).getXML().getAttribute('default-value')).toBe( + '-1', + ); + }); + + it('emits lower-bound only when not null', () => { + const withBound = { defaultValue: 0, lowerBound: -2, upperBound: null, entries: [] }; + const withoutBound = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; + expect(new Mapping(withBound, makeDeclaration()).getXML().getAttribute('lower-bound')).toBe( + '-2', + ); + expect( + new Mapping(withoutBound, makeDeclaration()).getXML().hasAttribute('lower-bound'), + ).toBe(false); + }); + + it('emits upper-bound only when not null', () => { + const withBound = { defaultValue: 0, lowerBound: null, upperBound: 5, entries: [] }; + expect(new Mapping(withBound, makeDeclaration()).getXML().getAttribute('upper-bound')).toBe( + '5', + ); + }); + + it('produces qti-map-entry children', () => { + const data = { + defaultValue: 0, + lowerBound: null, + upperBound: null, + entries: [ + { mapKey: 'ChoiceA', mappedValue: 1, caseSensitive: true }, + { mapKey: 'ChoiceB', mappedValue: -0.5, caseSensitive: true }, + ], + }; + const node = new Mapping(data, makeDeclaration()).getXML(); + const entries = [...node.querySelectorAll('qti-map-entry')]; + expect(entries).toHaveLength(2); + expect(entries[0].getAttribute('map-key')).toBe('ChoiceA'); + expect(entries[0].getAttribute('mapped-value')).toBe('1'); + }); + + it('only emits case-sensitive attr when false', () => { + const data = { + defaultValue: 0, + lowerBound: null, + upperBound: null, + entries: [ + { mapKey: 'a', mappedValue: 1, caseSensitive: false }, + { mapKey: 'b', mappedValue: 1, caseSensitive: true }, + ], + }; + const entries = [ + ...new Mapping(data, makeDeclaration()).getXML().querySelectorAll('qti-map-entry'), + ]; + expect(entries[0].getAttribute('case-sensitive')).toBe('false'); + expect(entries[1].hasAttribute('case-sensitive')).toBe(false); + }); + + it('round-trips fromXML → getXML preserves all attributes via DOM', () => { + const node = parseMappingXml(MAPPING_WITH_BOUNDS_XML); + const declaration = makeDeclaration(); + const m = Mapping.fromXML(node, declaration); + const out = m.getXML(); + expect(out.getAttribute('default-value')).toBe('-1'); + expect(out.getAttribute('lower-bound')).toBe('-2'); + expect(out.getAttribute('upper-bound')).toBe('5'); + expect(out.querySelector('qti-map-entry').getAttribute('map-key')).toBe('A'); + }); + }); + + describe('full XML output (QTI compatibility)', () => { + it('serializes to well-formed XML that re-parses without error', () => { + const node = parseMappingXml(MAPPING_WITH_BOUNDS_XML); + const m = Mapping.fromXML(node, makeDeclaration()); + expect(() => reparse(m.getXML())).not.toThrow(); + }); + + it('re-parsed XML has qti-mapping root tag', () => { + const reparsed = reparse( + Mapping.fromXML(parseMappingXml(SIMPLE_MAPPING_XML), makeDeclaration()).getXML(), + ); + expect(reparsed.tagName).toBe('qti-mapping'); + }); + + it('re-parsed XML has correct number of qti-map-entry children', () => { + const reparsed = reparse( + Mapping.fromXML(parseMappingXml(SIMPLE_MAPPING_XML), makeDeclaration()).getXML(), + ); + expect(reparsed.querySelectorAll('qti-map-entry').length).toBe(2); + }); + + it('re-parsed XML preserves map-key and mapped-value attributes', () => { + const reparsed = reparse( + Mapping.fromXML(parseMappingXml(SIMPLE_MAPPING_XML), makeDeclaration()).getXML(), + ); + const first = reparsed.querySelector('qti-map-entry'); + expect(first.getAttribute('map-key')).toBe('ChoiceA'); + expect(first.getAttribute('mapped-value')).toBe('1'); + }); + + it('re-parsed XML preserves bounds from full round-trip', () => { + const reparsed = reparse( + Mapping.fromXML(parseMappingXml(MAPPING_WITH_BOUNDS_XML), makeDeclaration()).getXML(), + ); + expect(reparsed.getAttribute('lower-bound')).toBe('-2'); + expect(reparsed.getAttribute('upper-bound')).toBe('5'); + }); + + // Non-ASCII map-key values (e.g. Arabic or CJK identifiers authored in i18n contexts) + it('correctly encodes non-ASCII map-key values (i18n)', () => { + const data = { + defaultValue: 0, + lowerBound: null, + upperBound: null, + entries: [{ mapKey: '选择甲', mappedValue: 1, caseSensitive: true }], + }; + const reparsed = reparse(new Mapping(data, makeDeclaration()).getXML()); + expect(reparsed.querySelector('qti-map-entry').getAttribute('map-key')).toBe('选择甲'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/fixtures/declarations.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/fixtures/declarations.js new file mode 100644 index 0000000000..fbb84352e6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/fixtures/declarations.js @@ -0,0 +1,42 @@ +/** QTI declaration XML fixtures shared across the QTIDeclaration test suite. */ + +export const SINGLE_SELECT_DECLARATION = ` + + + ChoiceA + + +`.trim(); + +export const MULTI_SELECT_DECLARATION = ` + + + ChoiceA + ChoiceC + + +`.trim(); + +export const DECLARATION_WITH_MAPPING = ` + + + ChoiceA + + + + + + +`.trim(); + +export const OUTCOME_DECLARATION = ` + +`.trim(); + +export const NO_BASETYPE_DECLARATION = ` + + + true + + +`.trim(); 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 new file mode 100644 index 0000000000..30f029c400 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js @@ -0,0 +1,40 @@ +/** + * Shared XML parse helper for declaration tests. + */ +const parser = new DOMParser(); +const serializer = new XMLSerializer(); + +/** + * Parse an XML string and return the root element. + * @param {string} xmlString + * @returns {Element} + */ +export function parseXML(xmlString) { + const doc = parser.parseFromString(xmlString, 'text/xml'); + const error = doc.querySelector('parsererror'); + if (error) throw new Error(`XML parse error: ${error.textContent}`); + return doc.documentElement; +} + +/** + * Serialize a DOM node to an XML string. + * @param {Node} node + * @returns {string} + */ +export function serializeXML(node) { + return serializer.serializeToString(node); +} + +/** + * Serialize a DOM node and parse it back to a new DOM tree. + * Useful for validating that output XML is well-formed. + * @param {Node} node + * @returns {Element} + */ +export function reparse(node) { + try { + return parseXML(serializeXML(node)); + } catch (error) { + throw new Error(`Re-parsed XML has a parsererror:\n${error.message}`); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js new file mode 100644 index 0000000000..1d484d5e31 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js @@ -0,0 +1,79 @@ +/** + * AreaMapping declaration strategy. + * + * Parses a element into plain JS data for authoring round-trip. + * The coords attribute is stored as an opaque string to prevent floating-point + * formatting changes on re-serialization. Geometry evaluation is out of scope + * for the authoring editor. + */ +import { buildXmlNode } from '../../assembleItem.js'; +import { CAPABILITY } from './capabilities.js'; +import { parseScoringAttrs } from './mapping.js'; + +export default class AreaMapping { + /** + * @param {{ + * defaultValue: number, + * lowerBound: number|null, + * upperBound: number|null, + * entries: Array<{ shape: string, coords: string, mappedValue: number }> + * }} data + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + */ + constructor(data, declaration) { + this._data = data; + this._declaration = declaration; + declaration.registerCapability(CAPABILITY.AREA_MAPPING, this); + } + + /** + * Parse a element and register on the parent declaration. + * + * @param {Element} xmlNode + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {AreaMapping} + */ + static fromXML(xmlNode, declaration) { + const bounds = parseScoringAttrs(xmlNode); + + const entries = [...xmlNode.querySelectorAll('qti-area-map-entry')].map(entry => ({ + shape: entry.getAttribute('shape'), + coords: entry.getAttribute('coords'), + mappedValue: parseFloat(entry.getAttribute('mapped-value')), + })); + + return new AreaMapping({ ...bounds, entries }, declaration); + } + + /** + * @returns {{ defaultValue: number, lowerBound: number|null, + * upperBound: number|null, entries: Array }} + */ + get() { + return this._data; + } + + /** + * @returns {Element} + */ + getXML() { + const { defaultValue, lowerBound, upperBound, entries } = this._data; + + const attrs = { 'default-value': defaultValue }; + if (lowerBound !== null) attrs['lower-bound'] = lowerBound; + if (upperBound !== null) attrs['upper-bound'] = upperBound; + + const children = entries.map(entry => + buildXmlNode({ + tag: 'qti-area-map-entry', + attrs: { + shape: entry.shape, + coords: entry.coords, + 'mapped-value': entry.mappedValue, + }, + }), + ); + + return buildXmlNode({ tag: 'qti-area-mapping', attrs, children }); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js new file mode 100644 index 0000000000..a47d854c07 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js @@ -0,0 +1,18 @@ +/** + * Capability name constants for QTI declaration objects. + * + * Used as keys when declaration strategy classes register themselves on a + * QTIDeclaration, and when QTIDeclaration looks them up via its getters. + * Constants prevent silent failures from typos in capability key names. + * + * SCORE and LOOKUP from the Kolibri original are intentionally omitted — + * the authoring editor carries no runtime scoring or lookup-table logic. + */ + +/** @enum {string} */ +export const CAPABILITY = Object.freeze({ + CORRECT_RESPONSE: 'correctResponse', + DEFAULT_VALUE: 'defaultValue', + MAPPING: 'mapping', + AREA_MAPPING: 'areaMapping', +}); 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 new file mode 100644 index 0000000000..e492ea8c9b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js @@ -0,0 +1,55 @@ +/** + * CorrectResponse declaration strategy. + * + * Parses a element and coerces each text + * to its native JS type (number, boolean, or string) based on the parent + * declaration's base-type. Re-serializes values back to XML strings on demand. + */ +import { buildXmlNode } from '../../assembleItem.js'; +import { CAPABILITY } from './capabilities.js'; + +export default class CorrectResponse { + /** + * @param {Array} values - Correct response values (native JS types) + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + */ + constructor(values, declaration) { + /** @type {Array} */ + this._values = values; + this._declaration = declaration; + declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, this); + } + + /** + * Parse a element and register on the parent declaration. + * Note: the optional `interpretation` attribute (QTI 3.0 §3.1.1.2) is not + * preserved — it is not used by the authoring editor. + * + * @param {Element} xmlNode + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {CorrectResponse} + */ + static fromXML(xmlNode, declaration) { + const rawStrings = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); + return new CorrectResponse(declaration.coerceValues(rawStrings), declaration); + } + + /** + * @returns {Array} + */ + get() { + return this._values; + } + + /** + * @returns {Element} + */ + getXML() { + return buildXmlNode({ + tag: 'qti-correct-response', + children: this._declaration + .formatValues(this._values) + .map(v => buildXmlNode({ tag: 'qti-value', children: [v] })), + }); + } +} 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 new file mode 100644 index 0000000000..00432f8e0b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js @@ -0,0 +1,53 @@ +/** + * DefaultValue declaration strategy. + * + * Parses a element and coerces each text + * to its native JS type (number, boolean, or string) based on the parent + * declaration's base-type. Re-serializes values back to XML strings on demand. + */ +import { buildXmlNode } from '../../assembleItem.js'; +import { CAPABILITY } from './capabilities.js'; + +export default class DefaultValue { + /** + * @param {Array} values - Default values (native JS types) + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + */ + constructor(values, declaration) { + /** @type {Array} */ + this._values = values; + this._declaration = declaration; + declaration.registerCapability(CAPABILITY.DEFAULT_VALUE, this); + } + + /** + * Parse a element and register on the parent declaration. + * + * @param {Element} xmlNode + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {DefaultValue} + */ + static fromXML(xmlNode, declaration) { + const rawStrings = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); + return new DefaultValue(declaration.coerceValues(rawStrings), declaration); + } + + /** + * @returns {Array} + */ + get() { + return this._values; + } + + /** + * @returns {Element} + */ + getXML() { + return buildXmlNode({ + tag: 'qti-default-value', + children: this._declaration + .formatValues(this._values) + .map(v => buildXmlNode({ tag: 'qti-value', children: [v] })), + }); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js new file mode 100644 index 0000000000..9498297347 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js @@ -0,0 +1,31 @@ +/** + * Declaration strategy registry for QTI child element parsing. + * + * Maps XML child tag names to their strategy classes. Each class exposes a + * static fromXML(xmlNode, declaration) that parses the element and registers + * a capability on the parent QTIDeclaration as a side-effect — identical to + * the strategy-class pattern in Kolibri's declarationParsers. + * + * qti-interpolation-table, qti-match-table, and ruleHandlers are intentionally + * omitted — the authoring editor has no lookup-table or response-processing support. + */ +import CorrectResponse from './correctResponse.js'; +import DefaultValue from './defaultValue.js'; +import Mapping from './mapping.js'; +import AreaMapping from './areaMapping.js'; + +export { CAPABILITY } from './capabilities.js'; + +/** + * Maps QTI child element tag names to declaration strategy classes. + * QTIDeclaration.fromXML iterates child elements and delegates to these. + * + * @type {Object.} + */ +export const declarationParsers = { + 'qti-correct-response': CorrectResponse, + 'qti-default-value': DefaultValue, + 'qti-mapping': Mapping, + 'qti-area-mapping': AreaMapping, +}; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js new file mode 100644 index 0000000000..3cf542a85c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js @@ -0,0 +1,104 @@ +/** + * Mapping declaration strategy. + * + * Parses a element into plain JS data for authoring round-trip. + * Registers a MAPPING capability so the data is accessible and re-serializable. + * + * Scoring logic (score(), clampScore(), lookup()) and the ScoringDeclaration + * base class from the Kolibri original are intentionally omitted — the + * authoring editor does not evaluate responses at runtime. + */ +import { buildXmlNode } from '../../assembleItem.js'; +import { CAPABILITY } from './capabilities.js'; + +/** + * Parse the bound attributes shared by Mapping and AreaMapping. + * + * @param {Element} xmlNode + * @returns {{ defaultValue: number, lowerBound: number|null, upperBound: number|null }} + */ +export function parseScoringAttrs(xmlNode) { + const parsed = parseFloat(xmlNode.getAttribute('default-value')); + const defaultValue = isNaN(parsed) ? 0 : parsed; + + const lb = xmlNode.hasAttribute('lower-bound') + ? parseFloat(xmlNode.getAttribute('lower-bound')) + : NaN; + const lowerBound = isNaN(lb) ? null : lb; + + const ub = xmlNode.hasAttribute('upper-bound') + ? parseFloat(xmlNode.getAttribute('upper-bound')) + : NaN; + const upperBound = isNaN(ub) ? null : ub; + + return { defaultValue, lowerBound, upperBound }; +} + +export default class Mapping { + /** + * @param {{ + * defaultValue: number, + * lowerBound: number|null, + * upperBound: number|null, + * entries: Array<{ mapKey: string|number|boolean, mappedValue: number, + * caseSensitive: boolean }> + * }} data + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + */ + constructor(data, declaration) { + this._data = data; + this._declaration = declaration; + declaration.registerCapability(CAPABILITY.MAPPING, this); + } + + /** + * Parse a element and register on the parent declaration. + * + * @param {Element} xmlNode + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {Mapping} + */ + static fromXML(xmlNode, declaration) { + const bounds = parseScoringAttrs(xmlNode); + + const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => ({ + mapKey: declaration.coerceValue(entry.getAttribute('map-key')), + mappedValue: parseFloat(entry.getAttribute('mapped-value')), + // Per QTI spec, case-sensitive defaults to true; only false when explicitly set. + caseSensitive: entry.getAttribute('case-sensitive') !== 'false', + })); + + return new Mapping({ ...bounds, entries }, declaration); + } + + /** + * @returns {{ defaultValue: number, lowerBound: number|null, + * upperBound: number|null, entries: Array }} + */ + get() { + return this._data; + } + + /** + * @returns {Element} + */ + getXML() { + const { defaultValue, lowerBound, upperBound, entries } = this._data; + + const attrs = { 'default-value': defaultValue }; + if (lowerBound !== null) attrs['lower-bound'] = lowerBound; + if (upperBound !== null) attrs['upper-bound'] = upperBound; + + const children = entries.map(entry => { + const entryAttrs = { + 'map-key': this._declaration.formatValue(entry.mapKey), + 'mapped-value': entry.mappedValue, + }; + // Omit case-sensitive when true — it is the spec default. + if (!entry.caseSensitive) entryAttrs['case-sensitive'] = 'false'; + return buildXmlNode({ tag: 'qti-map-entry', attrs: entryAttrs }); + }); + + return buildXmlNode({ tag: 'qti-mapping', attrs, children }); + } +}