From be5e5cb7bfb1a4325f1f00df90deb17e3efc3cc6 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Sun, 21 Jun 2026 11:45:57 +0530 Subject: [PATCH 1/5] feat: implement QTI declaration serialization framework and validation utilities Signed-off-by: Abhishek-Punhani --- .../__tests__/buildXmlNode.spec.js | 109 ++++++++ .../QTIEditor/serialization/assembleItem.js | 41 +++ .../serialization/qti/QTIDeclaration.js | 246 ++++++++++++++++ .../serialization/qti/QTISanitizer.js | 128 +++++++++ .../qti/__tests__/QTIDeclaration.spec.js | 262 ++++++++++++++++++ .../QTIDeclaration.typeConversion.spec.js | 237 ++++++++++++++++ .../qti/__tests__/QTISanitizer.spec.js | 173 ++++++++++++ .../declarations/areaMapping.spec.js | 173 ++++++++++++ .../declarations/correctResponse.spec.js | 197 +++++++++++++ .../declarations/defaultValue.spec.js | 109 ++++++++ .../qti/__tests__/declarations/fixtures.js | 44 +++ .../__tests__/declarations/mapping.spec.js | 237 ++++++++++++++++ .../qti/__tests__/fixtures/declarations.js | 42 +++ .../serialization/qti/__tests__/testUtils.js | 17 ++ .../qti/declarations/areaMapping.js | 94 +++++++ .../qti/declarations/capabilities.js | 20 ++ .../qti/declarations/correctResponse.js | 66 +++++ .../qti/declarations/defaultValue.js | 63 +++++ .../serialization/qti/declarations/index.js | 33 +++ .../serialization/qti/declarations/mapping.js | 119 ++++++++ .../serialization/qti/interactionSchema.js | 69 +++++ 21 files changed, 2479 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTISanitizer.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTISanitizer.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/areaMapping.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/defaultValue.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/fixtures/declarations.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js new file mode 100644 index 0000000000..1c3c80341e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js @@ -0,0 +1,109 @@ +/* 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('buildXmlNode', () => { + 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..715f87fe45 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -0,0 +1,246 @@ +/** + * 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, + * and serializes back to XML on demand. Carries no runtime value state or scoring logic. + * + */ +import { buildXmlNode } from '../assembleItem.js'; +import { declarationParsers, CAPABILITY } from './declarations/index.js'; +import { getSchemaForType } from './interactionSchema.js'; +import CorrectResponse from './declarations/correctResponse.js'; +import Mapping from './declarations/mapping.js'; +import AreaMapping from './declarations/areaMapping.js'; + +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 = 'single', + tag = 'qti-response-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 fromXML/fromPlain. + * + * @param {string} name - One of the CAPABILITY constants + * @param {{ get(): *, getXML(): Element }} declarationObject + */ + registerCapability(name, declarationObject) { + this._capabilities[name] = declarationObject; + } + + // --------------------------------------------------------------------------- + // Convenience getters + // --------------------------------------------------------------------------- + + /** @type {string[]|null} */ + get correctResponse() { + return this._capabilities[CAPABILITY.CORRECT_RESPONSE]?.get() ?? null; + } + + /** @type {string[]|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; + } + + // --------------------------------------------------------------------------- + // 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') ?? '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. + * Reads base-type and cardinality from the interaction schema. + * + * @param {string} questionType - One of QuestionType.* (must exist in INTERACTION_SCHEMA) + * @param {string} [identifier] - Response identifier, defaults to 'RESPONSE' + * @returns {QTIDeclaration} + */ + static forType(questionType, identifier = 'RESPONSE') { + const schema = getSchemaForType(questionType); + if (!schema) throw new Error(`Unknown question type: ${questionType}`); + return new QTIDeclaration({ + identifier, + baseType: schema.baseType, + cardinality: schema.cardinality, + tag: 'qti-response-declaration', + }); + } + + /** + * Convert this declaration to match a different question type. + * Returns a new QTIDeclaration — this instance is never mutated. + * + * Conversion rules: + * - Same base-type: correctResponse values are migrated with cardinality + * rules applied (wrap on upgrade, truncate on downgrade). Mapping is + * carried forward when the target schema supports it. + * - Different base-type: correctResponse and mapping are dropped (the + * stored keys/values would be invalid for the new type). + * - defaultValue is always dropped (type-specific, unlikely to remain valid). + * + * @param {string} newQuestionType - Target QuestionType.* + * @param {object} [opts] + * @param {boolean} [opts.keepFirst=true] - On multiple/ordered→single downgrade, + * retain the first value instead of dropping all. + * @returns {QTIDeclaration} + */ + convertTo(newQuestionType, { keepFirst = true } = {}) { + const schema = getSchemaForType(newQuestionType); + if (!schema) throw new Error(`Unknown question type: ${newQuestionType}`); + + const newDecl = new QTIDeclaration({ + identifier: this.identifier, + baseType: schema.baseType, + cardinality: schema.cardinality, + tag: this.tag, + }); + + const baseTypeChanged = schema.baseType !== this.baseType; + + const currentCR = this.correctResponse; + if ( + currentCR !== null && + !baseTypeChanged && + schema.capabilities.includes(CAPABILITY.CORRECT_RESPONSE) + ) { + const migrated = migrateValues(currentCR, this.cardinality, schema.cardinality, keepFirst); + if (migrated.length > 0) { + CorrectResponse.fromPlain(migrated, newDecl); + } + } + + const currentMapping = this.mapping; + if ( + currentMapping !== null && + !baseTypeChanged && + schema.capabilities.includes(CAPABILITY.MAPPING) + ) { + Mapping.fromPlain(currentMapping, newDecl); + } + + const currentAreaMapping = this.areaMapping; + if ( + currentAreaMapping !== null && + !baseTypeChanged && + schema.capabilities.includes(CAPABILITY.AREA_MAPPING) + ) { + AreaMapping.fromPlain(currentAreaMapping, newDecl); + } + + return newDecl; + } +} + +// --------------------------------------------------------------------------- +// Module-private helpers +// --------------------------------------------------------------------------- + +/** + * Migrate correctResponse values across a cardinality change. + * + * @param {string[]} values - Current values + * @param {string} fromCard - Source cardinality + * @param {string} toCard - Target cardinality + * @param {boolean} keepFirst - On downgrade to single, keep the first value + * @returns {string[]} + */ +function migrateValues(values, fromCard, toCard, keepFirst) { + const arr = Array.isArray(values) ? values : [values]; + + if (toCard === 'single') { + return keepFirst && arr.length > 0 ? [arr[0]] : []; + } + + // single → multiple/ordered, or multiple ↔ ordered: values transfer unchanged. + return arr; +} 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..291fca187f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js @@ -0,0 +1,262 @@ +/* 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 } from './testUtils.js'; +import { + DECLARATION_WITH_MAPPING, + MULTI_SELECT_DECLARATION, + NO_BASETYPE_DECLARATION, + OUTCOME_DECLARATION, + SINGLE_SELECT_DECLARATION, +} from './fixtures/declarations.js'; + +const serializer = new XMLSerializer(); +const parser = new DOMParser(); + +function reparse(node) { + const xml = serializer.serializeToString(node); + const doc = parser.parseFromString(xml, 'text/xml'); + if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); + return doc.documentElement; +} + +// --------------------------------------------------------------------------- +// Constructor tests +// --------------------------------------------------------------------------- + +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('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'); + }); +}); + +// --------------------------------------------------------------------------- +// registerCapability tests +// --------------------------------------------------------------------------- + +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']); + }); +}); + +// --------------------------------------------------------------------------- +// fromXML tests +// --------------------------------------------------------------------------- + +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(); + }); +}); + +// --------------------------------------------------------------------------- +// getXML / round-trip tests +// --------------------------------------------------------------------------- + +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 = serializer.serializeToString(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 = serializer.serializeToString(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']); + }); +}); + +// --------------------------------------------------------------------------- +// Full XML output — QTI 3.0 compatibility +// --------------------------------------------------------------------------- + +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_日本語'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js new file mode 100644 index 0000000000..cac84ab69e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js @@ -0,0 +1,237 @@ +import { QTIDeclaration } from '../QTIDeclaration.js'; +import { QuestionType } from '../../../constants.js'; + +import { getSchemaForType, isBaseTypeCompatible } from '../interactionSchema.js'; +import CorrectResponse from '../declarations/correctResponse.js'; +import Mapping from '../declarations/mapping.js'; +import { parseXML } from './testUtils.js'; + +const serializer = new XMLSerializer(); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeSingleSelectDecl(values = ['ChoiceA']) { + const xml = ` + + + ${values.map(v => `${v}`).join('')} + + + `.trim(); + return QTIDeclaration.fromXML(parseXML(xml)); +} + +function makeMultiSelectDecl(values = ['ChoiceA', 'ChoiceC']) { + const xml = ` + + + ${values.map(v => `${v}`).join('')} + + + `.trim(); + return QTIDeclaration.fromXML(parseXML(xml)); +} + +// --------------------------------------------------------------------------- +// QTIDeclaration.forType() +// --------------------------------------------------------------------------- + +describe('QTIDeclaration.forType', () => { + it('creates correct shape for singleSelect', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); + expect(d.baseType).toBe('identifier'); + expect(d.cardinality).toBe('single'); + expect(d.tag).toBe('qti-response-declaration'); + }); + + it('creates correct shape for multiSelect', () => { + const d = QTIDeclaration.forType(QuestionType.MULTI_SELECT); + expect(d.baseType).toBe('identifier'); + expect(d.cardinality).toBe('multiple'); + }); + + it('uses the supplied identifier', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT, 'Q1'); + expect(d.identifier).toBe('Q1'); + }); + + it('defaults identifier to RESPONSE', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); + expect(d.identifier).toBe('RESPONSE'); + }); + + it('starts with no capabilities', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); + expect(d.correctResponse).toBeNull(); + expect(d.mapping).toBeNull(); + }); + + it('throws for an unknown question type', () => { + expect(() => QTIDeclaration.forType('unknownType')).toThrow('Unknown question type'); + }); +}); + +// --------------------------------------------------------------------------- +// QTIDeclaration.convertTo() — same base-type (identifier ↔ identifier) +// --------------------------------------------------------------------------- + +describe('QTIDeclaration.convertTo — compatible base-type', () => { + describe('singleSelect → multiSelect (upgrade)', () => { + it('sets cardinality to multiple', () => { + const converted = makeSingleSelectDecl().convertTo(QuestionType.MULTI_SELECT); + expect(converted.cardinality).toBe('multiple'); + }); + + it('preserves base-type', () => { + const converted = makeSingleSelectDecl().convertTo(QuestionType.MULTI_SELECT); + expect(converted.baseType).toBe('identifier'); + }); + + it('preserves correctResponse value', () => { + const converted = makeSingleSelectDecl(['ChoiceA']).convertTo(QuestionType.MULTI_SELECT); + expect(converted.correctResponse).toEqual(['ChoiceA']); + }); + + it('preserves identifier', () => { + const converted = makeSingleSelectDecl().convertTo(QuestionType.MULTI_SELECT); + expect(converted.identifier).toBe('RESPONSE'); + }); + + it('preserves mapping when base-type is unchanged', () => { + const xml = ` + + ChoiceA + + + + + + `.trim(); + const d = QTIDeclaration.fromXML(parseXML(xml)); + const converted = d.convertTo(QuestionType.MULTI_SELECT); + expect(converted.mapping).not.toBeNull(); + expect(converted.mapping.entries).toHaveLength(2); + }); + }); + + describe('multiSelect → singleSelect (downgrade)', () => { + it('sets cardinality to single', () => { + const converted = makeMultiSelectDecl().convertTo(QuestionType.SINGLE_SELECT); + expect(converted.cardinality).toBe('single'); + }); + + it('keeps only the first correctResponse value by default', () => { + const converted = makeMultiSelectDecl(['ChoiceA', 'ChoiceC']).convertTo( + QuestionType.SINGLE_SELECT, + ); + expect(converted.correctResponse).toEqual(['ChoiceA']); + }); + + it('drops all correctResponse values when keepFirst is false', () => { + const converted = makeMultiSelectDecl(['ChoiceA', 'ChoiceC']).convertTo( + QuestionType.SINGLE_SELECT, + { keepFirst: false }, + ); + expect(converted.correctResponse).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('converts a declaration with no correctResponse without throwing', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); + const converted = d.convertTo(QuestionType.MULTI_SELECT); + expect(converted.correctResponse).toBeNull(); + }); + + it('does not mutate the original declaration', () => { + const original = makeSingleSelectDecl(['ChoiceA']); + original.convertTo(QuestionType.MULTI_SELECT); + expect(original.cardinality).toBe('single'); + expect(original.correctResponse).toEqual(['ChoiceA']); + }); + + it('converted declaration serializes valid XML', () => { + const converted = makeMultiSelectDecl(['ChoiceA', 'ChoiceC']).convertTo( + QuestionType.SINGLE_SELECT, + ); + const xml = serializer.serializeToString(converted.getXML()); + expect(xml).toContain('cardinality="single"'); + expect(xml).toContain('base-type="identifier"'); + expect(xml).toContain('ChoiceA'); + }); + + it('throws when converting to an unknown question type', () => { + const d = makeSingleSelectDecl(); + expect(() => d.convertTo('unknownType')).toThrow('Unknown question type'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// fromPlain — direct capability registration without XML +// --------------------------------------------------------------------------- + +describe('CorrectResponse.fromPlain', () => { + it('registers the capability and exposes values via getter', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); + CorrectResponse.fromPlain(['ChoiceB'], d); + expect(d.correctResponse).toEqual(['ChoiceB']); + }); + + it('overwrites an existing correctResponse capability', () => { + const d = makeSingleSelectDecl(['ChoiceA']); + CorrectResponse.fromPlain(['ChoiceB'], d); + expect(d.correctResponse).toEqual(['ChoiceB']); + }); +}); + +describe('Mapping.fromPlain', () => { + it('registers the capability and exposes data via getter', () => { + const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); + const data = { + defaultValue: 0, + lowerBound: null, + upperBound: null, + entries: [{ mapKey: 'ChoiceA', mappedValue: 1, caseSensitive: true }], + }; + Mapping.fromPlain(data, d); + expect(d.mapping.entries).toHaveLength(1); + expect(d.mapping.defaultValue).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// interactionSchema helpers +// --------------------------------------------------------------------------- + +describe('getSchemaForType', () => { + it('returns the correct schema for singleSelect', () => { + const schema = getSchemaForType(QuestionType.SINGLE_SELECT); + expect(schema.baseType).toBe('identifier'); + expect(schema.cardinality).toBe('single'); + expect(schema.interaction).toBe('qti-choice-interaction'); + }); + + it('returns the correct schema for multiSelect', () => { + const schema = getSchemaForType(QuestionType.MULTI_SELECT); + expect(schema.cardinality).toBe('multiple'); + }); + + it('returns undefined for an unknown type', () => { + expect(getSchemaForType('bogus')).toBeUndefined(); + }); +}); + +describe('isBaseTypeCompatible', () => { + it('returns true for singleSelect ↔ multiSelect', () => { + expect(isBaseTypeCompatible(QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT)).toBe(true); + expect(isBaseTypeCompatible(QuestionType.MULTI_SELECT, QuestionType.SINGLE_SELECT)).toBe(true); + }); + + it('returns false when either type is unknown', () => { + expect(isBaseTypeCompatible('bogus', QuestionType.SINGLE_SELECT)).toBe(false); + expect(isBaseTypeCompatible(QuestionType.SINGLE_SELECT, 'bogus')).toBe(false); + }); +}); 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..07193cf36a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/areaMapping.spec.js @@ -0,0 +1,173 @@ +/* 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 } from '../testUtils.js'; +import { AREA_MAPPING_WITH_BOUNDS_XML, AREA_MAPPING_XML } from './fixtures.js'; + +const serializer = new XMLSerializer(); +const parser = new DOMParser(); + +function reparse(node) { + const xml = serializer.serializeToString(node); + const doc = parser.parseFromString(xml, 'text/xml'); + if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); + return doc.documentElement; +} + +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); + 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).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).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..4d088435e2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js @@ -0,0 +1,197 @@ +/* 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 } from '../testUtils.js'; + +const serializer = new XMLSerializer(); +const parser = new DOMParser(); + +function makeDeclaration() { + return new QTIDeclaration({ + identifier: 'RESPONSE', + baseType: 'identifier', + cardinality: 'single', + }); +} + +function parseCorrectResponse(xmlString) { + return parseXML(xmlString).querySelector('qti-correct-response'); +} + +/** + * Serialize a node to an XML string, then re-parse it. + * Throws if the output is not well-formed XML, proving it is safe for a QTI player to consume. + */ +function reparse(node) { + const xml = serializer.serializeToString(node); + const doc = parser.parseFromString(xml, 'text/xml'); + if (doc.querySelector('parsererror')) { + throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); + } + return doc.documentElement; +} + +describe('CorrectResponse', () => { + describe('constructor', () => { + it('stores values', () => { + const cr = new CorrectResponse(['A', 'B']); + expect(cr.get()).toEqual(['A', 'B']); + }); + + it('stores an empty array', () => { + const cr = new CorrectResponse([]); + 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']).getXML().tagName).toBe('qti-correct-response'); + }); + + it('contains a qti-value child for each value', () => { + const values = [ + ...new CorrectResponse(['ChoiceA', 'ChoiceC']).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([]).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']); + expect(() => reparse(cr.getXML())).not.toThrow(); + }); + + it('re-parsed XML has the correct qti-correct-response root tag', () => { + const reparsed = reparse(new CorrectResponse(['ChoiceA']).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']).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']).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(['选择甲', 'اختيار_أ']); + 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']); + 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..14c841cfc2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/defaultValue.spec.js @@ -0,0 +1,109 @@ +/* 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 } from '../testUtils.js'; + +const parser = new DOMParser(); +const serializer = new XMLSerializer(); + +function reparse(node) { + const xml = serializer.serializeToString(node); + const doc = parser.parseFromString(xml, 'text/xml'); + if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); + return doc.documentElement; +} + +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']); + expect(dv.get()).toEqual(['0']); + }); + }); + + describe('fromXML', () => { + it('parses qti-value children as trimmed strings', () => { + 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']).getXML().tagName).toBe('qti-default-value'); + }); + + it('contains a qti-value for each value', () => { + const values = [ + ...new DefaultValue(['true', 'false']).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']).getXML())).not.toThrow(); + }); + + it('re-parsed XML has qti-default-value root tag', () => { + const reparsed = reparse(new DefaultValue(['0.5']).getXML()); + expect(reparsed.tagName).toBe('qti-default-value'); + }); + + it('re-parsed XML preserves qti-value text content', () => { + const reparsed = reparse(new DefaultValue(['3.14']).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(['默认值']).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..dd39b2b368 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js @@ -0,0 +1,237 @@ +/* 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 } from '../testUtils.js'; +import { MAPPING_WITH_BOUNDS_XML, MAPPING_WITH_CI_XML, SIMPLE_MAPPING_XML } from './fixtures.js'; + +const serializer = new XMLSerializer(); +const parser = new DOMParser(); + +function reparse(node) { + const xml = serializer.serializeToString(node); + const doc = parser.parseFromString(xml, 'text/xml'); + if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); + return doc.documentElement; +} + +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); + 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).getXML().tagName).toBe('qti-mapping'); + }); + + it('emits default-value attribute', () => { + const data = { defaultValue: -1, lowerBound: null, upperBound: null, entries: [] }; + expect(new Mapping(data).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).getXML().getAttribute('lower-bound')).toBe('-2'); + expect(new Mapping(withoutBound).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).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).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).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).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..a815907621 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js @@ -0,0 +1,17 @@ +/** + * Shared XML parse helper for declaration tests. + * Mirrors the approach in Kolibri's qtiXmlHelpers.js test utility. + */ +const parser = new DOMParser(); + +/** + * 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; +} 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..9d166c1d9a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js @@ -0,0 +1,94 @@ +/** + * 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. + * + * @module declarations/areaMapping + */ +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 + */ + constructor(data) { + this._data = data; + } + + /** + * 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')), + })); + + const instance = new AreaMapping({ ...bounds, entries }); + declaration.registerCapability(CAPABILITY.AREA_MAPPING, instance); + return instance; + } + + /** + * Build from plain JS data and register on the parent declaration. + * + * @param {{ defaultValue: number, lowerBound: number|null, + * upperBound: number|null, entries: Array }} data + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {AreaMapping} + */ + static fromPlain(data, declaration) { + const instance = new AreaMapping(data); + declaration.registerCapability(CAPABILITY.AREA_MAPPING, instance); + return instance; + } + + /** + * @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..c357fd85a3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js @@ -0,0 +1,20 @@ +/** + * 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. + * + * @module declarations/capabilities + */ + +/** @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..63ee237a34 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js @@ -0,0 +1,66 @@ +/** + * CorrectResponse declaration strategy. + * + * Parses a element into an array of raw trimmed strings + * and re-serializes it on demand. Values are kept as strings because the + * authoring editor has no runtime value state and performs no type coercion. + * + * @module declarations/correctResponse + */ +import { buildXmlNode } from '../../assembleItem.js'; +import { CAPABILITY } from './capabilities.js'; + +export default class CorrectResponse { + /** @param {string[]} values - Correct response values as raw strings */ + constructor(values) { + /** @type {string[]} */ + this._values = values; + } + + /** + * 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 values = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); + const instance = new CorrectResponse(values); + declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, instance); + return instance; + } + + /** + * Build from plain JS data and register on the parent declaration. + * Used by QTIDeclaration.convertTo() to carry forward values without XML serialization. + * + * @param {string[]} values + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {CorrectResponse} + */ + static fromPlain(values, declaration) { + const instance = new CorrectResponse(values); + declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, instance); + return instance; + } + + /** + * @returns {string[]} + */ + get() { + return this._values; + } + + /** + * @returns {Element} + */ + getXML() { + return buildXmlNode({ + tag: 'qti-correct-response', + children: 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..c6f6e66c44 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js @@ -0,0 +1,63 @@ +/** + * DefaultValue declaration strategy. + * + * Parses a element into an array of raw trimmed strings + * and re-serializes it on demand. Included for declaration round-trip fidelity; + * the authoring editor does not evaluate or coerce default values at runtime. + * + * @module declarations/defaultValue + */ +import { buildXmlNode } from '../../assembleItem.js'; +import { CAPABILITY } from './capabilities.js'; + +export default class DefaultValue { + /** @param {string[]} values - Default values as raw strings */ + constructor(values) { + /** @type {string[]} */ + this._values = values; + } + + /** + * 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 values = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); + const instance = new DefaultValue(values); + declaration.registerCapability(CAPABILITY.DEFAULT_VALUE, instance); + return instance; + } + + /** + * Build from plain JS data and register on the parent declaration. + * + * @param {string[]} values + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {DefaultValue} + */ + static fromPlain(values, declaration) { + const instance = new DefaultValue(values); + declaration.registerCapability(CAPABILITY.DEFAULT_VALUE, instance); + return instance; + } + + /** + * @returns {string[]} + */ + get() { + return this._values; + } + + /** + * @returns {Element} + */ + getXML() { + return buildXmlNode({ + tag: 'qti-default-value', + children: 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..cef5262bd4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js @@ -0,0 +1,33 @@ +/** + * 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. + * + * @module declarations/index + */ +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..4e682147f4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js @@ -0,0 +1,119 @@ +/** + * 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. + * + * @module declarations/mapping + */ +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, mappedValue: number, caseSensitive: boolean }> + * }} data + */ + constructor(data) { + this._data = data; + } + + /** + * 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: 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', + })); + + const instance = new Mapping({ ...bounds, entries }); + declaration.registerCapability(CAPABILITY.MAPPING, instance); + return instance; + } + + /** + * Build from plain JS data and register on the parent declaration. + * Used by QTIDeclaration.convertTo() when base-type is unchanged across a type conversion. + * + * @param {{ defaultValue: number, lowerBound: number|null, + * upperBound: number|null, entries: Array }} data + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration + * @returns {Mapping} + */ + static fromPlain(data, declaration) { + const instance = new Mapping(data); + declaration.registerCapability(CAPABILITY.MAPPING, instance); + return instance; + } + + /** + * @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': 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 }); + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js new file mode 100644 index 0000000000..13ff1b5c75 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js @@ -0,0 +1,69 @@ +/** + * Interaction schema registry. + * + * Maps each QuestionType (UI concept) to the QTI declaration shape it requires: + * the interaction tag, base-type, cardinality, and which capabilities are valid. + * + * This is the single source of truth for QTIDeclaration.forType() and + * QTIDeclaration.convertTo(). Adding support for a new interaction type requires + * only adding an entry here — the parsing and serialization layer does not change. + * + * @module serialization/qti/interactionSchema + */ +import { QuestionType, QtiInteraction, Cardinality, BaseType } from '../../constants.js'; +import { CAPABILITY } from './declarations/index.js'; + +/** + * @typedef {object} DeclarationSchema + * @property {string} interaction - QtiInteraction tag name + * @property {string} baseType - Required QTI base-type value + * @property {string} cardinality - Required QTI cardinality value + * @property {string[]} capabilities - Valid CAPABILITY keys for this type + */ + +/** + * Schema registry: QuestionType → declaration shape. + * Expand this object to support additional interaction types. + * + * @type {Object.} + */ +export const INTERACTION_SCHEMA = Object.freeze({ + [QuestionType.SINGLE_SELECT]: { + interaction: QtiInteraction.CHOICE, + baseType: BaseType.IDENTIFIER, + cardinality: Cardinality.SINGLE, + capabilities: [CAPABILITY.CORRECT_RESPONSE, CAPABILITY.DEFAULT_VALUE, CAPABILITY.MAPPING], + }, + + [QuestionType.MULTI_SELECT]: { + interaction: QtiInteraction.CHOICE, + baseType: BaseType.IDENTIFIER, + cardinality: Cardinality.MULTIPLE, + capabilities: [CAPABILITY.CORRECT_RESPONSE, CAPABILITY.DEFAULT_VALUE, CAPABILITY.MAPPING], + }, +}); + +/** + * Look up the schema for a given QuestionType. + * + * @param {string} questionType - One of QuestionType.* + * @returns {DeclarationSchema|undefined} + */ +export function getSchemaForType(questionType) { + return INTERACTION_SCHEMA[questionType]; +} + +/** + * Return true when two question types share the same base-type, meaning + * correctResponse values can be reused on a type conversion. + * + * @param {string} fromType - Source QuestionType + * @param {string} toType - Target QuestionType + * @returns {boolean} + */ +export function isBaseTypeCompatible(fromType, toType) { + const from = INTERACTION_SCHEMA[fromType]; + const to = INTERACTION_SCHEMA[toType]; + if (!from || !to) return false; + return from.baseType === to.baseType; +} From f0cc8d545835a04996d971647157972b3585c6d0 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Mon, 22 Jun 2026 22:25:45 +0530 Subject: [PATCH 2/5] feat: add QTI attribute validation to QTIDeclaration serialization using QTISanitizer Signed-off-by: Abhishek-Punhani --- .../views/QTIEditor/serialization/qti/QTIDeclaration.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index 715f87fe45..644e002034 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -12,6 +12,7 @@ import { getSchemaForType } from './interactionSchema.js'; import CorrectResponse from './declarations/correctResponse.js'; import Mapping from './declarations/mapping.js'; import AreaMapping from './declarations/areaMapping.js'; +import { QTISanitizer } from './QTISanitizer.js'; export class QTIDeclaration { /** @@ -119,6 +120,10 @@ export class QTIDeclaration { * @returns {Element} */ getXML() { + QTISanitizer.validateIdentifier(this.identifier); + QTISanitizer.validateCardinality(this.cardinality); + QTISanitizer.validateBaseType(this.baseType); + const attrs = { identifier: this.identifier, cardinality: this.cardinality, From eb9314f105a27f3983f553987fc7d75e5c49838d Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Tue, 23 Jun 2026 00:38:16 +0530 Subject: [PATCH 3/5] refactor: integrate QTIDeclaration dependency into QTI response capabilities for improved type coercion and schema alignment. Signed-off-by: Abhishek-Punhani --- .../QTIEditor/interactions/choice/index.js | 17 +- .../interactions/defineInteraction.js | 1 + .../views/QTIEditor/interactions/index.js | 10 + .../serialization/qti/QTIDeclaration.js | 219 ++++++++-------- .../qti/__tests__/QTIDeclaration.spec.js | 6 + .../QTIDeclaration.typeConversion.spec.js | 237 ------------------ .../declarations/areaMapping.spec.js | 6 +- .../declarations/correctResponse.spec.js | 32 ++- .../declarations/defaultValue.spec.js | 20 +- .../__tests__/declarations/mapping.spec.js | 28 ++- .../qti/declarations/areaMapping.js | 22 +- .../qti/declarations/correctResponse.js | 43 ++-- .../qti/declarations/defaultValue.js | 42 ++-- .../serialization/qti/declarations/mapping.js | 31 +-- .../serialization/qti/interactionSchema.js | 69 ----- 15 files changed, 248 insertions(+), 535 deletions(-) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js delete mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js 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/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index 644e002034..ccad7e8397 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -2,17 +2,15 @@ * 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, + * 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'; -import { getSchemaForType } from './interactionSchema.js'; -import CorrectResponse from './declarations/correctResponse.js'; -import Mapping from './declarations/mapping.js'; -import AreaMapping from './declarations/areaMapping.js'; -import { QTISanitizer } from './QTISanitizer.js'; export class QTIDeclaration { /** @@ -28,6 +26,42 @@ export class QTIDeclaration { 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 === '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; @@ -56,12 +90,12 @@ export class QTIDeclaration { // Convenience getters // --------------------------------------------------------------------------- - /** @type {string[]|null} */ + /** @type {Array|null} */ get correctResponse() { return this._capabilities[CAPABILITY.CORRECT_RESPONSE]?.get() ?? null; } - /** @type {string[]|null} */ + /** @type {Array|null} */ get defaultValue() { return this._capabilities[CAPABILITY.DEFAULT_VALUE]?.get() ?? null; } @@ -76,6 +110,71 @@ export class QTIDeclaration { return this._capabilities[CAPABILITY.AREA_MAPPING]?.get() ?? null; } + // --------------------------------------------------------------------------- + // Value coercion and formatting + // --------------------------------------------------------------------------- + + /** + * Coerce a raw XML string value to its native JS type based on a QTI base-type. + * + * QTI stores all values as text nodes in XML. This method converts the raw + * string to the appropriate JS primitive so the editor works with native types. + * + * @param {string} raw - Raw text from a element + * @param {string} baseType - One of BaseType.* (or null) + * @returns {string|number|boolean} + */ + static coerceValue(raw, baseType) { + switch (baseType) { + case BaseType.INTEGER: { + const n = parseInt(raw, 10); + return Number.isNaN(n) ? raw : n; + } + case BaseType.FLOAT: { + const n = parseFloat(raw); + return Number.isNaN(n) ? raw : n; + } + case BaseType.BOOLEAN: + return raw === 'true'; + default: + // identifier, string, point, pair, directedPair, duration, file, uri + 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. + * + * @param {string|number|boolean} value + * @returns {string} + */ + static formatValue(value) { + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value ?? ''); + } + + /** + * Coerce an array of raw XML strings using this declaration's baseType. + * + * @param {string[]} rawStrings + * @returns {Array} + */ + coerceValues(rawStrings) { + return rawStrings.map(v => QTIDeclaration.coerceValue(v, this.baseType)); + } + + /** + * Format an array of native JS values to QTI XML strings. + * + * @param {Array} values + * @returns {string[]} + */ + formatValues(values) { + return values.map(QTIDeclaration.formatValue); + } + // --------------------------------------------------------------------------- // XML entry point // --------------------------------------------------------------------------- @@ -120,10 +219,6 @@ export class QTIDeclaration { * @returns {Element} */ getXML() { - QTISanitizer.validateIdentifier(this.identifier); - QTISanitizer.validateCardinality(this.cardinality); - QTISanitizer.validateBaseType(this.baseType); - const attrs = { identifier: this.identifier, cardinality: this.cardinality, @@ -144,15 +239,17 @@ export class QTIDeclaration { /** * Create a blank QTIDeclaration shaped for the given question type. - * Reads base-type and cardinality from the interaction schema. + * Delegates to the factory registered by each interaction module. * - * @param {string} questionType - One of QuestionType.* (must exist in INTERACTION_SCHEMA) + * @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') { - const schema = getSchemaForType(questionType); - if (!schema) throw new Error(`Unknown question type: ${questionType}`); + 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, @@ -160,92 +257,4 @@ export class QTIDeclaration { tag: 'qti-response-declaration', }); } - - /** - * Convert this declaration to match a different question type. - * Returns a new QTIDeclaration — this instance is never mutated. - * - * Conversion rules: - * - Same base-type: correctResponse values are migrated with cardinality - * rules applied (wrap on upgrade, truncate on downgrade). Mapping is - * carried forward when the target schema supports it. - * - Different base-type: correctResponse and mapping are dropped (the - * stored keys/values would be invalid for the new type). - * - defaultValue is always dropped (type-specific, unlikely to remain valid). - * - * @param {string} newQuestionType - Target QuestionType.* - * @param {object} [opts] - * @param {boolean} [opts.keepFirst=true] - On multiple/ordered→single downgrade, - * retain the first value instead of dropping all. - * @returns {QTIDeclaration} - */ - convertTo(newQuestionType, { keepFirst = true } = {}) { - const schema = getSchemaForType(newQuestionType); - if (!schema) throw new Error(`Unknown question type: ${newQuestionType}`); - - const newDecl = new QTIDeclaration({ - identifier: this.identifier, - baseType: schema.baseType, - cardinality: schema.cardinality, - tag: this.tag, - }); - - const baseTypeChanged = schema.baseType !== this.baseType; - - const currentCR = this.correctResponse; - if ( - currentCR !== null && - !baseTypeChanged && - schema.capabilities.includes(CAPABILITY.CORRECT_RESPONSE) - ) { - const migrated = migrateValues(currentCR, this.cardinality, schema.cardinality, keepFirst); - if (migrated.length > 0) { - CorrectResponse.fromPlain(migrated, newDecl); - } - } - - const currentMapping = this.mapping; - if ( - currentMapping !== null && - !baseTypeChanged && - schema.capabilities.includes(CAPABILITY.MAPPING) - ) { - Mapping.fromPlain(currentMapping, newDecl); - } - - const currentAreaMapping = this.areaMapping; - if ( - currentAreaMapping !== null && - !baseTypeChanged && - schema.capabilities.includes(CAPABILITY.AREA_MAPPING) - ) { - AreaMapping.fromPlain(currentAreaMapping, newDecl); - } - - return newDecl; - } -} - -// --------------------------------------------------------------------------- -// Module-private helpers -// --------------------------------------------------------------------------- - -/** - * Migrate correctResponse values across a cardinality change. - * - * @param {string[]} values - Current values - * @param {string} fromCard - Source cardinality - * @param {string} toCard - Target cardinality - * @param {boolean} keepFirst - On downgrade to single, keep the first value - * @returns {string[]} - */ -function migrateValues(values, fromCard, toCard, keepFirst) { - const arr = Array.isArray(values) ? values : [values]; - - if (toCard === 'single') { - return keepFirst && arr.length > 0 ? [arr[0]] : []; - } - - // single → multiple/ordered, or multiple ↔ ordered: values transfer unchanged. - return arr; } 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 index 291fca187f..e5f00bac82 100644 --- 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 @@ -42,6 +42,12 @@ describe('QTIDeclaration constructor', () => { 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(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js deleted file mode 100644 index cac84ab69e..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.typeConversion.spec.js +++ /dev/null @@ -1,237 +0,0 @@ -import { QTIDeclaration } from '../QTIDeclaration.js'; -import { QuestionType } from '../../../constants.js'; - -import { getSchemaForType, isBaseTypeCompatible } from '../interactionSchema.js'; -import CorrectResponse from '../declarations/correctResponse.js'; -import Mapping from '../declarations/mapping.js'; -import { parseXML } from './testUtils.js'; - -const serializer = new XMLSerializer(); - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -function makeSingleSelectDecl(values = ['ChoiceA']) { - const xml = ` - - - ${values.map(v => `${v}`).join('')} - - - `.trim(); - return QTIDeclaration.fromXML(parseXML(xml)); -} - -function makeMultiSelectDecl(values = ['ChoiceA', 'ChoiceC']) { - const xml = ` - - - ${values.map(v => `${v}`).join('')} - - - `.trim(); - return QTIDeclaration.fromXML(parseXML(xml)); -} - -// --------------------------------------------------------------------------- -// QTIDeclaration.forType() -// --------------------------------------------------------------------------- - -describe('QTIDeclaration.forType', () => { - it('creates correct shape for singleSelect', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); - expect(d.baseType).toBe('identifier'); - expect(d.cardinality).toBe('single'); - expect(d.tag).toBe('qti-response-declaration'); - }); - - it('creates correct shape for multiSelect', () => { - const d = QTIDeclaration.forType(QuestionType.MULTI_SELECT); - expect(d.baseType).toBe('identifier'); - expect(d.cardinality).toBe('multiple'); - }); - - it('uses the supplied identifier', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT, 'Q1'); - expect(d.identifier).toBe('Q1'); - }); - - it('defaults identifier to RESPONSE', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); - expect(d.identifier).toBe('RESPONSE'); - }); - - it('starts with no capabilities', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); - expect(d.correctResponse).toBeNull(); - expect(d.mapping).toBeNull(); - }); - - it('throws for an unknown question type', () => { - expect(() => QTIDeclaration.forType('unknownType')).toThrow('Unknown question type'); - }); -}); - -// --------------------------------------------------------------------------- -// QTIDeclaration.convertTo() — same base-type (identifier ↔ identifier) -// --------------------------------------------------------------------------- - -describe('QTIDeclaration.convertTo — compatible base-type', () => { - describe('singleSelect → multiSelect (upgrade)', () => { - it('sets cardinality to multiple', () => { - const converted = makeSingleSelectDecl().convertTo(QuestionType.MULTI_SELECT); - expect(converted.cardinality).toBe('multiple'); - }); - - it('preserves base-type', () => { - const converted = makeSingleSelectDecl().convertTo(QuestionType.MULTI_SELECT); - expect(converted.baseType).toBe('identifier'); - }); - - it('preserves correctResponse value', () => { - const converted = makeSingleSelectDecl(['ChoiceA']).convertTo(QuestionType.MULTI_SELECT); - expect(converted.correctResponse).toEqual(['ChoiceA']); - }); - - it('preserves identifier', () => { - const converted = makeSingleSelectDecl().convertTo(QuestionType.MULTI_SELECT); - expect(converted.identifier).toBe('RESPONSE'); - }); - - it('preserves mapping when base-type is unchanged', () => { - const xml = ` - - ChoiceA - - - - - - `.trim(); - const d = QTIDeclaration.fromXML(parseXML(xml)); - const converted = d.convertTo(QuestionType.MULTI_SELECT); - expect(converted.mapping).not.toBeNull(); - expect(converted.mapping.entries).toHaveLength(2); - }); - }); - - describe('multiSelect → singleSelect (downgrade)', () => { - it('sets cardinality to single', () => { - const converted = makeMultiSelectDecl().convertTo(QuestionType.SINGLE_SELECT); - expect(converted.cardinality).toBe('single'); - }); - - it('keeps only the first correctResponse value by default', () => { - const converted = makeMultiSelectDecl(['ChoiceA', 'ChoiceC']).convertTo( - QuestionType.SINGLE_SELECT, - ); - expect(converted.correctResponse).toEqual(['ChoiceA']); - }); - - it('drops all correctResponse values when keepFirst is false', () => { - const converted = makeMultiSelectDecl(['ChoiceA', 'ChoiceC']).convertTo( - QuestionType.SINGLE_SELECT, - { keepFirst: false }, - ); - expect(converted.correctResponse).toBeNull(); - }); - }); - - describe('edge cases', () => { - it('converts a declaration with no correctResponse without throwing', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); - const converted = d.convertTo(QuestionType.MULTI_SELECT); - expect(converted.correctResponse).toBeNull(); - }); - - it('does not mutate the original declaration', () => { - const original = makeSingleSelectDecl(['ChoiceA']); - original.convertTo(QuestionType.MULTI_SELECT); - expect(original.cardinality).toBe('single'); - expect(original.correctResponse).toEqual(['ChoiceA']); - }); - - it('converted declaration serializes valid XML', () => { - const converted = makeMultiSelectDecl(['ChoiceA', 'ChoiceC']).convertTo( - QuestionType.SINGLE_SELECT, - ); - const xml = serializer.serializeToString(converted.getXML()); - expect(xml).toContain('cardinality="single"'); - expect(xml).toContain('base-type="identifier"'); - expect(xml).toContain('ChoiceA'); - }); - - it('throws when converting to an unknown question type', () => { - const d = makeSingleSelectDecl(); - expect(() => d.convertTo('unknownType')).toThrow('Unknown question type'); - }); - }); -}); - -// --------------------------------------------------------------------------- -// fromPlain — direct capability registration without XML -// --------------------------------------------------------------------------- - -describe('CorrectResponse.fromPlain', () => { - it('registers the capability and exposes values via getter', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); - CorrectResponse.fromPlain(['ChoiceB'], d); - expect(d.correctResponse).toEqual(['ChoiceB']); - }); - - it('overwrites an existing correctResponse capability', () => { - const d = makeSingleSelectDecl(['ChoiceA']); - CorrectResponse.fromPlain(['ChoiceB'], d); - expect(d.correctResponse).toEqual(['ChoiceB']); - }); -}); - -describe('Mapping.fromPlain', () => { - it('registers the capability and exposes data via getter', () => { - const d = QTIDeclaration.forType(QuestionType.SINGLE_SELECT); - const data = { - defaultValue: 0, - lowerBound: null, - upperBound: null, - entries: [{ mapKey: 'ChoiceA', mappedValue: 1, caseSensitive: true }], - }; - Mapping.fromPlain(data, d); - expect(d.mapping.entries).toHaveLength(1); - expect(d.mapping.defaultValue).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// interactionSchema helpers -// --------------------------------------------------------------------------- - -describe('getSchemaForType', () => { - it('returns the correct schema for singleSelect', () => { - const schema = getSchemaForType(QuestionType.SINGLE_SELECT); - expect(schema.baseType).toBe('identifier'); - expect(schema.cardinality).toBe('single'); - expect(schema.interaction).toBe('qti-choice-interaction'); - }); - - it('returns the correct schema for multiSelect', () => { - const schema = getSchemaForType(QuestionType.MULTI_SELECT); - expect(schema.cardinality).toBe('multiple'); - }); - - it('returns undefined for an unknown type', () => { - expect(getSchemaForType('bogus')).toBeUndefined(); - }); -}); - -describe('isBaseTypeCompatible', () => { - it('returns true for singleSelect ↔ multiSelect', () => { - expect(isBaseTypeCompatible(QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT)).toBe(true); - expect(isBaseTypeCompatible(QuestionType.MULTI_SELECT, QuestionType.SINGLE_SELECT)).toBe(true); - }); - - it('returns false when either type is unknown', () => { - expect(isBaseTypeCompatible('bogus', QuestionType.SINGLE_SELECT)).toBe(false); - expect(isBaseTypeCompatible(QuestionType.SINGLE_SELECT, 'bogus')).toBe(false); - }); -}); 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 index 07193cf36a..94d165c196 100644 --- 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 @@ -27,7 +27,7 @@ describe('AreaMapping', () => { describe('constructor', () => { it('stores the provided data', () => { const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; - const am = new AreaMapping(data); + const am = new AreaMapping(data, makeDeclaration()); expect(am.get()).toEqual(data); }); }); @@ -93,7 +93,7 @@ describe('AreaMapping', () => { describe('getXML', () => { it('produces a qti-area-mapping element', () => { const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; - expect(new AreaMapping(data).getXML().tagName).toBe('qti-area-mapping'); + expect(new AreaMapping(data, makeDeclaration()).getXML().tagName).toBe('qti-area-mapping'); }); it('emits qti-area-map-entry children', () => { @@ -103,7 +103,7 @@ describe('AreaMapping', () => { upperBound: null, entries: [{ shape: 'circle', coords: '100,100,50', mappedValue: 1 }], }; - const node = new AreaMapping(data).getXML(); + 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'); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js index 4d088435e2..54e5bf1580 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js @@ -35,12 +35,12 @@ function reparse(node) { describe('CorrectResponse', () => { describe('constructor', () => { it('stores values', () => { - const cr = new CorrectResponse(['A', 'B']); + const cr = new CorrectResponse(['A', 'B'], makeDeclaration()); expect(cr.get()).toEqual(['A', 'B']); }); it('stores an empty array', () => { - const cr = new CorrectResponse([]); + const cr = new CorrectResponse([], makeDeclaration()); expect(cr.get()).toEqual([]); }); }); @@ -110,18 +110,24 @@ describe('CorrectResponse', () => { describe('getXML', () => { it('produces a qti-correct-response element', () => { - expect(new CorrectResponse(['ChoiceA']).getXML().tagName).toBe('qti-correct-response'); + 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']).getXML().querySelectorAll('qti-value'), + ...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([]).getXML().querySelectorAll('qti-value').length).toBe(0); + expect( + new CorrectResponse([], makeDeclaration()).getXML().querySelectorAll('qti-value').length, + ).toBe(0); }); it('round-trips: qti-value child carries correct text', () => { @@ -158,29 +164,33 @@ describe('CorrectResponse', () => { describe('full XML output (QTI compatibility)', () => { it('serializes to well-formed XML that re-parses without error', () => { - const cr = new CorrectResponse(['ChoiceA', 'ChoiceC']); + 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']).getXML()); + 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']).getXML()); + 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']).getXML()); + 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(['选择甲', 'اختيار_أ']); + const cr = new CorrectResponse(['选择甲', 'اختيار_أ'], makeDeclaration()); const reparsed = reparse(cr.getXML()); const values = [...reparsed.querySelectorAll('qti-value')].map(n => n.textContent); expect(values).toEqual(['选择甲', 'اختيار_أ']); @@ -189,7 +199,7 @@ describe('CorrectResponse', () => { // 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']); + 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 index 14c841cfc2..769cca3ab7 100644 --- 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 @@ -25,7 +25,7 @@ function parseDefaultValue(xmlString) { describe('DefaultValue', () => { describe('constructor', () => { it('stores values', () => { - const dv = new DefaultValue(['0']); + const dv = new DefaultValue(['0'], makeDeclaration()); expect(dv.get()).toEqual(['0']); }); }); @@ -40,7 +40,7 @@ describe('DefaultValue', () => {
`); const dv = DefaultValue.fromXML(xmlNode, makeDeclaration()); - expect(dv.get()).toEqual(['0.5']); + expect(dv.get()).toEqual([0.5]); }); it('registers itself as DEFAULT_VALUE capability', () => { @@ -54,18 +54,20 @@ describe('DefaultValue', () => { const declaration = makeDeclaration(); DefaultValue.fromXML(xmlNode, declaration); expect(declaration._capabilities[CAPABILITY.DEFAULT_VALUE]).toBeDefined(); - expect(declaration.defaultValue).toEqual(['1']); + expect(declaration.defaultValue).toEqual([1]); }); }); describe('getXML', () => { it('produces a qti-default-value element', () => { - expect(new DefaultValue(['0']).getXML().tagName).toBe('qti-default-value'); + 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']).getXML().querySelectorAll('qti-value'), + ...new DefaultValue(['true', 'false'], makeDeclaration()) + .getXML() + .querySelectorAll('qti-value'), ].map(n => n.textContent); expect(values).toEqual(['true', 'false']); }); @@ -87,22 +89,22 @@ describe('DefaultValue', () => { describe('full XML output (QTI compatibility)', () => { it('serializes to well-formed XML that re-parses without error', () => { - expect(() => reparse(new DefaultValue(['0.5']).getXML())).not.toThrow(); + 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']).getXML()); + 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']).getXML()); + 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(['默认值']).getXML()); + 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/mapping.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js index dd39b2b368..f700244c59 100644 --- 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 @@ -31,7 +31,7 @@ describe('Mapping', () => { describe('constructor', () => { it('stores the provided data', () => { const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; - const m = new Mapping(data); + const m = new Mapping(data, makeDeclaration()); expect(m.get()).toEqual(data); }); }); @@ -120,24 +120,32 @@ describe('Mapping', () => { describe('getXML', () => { it('produces a qti-mapping element', () => { const data = { defaultValue: 0, lowerBound: null, upperBound: null, entries: [] }; - expect(new Mapping(data).getXML().tagName).toBe('qti-mapping'); + 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).getXML().getAttribute('default-value')).toBe('-1'); + 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).getXML().getAttribute('lower-bound')).toBe('-2'); - expect(new Mapping(withoutBound).getXML().hasAttribute('lower-bound')).toBe(false); + 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).getXML().getAttribute('upper-bound')).toBe('5'); + expect(new Mapping(withBound, makeDeclaration()).getXML().getAttribute('upper-bound')).toBe( + '5', + ); }); it('produces qti-map-entry children', () => { @@ -150,7 +158,7 @@ describe('Mapping', () => { { mapKey: 'ChoiceB', mappedValue: -0.5, caseSensitive: true }, ], }; - const node = new Mapping(data).getXML(); + 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'); @@ -167,7 +175,9 @@ describe('Mapping', () => { { mapKey: 'b', mappedValue: 1, caseSensitive: true }, ], }; - const entries = [...new Mapping(data).getXML().querySelectorAll('qti-map-entry')]; + 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); }); @@ -230,7 +240,7 @@ describe('Mapping', () => { upperBound: null, entries: [{ mapKey: '选择甲', mappedValue: 1, caseSensitive: true }], }; - const reparsed = reparse(new Mapping(data).getXML()); + 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/declarations/areaMapping.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js index 9d166c1d9a..2aacf4522a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js @@ -20,9 +20,11 @@ export default class AreaMapping { * upperBound: number|null, * entries: Array<{ shape: string, coords: string, mappedValue: number }> * }} data + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration */ - constructor(data) { + constructor(data, declaration) { this._data = data; + declaration.registerCapability(CAPABILITY.AREA_MAPPING, this); } /** @@ -41,23 +43,7 @@ export default class AreaMapping { mappedValue: parseFloat(entry.getAttribute('mapped-value')), })); - const instance = new AreaMapping({ ...bounds, entries }); - declaration.registerCapability(CAPABILITY.AREA_MAPPING, instance); - return instance; - } - - /** - * Build from plain JS data and register on the parent declaration. - * - * @param {{ defaultValue: number, lowerBound: number|null, - * upperBound: number|null, entries: Array }} data - * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration - * @returns {AreaMapping} - */ - static fromPlain(data, declaration) { - const instance = new AreaMapping(data); - declaration.registerCapability(CAPABILITY.AREA_MAPPING, instance); - return instance; + return new AreaMapping({ ...bounds, entries }, declaration); } /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js index 63ee237a34..9dfa372b9c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js @@ -1,9 +1,9 @@ /** * CorrectResponse declaration strategy. * - * Parses a element into an array of raw trimmed strings - * and re-serializes it on demand. Values are kept as strings because the - * authoring editor has no runtime value state and performs no type coercion. + * 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. * * @module declarations/correctResponse */ @@ -11,10 +11,15 @@ import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; export default class CorrectResponse { - /** @param {string[]} values - Correct response values as raw strings */ - constructor(values) { - /** @type {string[]} */ + /** + * @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); } /** @@ -27,28 +32,12 @@ export default class CorrectResponse { * @returns {CorrectResponse} */ static fromXML(xmlNode, declaration) { - const values = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); - const instance = new CorrectResponse(values); - declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, instance); - return instance; - } - - /** - * Build from plain JS data and register on the parent declaration. - * Used by QTIDeclaration.convertTo() to carry forward values without XML serialization. - * - * @param {string[]} values - * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration - * @returns {CorrectResponse} - */ - static fromPlain(values, declaration) { - const instance = new CorrectResponse(values); - declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, instance); - return instance; + const rawStrings = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); + return new CorrectResponse(declaration.coerceValues(rawStrings), declaration); } /** - * @returns {string[]} + * @returns {Array} */ get() { return this._values; @@ -60,7 +49,9 @@ export default class CorrectResponse { getXML() { return buildXmlNode({ tag: 'qti-correct-response', - children: this._values.map(v => buildXmlNode({ tag: 'qti-value', children: [v] })), + 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 index c6f6e66c44..789199722d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js @@ -1,9 +1,9 @@ /** * DefaultValue declaration strategy. * - * Parses a element into an array of raw trimmed strings - * and re-serializes it on demand. Included for declaration round-trip fidelity; - * the authoring editor does not evaluate or coerce default values at runtime. + * 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. * * @module declarations/defaultValue */ @@ -11,10 +11,15 @@ import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; export default class DefaultValue { - /** @param {string[]} values - Default values as raw strings */ - constructor(values) { - /** @type {string[]} */ + /** + * @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); } /** @@ -25,27 +30,12 @@ export default class DefaultValue { * @returns {DefaultValue} */ static fromXML(xmlNode, declaration) { - const values = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); - const instance = new DefaultValue(values); - declaration.registerCapability(CAPABILITY.DEFAULT_VALUE, instance); - return instance; - } - - /** - * Build from plain JS data and register on the parent declaration. - * - * @param {string[]} values - * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration - * @returns {DefaultValue} - */ - static fromPlain(values, declaration) { - const instance = new DefaultValue(values); - declaration.registerCapability(CAPABILITY.DEFAULT_VALUE, instance); - return instance; + const rawStrings = [...xmlNode.querySelectorAll('qti-value')].map(v => v.textContent.trim()); + return new DefaultValue(declaration.coerceValues(rawStrings), declaration); } /** - * @returns {string[]} + * @returns {Array} */ get() { return this._values; @@ -57,7 +47,9 @@ export default class DefaultValue { getXML() { return buildXmlNode({ tag: 'qti-default-value', - children: this._values.map(v => buildXmlNode({ tag: 'qti-value', children: [v] })), + 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/mapping.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js index 4e682147f4..a961b6038f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js @@ -10,6 +10,7 @@ * * @module declarations/mapping */ +import { QTIDeclaration } from '../QTIDeclaration.js'; import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; @@ -42,11 +43,14 @@ export default class Mapping { * defaultValue: number, * lowerBound: number|null, * upperBound: number|null, - * entries: Array<{ mapKey: string, mappedValue: number, caseSensitive: boolean }> + * entries: Array<{ mapKey: string|number|boolean, mappedValue: number, + * caseSensitive: boolean }> * }} data + * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration */ - constructor(data) { + constructor(data, declaration) { this._data = data; + declaration.registerCapability(CAPABILITY.MAPPING, this); } /** @@ -60,30 +64,13 @@ export default class Mapping { const bounds = parseScoringAttrs(xmlNode); const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => ({ - mapKey: entry.getAttribute('map-key'), + mapKey: QTIDeclaration.coerceValue(entry.getAttribute('map-key'), declaration.baseType), 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', })); - const instance = new Mapping({ ...bounds, entries }); - declaration.registerCapability(CAPABILITY.MAPPING, instance); - return instance; - } - - /** - * Build from plain JS data and register on the parent declaration. - * Used by QTIDeclaration.convertTo() when base-type is unchanged across a type conversion. - * - * @param {{ defaultValue: number, lowerBound: number|null, - * upperBound: number|null, entries: Array }} data - * @param {import('../QTIDeclaration.js').QTIDeclaration} declaration - * @returns {Mapping} - */ - static fromPlain(data, declaration) { - const instance = new Mapping(data); - declaration.registerCapability(CAPABILITY.MAPPING, instance); - return instance; + return new Mapping({ ...bounds, entries }, declaration); } /** @@ -106,7 +93,7 @@ export default class Mapping { const children = entries.map(entry => { const entryAttrs = { - 'map-key': entry.mapKey, + 'map-key': QTIDeclaration.formatValue(entry.mapKey), 'mapped-value': entry.mappedValue, }; // Omit case-sensitive when true — it is the spec default. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js deleted file mode 100644 index 13ff1b5c75..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/interactionSchema.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Interaction schema registry. - * - * Maps each QuestionType (UI concept) to the QTI declaration shape it requires: - * the interaction tag, base-type, cardinality, and which capabilities are valid. - * - * This is the single source of truth for QTIDeclaration.forType() and - * QTIDeclaration.convertTo(). Adding support for a new interaction type requires - * only adding an entry here — the parsing and serialization layer does not change. - * - * @module serialization/qti/interactionSchema - */ -import { QuestionType, QtiInteraction, Cardinality, BaseType } from '../../constants.js'; -import { CAPABILITY } from './declarations/index.js'; - -/** - * @typedef {object} DeclarationSchema - * @property {string} interaction - QtiInteraction tag name - * @property {string} baseType - Required QTI base-type value - * @property {string} cardinality - Required QTI cardinality value - * @property {string[]} capabilities - Valid CAPABILITY keys for this type - */ - -/** - * Schema registry: QuestionType → declaration shape. - * Expand this object to support additional interaction types. - * - * @type {Object.} - */ -export const INTERACTION_SCHEMA = Object.freeze({ - [QuestionType.SINGLE_SELECT]: { - interaction: QtiInteraction.CHOICE, - baseType: BaseType.IDENTIFIER, - cardinality: Cardinality.SINGLE, - capabilities: [CAPABILITY.CORRECT_RESPONSE, CAPABILITY.DEFAULT_VALUE, CAPABILITY.MAPPING], - }, - - [QuestionType.MULTI_SELECT]: { - interaction: QtiInteraction.CHOICE, - baseType: BaseType.IDENTIFIER, - cardinality: Cardinality.MULTIPLE, - capabilities: [CAPABILITY.CORRECT_RESPONSE, CAPABILITY.DEFAULT_VALUE, CAPABILITY.MAPPING], - }, -}); - -/** - * Look up the schema for a given QuestionType. - * - * @param {string} questionType - One of QuestionType.* - * @returns {DeclarationSchema|undefined} - */ -export function getSchemaForType(questionType) { - return INTERACTION_SCHEMA[questionType]; -} - -/** - * Return true when two question types share the same base-type, meaning - * correctResponse values can be reused on a type conversion. - * - * @param {string} fromType - Source QuestionType - * @param {string} toType - Target QuestionType - * @returns {boolean} - */ -export function isBaseTypeCompatible(fromType, toType) { - const from = INTERACTION_SCHEMA[fromType]; - const to = INTERACTION_SCHEMA[toType]; - if (!from || !to) return false; - return from.baseType === to.baseType; -} From 7ce1ed98b2a8583ea9a32d84ba28a22d915d8bcd Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Tue, 23 Jun 2026 00:45:03 +0530 Subject: [PATCH 4/5] feat: add getDeclarationSchema to interaction definition mock and interface test requirements Signed-off-by: Abhishek-Punhani --- .../QTIEditor/interactions/__tests__/defineInteraction.spec.js | 2 ++ 1 file changed, 2 insertions(+) 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', ]; From 6d5b512d274c4934da891d188fbe77fff95c457a Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 24 Jun 2026 00:27:11 +0530 Subject: [PATCH 5/5] refactor: move value coercion logic into QTIDeclaration and improve test utilities Signed-off-by: Abhishek-Punhani --- ...ldXmlNode.spec.js => assembleItem.spec.js} | 4 +- .../serialization/qti/QTIDeclaration.js | 145 ++++++++++++--- .../qti/__tests__/QTIDeclaration.spec.js | 175 ++++++++++++++---- .../declarations/areaMapping.spec.js | 14 +- .../declarations/correctResponse.spec.js | 20 +- .../declarations/defaultValue.spec.js | 16 +- .../__tests__/declarations/mapping.spec.js | 14 +- .../serialization/qti/__tests__/testUtils.js | 25 ++- .../qti/declarations/areaMapping.js | 3 +- .../qti/declarations/capabilities.js | 2 - .../qti/declarations/correctResponse.js | 2 - .../qti/declarations/defaultValue.js | 2 - .../serialization/qti/declarations/index.js | 2 - .../serialization/qti/declarations/mapping.js | 8 +- 14 files changed, 310 insertions(+), 122 deletions(-) rename contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/{buildXmlNode.spec.js => assembleItem.spec.js} (95%) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js similarity index 95% rename from contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js rename to contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js index 1c3c80341e..24357fe5c7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/buildXmlNode.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js @@ -1,9 +1,11 @@ +// 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('buildXmlNode', () => { +describe('assembleItem', () => { describe('tag and attributes', () => { it('creates an element with the given tag name', () => { const node = buildXmlNode({ tag: 'qti-response-declaration' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index ccad7e8397..3b028d22a5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -12,6 +12,13 @@ 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 @@ -23,7 +30,7 @@ export class QTIDeclaration { constructor({ identifier, baseType = null, - cardinality = 'single', + cardinality = Cardinality.SINGLE, tag = 'qti-response-declaration', }) { const IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_.-]*$/u; @@ -34,7 +41,7 @@ export class QTIDeclaration { ); } - if (cardinality === 'record') { + if (cardinality === Cardinality.RECORD) { throw new Error('cardinality="record" is not yet supported'); } @@ -77,7 +84,7 @@ export class QTIDeclaration { /** * Register a named capability on this declaration. - * Called as a side-effect by declaration strategy classes during fromXML/fromPlain. + * 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 @@ -115,29 +122,101 @@ export class QTIDeclaration { // --------------------------------------------------------------------------- /** - * Coerce a raw XML string value to its native JS type based on a QTI base-type. + * Whether this declaration's base-type uses a 2-element array as its + * native JS representation (point, pair, directedPair). * - * QTI stores all values as text nodes in XML. This method converts the raw - * string to the appropriate JS primitive so the editor works with native types. + * Useful for callers that need to distinguish compound values from containers. * - * @param {string} raw - Raw text from a element - * @param {string} baseType - One of BaseType.* (or null) - * @returns {string|number|boolean} + * @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); - return Number.isNaN(n) ? raw : n; + if (Number.isNaN(n)) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to integer`); + } + return n; } + case BaseType.FLOAT: { const n = parseFloat(raw); - return Number.isNaN(n) ? raw : n; + if (Number.isNaN(n)) { + throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to float`); + } + return n; } - case BaseType.BOOLEAN: - return raw === 'true'; + + 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: - // identifier, string, point, pair, directedPair, duration, file, uri return raw; } } @@ -146,33 +225,57 @@ export class QTIDeclaration { * 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} value + * @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'; - return String(value ?? ''); + 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} + * @returns {Array} */ coerceValues(rawStrings) { - return rawStrings.map(v => QTIDeclaration.coerceValue(v, this.baseType)); + return rawStrings.map(v => this.coerceValue(v)); } /** * Format an array of native JS values to QTI XML strings. * - * @param {Array} values + * @param {Array} values * @returns {string[]} */ formatValues(values) { - return values.map(QTIDeclaration.formatValue); + return values.map(v => this.formatValue(v)); } // --------------------------------------------------------------------------- @@ -192,7 +295,7 @@ export class QTIDeclaration { static fromXML(xmlNode) { const identifier = xmlNode.getAttribute('identifier') ?? ''; const baseType = xmlNode.getAttribute('base-type') ?? null; - const cardinality = xmlNode.getAttribute('cardinality') ?? 'single'; + const cardinality = xmlNode.getAttribute('cardinality') ?? Cardinality.SINGLE; const tag = xmlNode.tagName.toLowerCase(); const declaration = new QTIDeclaration({ identifier, baseType, cardinality, tag }); 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 index e5f00bac82..81d257bf4d 100644 --- 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 @@ -1,7 +1,9 @@ +// 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 } from './testUtils.js'; +import { parseXML, reparse, serializeXML } from './testUtils.js'; import { DECLARATION_WITH_MAPPING, MULTI_SELECT_DECLARATION, @@ -10,20 +12,6 @@ import { SINGLE_SELECT_DECLARATION, } from './fixtures/declarations.js'; -const serializer = new XMLSerializer(); -const parser = new DOMParser(); - -function reparse(node) { - const xml = serializer.serializeToString(node); - const doc = parser.parseFromString(xml, 'text/xml'); - if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); - return doc.documentElement; -} - -// --------------------------------------------------------------------------- -// Constructor tests -// --------------------------------------------------------------------------- - describe('QTIDeclaration constructor', () => { it('stores scalar fields', () => { const d = new QTIDeclaration({ @@ -62,10 +50,6 @@ describe('QTIDeclaration constructor', () => { }); }); -// --------------------------------------------------------------------------- -// registerCapability tests -// --------------------------------------------------------------------------- - describe('QTIDeclaration.registerCapability', () => { it('stores and exposes a capability via its getter', () => { const d = new QTIDeclaration({ identifier: 'X' }); @@ -75,10 +59,6 @@ describe('QTIDeclaration.registerCapability', () => { }); }); -// --------------------------------------------------------------------------- -// fromXML tests -// --------------------------------------------------------------------------- - describe('QTIDeclaration.fromXML', () => { it('reads identifier from XML', () => { const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); @@ -140,10 +120,6 @@ describe('QTIDeclaration.fromXML', () => { }); }); -// --------------------------------------------------------------------------- -// getXML / round-trip tests -// --------------------------------------------------------------------------- - describe('QTIDeclaration.getXML', () => { it('produces an element with the correct tag', () => { const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); @@ -172,14 +148,14 @@ describe('QTIDeclaration.getXML', () => { it('includes qti-correct-response child on round-trip', () => { const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); - const xml = serializer.serializeToString(d.getXML()); + 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 = serializer.serializeToString(d.getXML()); + const xml = serializeXML(d.getXML()); expect(xml).toContain('qti-mapping'); expect(xml).toContain('map-key="ChoiceA"'); }); @@ -194,10 +170,6 @@ describe('QTIDeclaration.getXML', () => { }); }); -// --------------------------------------------------------------------------- -// Full XML output — QTI 3.0 compatibility -// --------------------------------------------------------------------------- - 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)); @@ -266,3 +238,140 @@ describe('QTIDeclaration full XML output (QTI compatibility)', () => { 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__/declarations/areaMapping.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/areaMapping.spec.js index 94d165c196..4ed275d5c9 100644 --- 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 @@ -1,20 +1,12 @@ +// 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 } from '../testUtils.js'; +import { parseXML, reparse } from '../testUtils.js'; import { AREA_MAPPING_WITH_BOUNDS_XML, AREA_MAPPING_XML } from './fixtures.js'; -const serializer = new XMLSerializer(); -const parser = new DOMParser(); - -function reparse(node) { - const xml = serializer.serializeToString(node); - const doc = parser.parseFromString(xml, 'text/xml'); - if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); - return doc.documentElement; -} - function makeDeclaration() { return new QTIDeclaration({ identifier: 'RESPONSE', baseType: 'point', cardinality: 'single' }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js index 54e5bf1580..7aa7c250fc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/correctResponse.spec.js @@ -1,11 +1,10 @@ +// 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 } from '../testUtils.js'; - -const serializer = new XMLSerializer(); -const parser = new DOMParser(); +import { parseXML, reparse } from '../testUtils.js'; function makeDeclaration() { return new QTIDeclaration({ @@ -19,19 +18,6 @@ function parseCorrectResponse(xmlString) { return parseXML(xmlString).querySelector('qti-correct-response'); } -/** - * Serialize a node to an XML string, then re-parse it. - * Throws if the output is not well-formed XML, proving it is safe for a QTI player to consume. - */ -function reparse(node) { - const xml = serializer.serializeToString(node); - const doc = parser.parseFromString(xml, 'text/xml'); - if (doc.querySelector('parsererror')) { - throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); - } - return doc.documentElement; -} - describe('CorrectResponse', () => { describe('constructor', () => { it('stores values', () => { 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 index 769cca3ab7..5ab4603985 100644 --- 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 @@ -1,18 +1,10 @@ +// 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 } from '../testUtils.js'; - -const parser = new DOMParser(); -const serializer = new XMLSerializer(); - -function reparse(node) { - const xml = serializer.serializeToString(node); - const doc = parser.parseFromString(xml, 'text/xml'); - if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); - return doc.documentElement; -} +import { parseXML, reparse } from '../testUtils.js'; function makeDeclaration() { return new QTIDeclaration({ identifier: 'SCORE', baseType: 'float', cardinality: 'single' }); @@ -31,7 +23,7 @@ describe('DefaultValue', () => { }); describe('fromXML', () => { - it('parses qti-value children as trimmed strings', () => { + it('parses and coerces qti-value children according to the declaration base type', () => { const xmlNode = parseDefaultValue(` 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 index f700244c59..1ead5d6831 100644 --- 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 @@ -1,20 +1,12 @@ +// 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 } from '../testUtils.js'; +import { parseXML, reparse } from '../testUtils.js'; import { MAPPING_WITH_BOUNDS_XML, MAPPING_WITH_CI_XML, SIMPLE_MAPPING_XML } from './fixtures.js'; -const serializer = new XMLSerializer(); -const parser = new DOMParser(); - -function reparse(node) { - const xml = serializer.serializeToString(node); - const doc = parser.parseFromString(xml, 'text/xml'); - if (doc.querySelector('parsererror')) throw new Error(`Re-parsed XML has a parsererror:\n${xml}`); - return doc.documentElement; -} - function makeDeclaration() { return new QTIDeclaration({ identifier: 'RESPONSE', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js index a815907621..30f029c400 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/testUtils.js @@ -1,8 +1,8 @@ /** * Shared XML parse helper for declaration tests. - * Mirrors the approach in Kolibri's qtiXmlHelpers.js test utility. */ const parser = new DOMParser(); +const serializer = new XMLSerializer(); /** * Parse an XML string and return the root element. @@ -15,3 +15,26 @@ export function parseXML(xmlString) { 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 index 2aacf4522a..1d484d5e31 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/areaMapping.js @@ -5,8 +5,6 @@ * 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. - * - * @module declarations/areaMapping */ import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; @@ -24,6 +22,7 @@ export default class AreaMapping { */ constructor(data, declaration) { this._data = data; + this._declaration = declaration; declaration.registerCapability(CAPABILITY.AREA_MAPPING, this); } 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 index c357fd85a3..a47d854c07 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/capabilities.js @@ -7,8 +7,6 @@ * * SCORE and LOOKUP from the Kolibri original are intentionally omitted — * the authoring editor carries no runtime scoring or lookup-table logic. - * - * @module declarations/capabilities */ /** @enum {string} */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js index 9dfa372b9c..e492ea8c9b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/correctResponse.js @@ -4,8 +4,6 @@ * 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. - * - * @module declarations/correctResponse */ import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js index 789199722d..00432f8e0b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/defaultValue.js @@ -4,8 +4,6 @@ * 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. - * - * @module declarations/defaultValue */ import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; 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 index cef5262bd4..9498297347 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/index.js @@ -8,8 +8,6 @@ * * qti-interpolation-table, qti-match-table, and ruleHandlers are intentionally * omitted — the authoring editor has no lookup-table or response-processing support. - * - * @module declarations/index */ import CorrectResponse from './correctResponse.js'; import DefaultValue from './defaultValue.js'; 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 index a961b6038f..3cf542a85c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js @@ -7,10 +7,7 @@ * 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. - * - * @module declarations/mapping */ -import { QTIDeclaration } from '../QTIDeclaration.js'; import { buildXmlNode } from '../../assembleItem.js'; import { CAPABILITY } from './capabilities.js'; @@ -50,6 +47,7 @@ export default class Mapping { */ constructor(data, declaration) { this._data = data; + this._declaration = declaration; declaration.registerCapability(CAPABILITY.MAPPING, this); } @@ -64,7 +62,7 @@ export default class Mapping { const bounds = parseScoringAttrs(xmlNode); const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => ({ - mapKey: QTIDeclaration.coerceValue(entry.getAttribute('map-key'), declaration.baseType), + 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', @@ -93,7 +91,7 @@ export default class Mapping { const children = entries.map(entry => { const entryAttrs = { - 'map-key': QTIDeclaration.formatValue(entry.mapKey), + 'map-key': this._declaration.formatValue(entry.mapKey), 'mapped-value': entry.mappedValue, }; // Omit case-sensitive when true — it is the spec default.