diff --git a/README.md b/README.md index cba8196..c802905 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This project provides two major capabilities: 1. **QTI 2.x & 3.0 Players** — Production-ready item and assessment players with unified version-agnostic architecture 2. **PIE ↔ QTI Transformation Framework** — Bidirectional transforms between QTI and PIE, with CLI, web app, and IMS Content Package support -📚 **[Live Examples](https://pie-framework.github.io/pie-qti/examples/)** +📚 **[Live Examples](https://qti.pie-framework.org/examples/)** ![QTI Player Examples](docs/images/examples-screenshot-1.png) diff --git a/packages/item-player/src/__tests__/qti3-attribute-extraction.test.ts b/packages/item-player/src/__tests__/qti3-attribute-extraction.test.ts new file mode 100644 index 0000000..3be11ab --- /dev/null +++ b/packages/item-player/src/__tests__/qti3-attribute-extraction.test.ts @@ -0,0 +1,174 @@ +/** + * QTI 3.0 Attribute Extraction Tests + * + * Verifies that the attribute mapper integration works correctly for QTI 3.0 + * kebab-case attributes at the extraction utilities layer. + */ + +import { describe, it, expect } from 'bun:test'; +import { parse } from 'node-html-parser'; +import { createQtiParser } from '@pie-qti/qti-common'; +import { createExtractionUtils } from '../extraction/utils.js'; +import type { QTIElement } from '../types/interactions.js'; + +describe('QTI 3.0 Attribute Extraction', () => { + it('should extract max-choices attribute (kebab-case) from QTI 3.0 element', () => { + const xml = ` + + Select one + Choice A +`; + + const { attributeMapper } = createQtiParser(xml); + const dom = parse(xml); + const element = dom.querySelector('qti-choice-interaction') as unknown as QTIElement; + + const utils = createExtractionUtils(undefined, undefined, attributeMapper); + + // Extract QTI 3.0 kebab-case attributes using camelCase names + const maxChoices = utils.getNumberAttribute(element, 'maxChoices', 0); + const shuffle = utils.getBooleanAttribute(element, 'shuffle', true); + const responseId = utils.getAttribute(element, 'responseIdentifier', ''); + + expect(maxChoices).toBe(1); + expect(shuffle).toBe(false); + expect(responseId).toBe('RESPONSE'); + }); + + it('should extract expected-length attribute from QTI 3.0 text entry', () => { + const xml = ` +`; + + const { attributeMapper } = createQtiParser(xml); + const dom = parse(xml); + const element = dom.querySelector('qti-text-entry-interaction') as unknown as QTIElement; + + const utils = createExtractionUtils(undefined, undefined, attributeMapper); + + const expectedLength = utils.getNumberAttribute(element, 'expectedLength', 0); + const responseId = utils.getAttribute(element, 'responseIdentifier', ''); + + expect(expectedLength).toBe(15); + expect(responseId).toBe('RESPONSE'); + }); + + it('should extract expected-lines and placeholder-text from QTI 3.0 extended text', () => { + const xml = ` +`; + + const { attributeMapper } = createQtiParser(xml); + const dom = parse(xml); + const element = dom.querySelector('qti-extended-text-interaction') as unknown as QTIElement; + + const utils = createExtractionUtils(undefined, undefined, attributeMapper); + + const expectedLines = utils.getNumberAttribute(element, 'expectedLines', 0); + const expectedLength = utils.getNumberAttribute(element, 'expectedLength', 0); + const placeholderText = utils.getAttribute(element, 'placeholderText', ''); + + expect(expectedLines).toBe(5); + expect(expectedLength).toBe(500); + expect(placeholderText).toBe('Type here...'); + }); + + it('should extract max-associations from QTI 3.0 match interaction', () => { + const xml = ` + + + Item A + +`; + + const { attributeMapper } = createQtiParser(xml); + const dom = parse(xml); + const interaction = dom.querySelector('qti-match-interaction') as unknown as QTIElement; + const choice = dom.querySelector('qti-simple-associable-choice') as unknown as QTIElement; + + const utils = createExtractionUtils(undefined, undefined, attributeMapper); + + const maxAssociations = utils.getNumberAttribute(interaction, 'maxAssociations', 0); + const matchMax = utils.getNumberAttribute(choice, 'matchMax', 0); + + expect(maxAssociations).toBe(3); + expect(matchMax).toBe(1); + }); + + it('should handle QTI 2.x camelCase attributes with default mapper', () => { + const xml = ` + + Choice A +`; + + // No mapper specified - defaults to QTI 2.x + const dom = parse(xml); + const element = dom.querySelector('choiceinteraction') as unknown as QTIElement; + + const utils = createExtractionUtils(); // Defaults to Qti2xAttributeNameMapper + + const maxChoices = utils.getNumberAttribute(element, 'maxChoices', 0); + const shuffle = utils.getBooleanAttribute(element, 'shuffle', false); + const responseId = utils.getAttribute(element, 'responseIdentifier', ''); + + expect(maxChoices).toBe(2); + expect(shuffle).toBe(true); + expect(responseId).toBe('RESPONSE'); + }); + + it('should handle boolean attributes correctly for both versions', () => { + // QTI 3.0 + const qti3xml = ``; + const { attributeMapper: qti3Mapper } = createQtiParser(qti3xml); + const qti3Dom = parse(qti3xml); + const qti3Element = qti3Dom.querySelector('qti-choice-interaction') as unknown as QTIElement; + const qti3Utils = createExtractionUtils(undefined, undefined, qti3Mapper); + + expect(qti3Utils.getBooleanAttribute(qti3Element, 'shuffle', false)).toBe(true); + + // QTI 2.x + const qti2xml = ``; + const qti2Dom = parse(qti2xml); + const qti2Element = qti2Dom.querySelector('choiceinteraction') as unknown as QTIElement; + const qti2Utils = createExtractionUtils(); // defaults + + expect(qti2Utils.getBooleanAttribute(qti2Element, 'shuffle', false)).toBe(true); + }); + + it('should return default values when attributes are missing', () => { + const xml = ``; + const { attributeMapper } = createQtiParser(xml); + const dom = parse(xml); + const element = dom.querySelector('qti-choice-interaction') as unknown as QTIElement; + + const utils = createExtractionUtils(undefined, undefined, attributeMapper); + + // These attributes don't exist, should return defaults + const maxChoices = utils.getNumberAttribute(element, 'maxChoices', 99); + const shuffle = utils.getBooleanAttribute(element, 'shuffle', true); + + expect(maxChoices).toBe(99); + expect(shuffle).toBe(true); + }); + + it('should handle lower-bound and upper-bound for slider', () => { + const xml = ` +`; + + const { attributeMapper } = createQtiParser(xml); + const dom = parse(xml); + const element = dom.querySelector('qti-slider-interaction') as unknown as QTIElement; + + const utils = createExtractionUtils(undefined, undefined, attributeMapper); + + const lowerBound = utils.getNumberAttribute(element, 'lowerBound', 0); + const upperBound = utils.getNumberAttribute(element, 'upperBound', 0); + const step = utils.getNumberAttribute(element, 'step', 0); + + expect(lowerBound).toBe(0); + expect(upperBound).toBe(100); + expect(step).toBe(5); + }); +}); diff --git a/packages/item-player/src/__tests__/qti3-debug.test.ts b/packages/item-player/src/__tests__/qti3-debug.test.ts new file mode 100644 index 0000000..a63e95d --- /dev/null +++ b/packages/item-player/src/__tests__/qti3-debug.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'bun:test'; +import { detectQtiVersion } from '@pie-qti/qti-common'; +import { Player } from '../core/Player.js'; +import { parse } from 'node-html-parser'; + +const QTI3_SIMPLE = ` + + + + + A + + +`; + +const QTI3_TEXT_ENTRY = ` + + + +

What is the capital?

+
+
`; + +describe('QTI 3.0 Debug', () => { + it('should detect QTI 3.0 version', () => { + const version = detectQtiVersion(QTI3_SIMPLE); + console.log('Detected version:', version); + expect(version).toBe('3.0'); + }); + + it('should create player with QTI 3.0', () => { + const player = new Player({ itemXml: QTI3_SIMPLE }); + console.log('Player created successfully'); + expect(player).toBeDefined(); + }); + + it('should parse HTML and find elements', () => { + const docRoot = parse(QTI3_SIMPLE, { lowerCaseTagName: false, comment: false }); + console.log('Parsed HTML structure:'); + console.log('Root tag:', docRoot.rawTagName); + + const itemBody = docRoot.querySelector('qti-item-body'); + console.log('Found qti-item-body:', !!itemBody); + if (itemBody) { + console.log('itemBody children:', itemBody.childNodes.length); + } + + const choiceInteraction = docRoot.querySelector('qti-choice-interaction'); + console.log('Found qti-choice-interaction:', !!choiceInteraction); + if (choiceInteraction) { + console.log('choiceInteraction tag:', (choiceInteraction as any).rawTagName); + console.log('response-identifier attr:', choiceInteraction.getAttribute('response-identifier')); + } + + expect(choiceInteraction).toBeDefined(); + }); + + it('should extract choice interaction', () => { + const player = new Player({ itemXml: QTI3_SIMPLE }); + const interactions = player.getInteractionData(); + console.log('Choice interactions found:', interactions.length); + expect(interactions.length).toBeGreaterThan(0); + }); + + it('should parse text entry in HTML', () => { + const docRoot = parse(QTI3_TEXT_ENTRY, { lowerCaseTagName: false, comment: false }); + const textEntry = docRoot.querySelector('qti-text-entry-interaction'); + console.log('Found qti-text-entry-interaction:', !!textEntry); + if (textEntry) { + console.log('textEntry tag:', (textEntry as any).rawTagName); + console.log('response-identifier attr:', textEntry.getAttribute('response-identifier')); + } else { + // Try finding all elements + const allElements = docRoot.querySelectorAll('*'); + console.log('Total elements found:', allElements.length); + console.log('Element tags:', allElements.map((el: any) => el.rawTagName).slice(0, 10)); + } + expect(textEntry).toBeDefined(); + }); + + it('should extract text entry interaction', () => { + const player = new Player({ itemXml: QTI3_TEXT_ENTRY }); + const interactions = player.getInteractionData(); + console.log('Text entry interactions found:', interactions.length); + console.log('Interactions:', JSON.stringify(interactions, null, 2)); + expect(interactions.length).toBeGreaterThan(0); + }); + + it('should process responses for QTI 3.0', () => { + const qti3WithProcessing = ` + + + + A + + + + + 0 + + + + + Correct + Wrong + + + + + + + + + + + 1.0 + + + + +`; + + const player = new Player({ itemXml: qti3WithProcessing }); + player.setResponses({ RESPONSE: 'A' }); + const result = player.processResponses(); + console.log('Response processing result:', result); + console.log('Score:', result.score); + console.log('Outcome values:', result.outcomeValues); + expect(result.score).toBe(1.0); + }); +}); diff --git a/packages/item-player/src/__tests__/qti3-extraction.test.ts b/packages/item-player/src/__tests__/qti3-extraction.test.ts new file mode 100644 index 0000000..335f6f5 --- /dev/null +++ b/packages/item-player/src/__tests__/qti3-extraction.test.ts @@ -0,0 +1,309 @@ +/** + * QTI 3.0 Extraction Tests + * + * Verifies that QTI 3.0 content (kebab-case attributes/elements) extracts correctly + * and produces identical data structures to QTI 2.x equivalents. + */ + +import { describe, it, expect } from 'bun:test'; +import { Player } from '../core/Player.js'; + +// QTI 3.0 sample items +const QTI3_SIMPLE_CHOICE = ` + + + + + ChoiceA + + + + + + 0.0 + + + + +

If Maya has 12 cookies and gives 5 to her friend, how many cookies does she have left?

+ + Select the correct answer: + 7 + 17 + 5 + 8 + +
+ + + + + + + + + + 1.0 + + + + +
`; + +const QTI3_TEXT_ENTRY = ` + + + + + Paris + + + + +

What is the capital of France?

+
+ + + + + + + + + + 1.0 + + + + +
`; + +const QTI3_EXTENDED_TEXT = ` + + + + + +

Describe the water cycle in your own words.

+ +
+
`; + +const QTI3_MATCH = ` + + + + + france paris + germany berlin + + + + + + + + + + 0 + + + + +

Match each country with its capital city:

+ + Drag each capital to its country: + + France + Germany + + + Paris + Berlin + + +
+ + + + + + + + + 0 + + + + + + + + + +
`; + +describe('QTI 3.0 Extraction', () => { + describe('Choice Interaction', () => { + it('should extract and render QTI 3.0 choice interaction', () => { + const player = new Player({ itemXml: QTI3_SIMPLE_CHOICE }); + const interactions = player.getInteractionData(); + + // Verify interaction was extracted + expect(interactions).toHaveLength(1); + const interaction = interactions[0]; + + expect(interaction.type).toBe('qti-choice-interaction'); // QTI 3.0 uses kebab-case + expect(interaction.responseId).toBe('RESPONSE'); + expect(interaction.maxChoices).toBe(1); + expect(interaction.shuffle).toBe(false); + expect(interaction.choices).toHaveLength(4); + expect(interaction.choices[0].identifier).toBe('ChoiceA'); + expect(interaction.choices[0].text).toContain('7'); + }); + + it('should process responses correctly for QTI 3.0 items', () => { + const player = new Player({ itemXml: QTI3_SIMPLE_CHOICE }); + + // Set correct response + player.setResponses({ RESPONSE: 'ChoiceA' }); + const result = player.processResponses(); + + expect(result.score).toBe(1.0); + expect(result.outcomeValues.SCORE).toBe('1.0'); // outcomeValues are strings + }); + }); + + describe('Text Entry Interaction', () => { + it('should extract QTI 3.0 text entry with expected-length attribute', () => { + const player = new Player({ itemXml: QTI3_TEXT_ENTRY }); + const interactions = player.getInteractionData(); + + expect(interactions).toHaveLength(1); + const interaction = interactions[0]; + + expect(interaction.type).toBe('qti-text-entry-interaction'); // QTI 3.0 uses kebab-case + expect(interaction.responseId).toBe('RESPONSE'); + expect(interaction.expectedLength).toBe(15); + }); + + it('should process text entry responses correctly', () => { + const player = new Player({ itemXml: QTI3_TEXT_ENTRY }); + + player.setResponses({ RESPONSE: 'Paris' }); + const result = player.processResponses(); + + expect(result.score).toBe(1.0); + }); + }); + + describe('Extended Text Interaction', () => { + it('should extract QTI 3.0 extended text with kebab-case attributes', () => { + const player = new Player({ itemXml: QTI3_EXTENDED_TEXT }); + const interactions = player.getInteractionData(); + + expect(interactions).toHaveLength(1); + const interaction = interactions[0]; + + expect(interaction.type).toBe('qti-extended-text-interaction'); // QTI 3.0 uses kebab-case + expect(interaction.responseId).toBe('RESPONSE'); + expect(interaction.expectedLines).toBe(5); + expect(interaction.expectedLength).toBe(500); + expect(interaction.placeholderText).toBe('Type your answer here...'); + }); + }); + + describe('Match Interaction', () => { + it('should extract QTI 3.0 match interaction with kebab-case attributes', () => { + const player = new Player({ itemXml: QTI3_MATCH }); + const interactions = player.getInteractionData(); + + expect(interactions).toHaveLength(1); + const interaction = interactions[0]; + + expect(interaction.type).toBe('qti-match-interaction'); // QTI 3.0 uses kebab-case + expect(interaction.responseId).toBe('RESPONSE'); + expect(interaction.maxAssociations).toBe(3); + expect(interaction.shuffle).toBe(false); + expect(interaction.sourceSet).toHaveLength(2); + expect(interaction.targetSet).toHaveLength(2); + expect(interaction.sourceSet[0].identifier).toBe('france'); + expect(interaction.targetSet[0].identifier).toBe('paris'); + }); + + it('should process match interaction responses correctly', () => { + const player = new Player({ itemXml: QTI3_MATCH }); + + // Set correct response + player.setResponses({ RESPONSE: ['france paris', 'germany berlin'] }); + const result = player.processResponses(); + + expect(result.score).toBe(3.0); + }); + }); + + describe('Backward Compatibility', () => { + it('should handle QTI 2.x without mappers (default behavior)', () => { + const qti2xml = ` + + + + A + + + + 0 + + + + Choice A + Choice B + + + +`; + + // No explicit mappers - should automatically use QTI 2.x defaults + const player = new Player({ itemXml: qti2xml }); + const interactions = player.getInteractionData(); + + expect(interactions).toHaveLength(1); + expect(interactions[0].type).toBe('choiceInteraction'); + expect(interactions[0].maxChoices).toBe(1); + expect(interactions[0].shuffle).toBe(true); + + // Verify responses work + player.setResponses({ RESPONSE: 'A' }); + const result = player.processResponses(); + expect(result.score).toBe(1); + }); + }); +}); diff --git a/packages/item-player/src/core/Player.ts b/packages/item-player/src/core/Player.ts index dd808e2..4c5fa09 100644 --- a/packages/item-player/src/core/Player.ts +++ b/packages/item-player/src/core/Player.ts @@ -27,8 +27,14 @@ import { toNumber, toStringValue, } from '@pie-qti/qti-processing'; -import type { ElementNameMapper } from '@pie-qti/qti-common'; -import { Qti2xElementNameMapper } from '@pie-qti/qti-common'; +import type { ElementNameMapper, AttributeNameMapper } from '@pie-qti/qti-common'; +import { + Qti2xElementNameMapper, + Qti2xAttributeNameMapper, + Qti3ElementNameMapper, + Qti3AttributeNameMapper, + detectQtiVersion, +} from '@pie-qti/qti-common'; import { parse } from 'node-html-parser'; import type { ExtractionRegistry } from '../extraction/ExtractionRegistry.js'; import { createExtractionRegistry } from '../extraction/ExtractionRegistry.js'; @@ -82,7 +88,28 @@ export class Player { this.itemXml = config.itemXml ?? ''; this.role = config.role ?? 'candidate'; this.rng = config.rng ?? (typeof config.seed === 'number' ? createSeededRng(config.seed) : Math.random); - this.mapper = (config.elementNameMapper as ElementNameMapper | undefined) ?? new Qti2xElementNameMapper(); + + // Auto-detect QTI version and create appropriate mappers if not provided + if (!config.elementNameMapper) { + const version = detectQtiVersion(this.itemXml); + if (version === '3.0') { + this.mapper = new Qti3ElementNameMapper(); + // Set in config so it's available to extraction utils + (config as any).elementNameMapper = this.mapper; + // Also set attribute mapper if not provided + if (!config.attributeNameMapper) { + (config as any).attributeNameMapper = new Qti3AttributeNameMapper(); + } + } else { + this.mapper = new Qti2xElementNameMapper(); + (config as any).elementNameMapper = this.mapper; + if (!config.attributeNameMapper) { + (config as any).attributeNameMapper = new Qti2xAttributeNameMapper(); + } + } + } else { + this.mapper = config.elementNameMapper as ElementNameMapper; + } // Optional DoS guardrails for untrusted content (compat-by-default; disabled unless enabled). enforceItemXmlLimits(this.itemXml, this.config.security); @@ -101,7 +128,8 @@ export class Player { } // Extraction + rendering registries (for interaction rendering) - this.extractionRegistry = (config.extractionRegistry as ExtractionRegistry | undefined) ?? createExtractionRegistry(); + // Pass the element name mapper to the extraction registry for QTI version handling + this.extractionRegistry = (config.extractionRegistry as ExtractionRegistry | undefined) ?? createExtractionRegistry(this.mapper); this.componentRegistry = (config.componentRegistry as ComponentRegistry | undefined) ?? createComponentRegistry(); // I18n provider (defaults to a simple fallback if not provided) @@ -240,6 +268,24 @@ export class Player { return this.i18nProvider; } + /** + * Get attribute value with QTI version support. + * Tries the attribute mapper's native form first, then falls back to direct lookup. + * @param el - Element to get attribute from + * @param canonicalName - Canonical (lowercase) attribute name + * @returns Attribute value or null + */ + private getAttrMapped(el: Element, canonicalName: string): string | null { + // Try mapped attribute name first (e.g., 'mapkey' -> 'map-key' for QTI 3.0) + if (this.config.attributeNameMapper) { + const nativeName = this.config.attributeNameMapper.toNative(canonicalName); + const value = el.getAttribute(nativeName); + if (value !== null) return value; + } + // Fallback to canonical name (for QTI 2.x compatibility) + return getAttr(el, canonicalName); + } + /** * Create a simple fallback i18n provider when none is provided * This avoids a hard dependency on @pie-qti/i18n @@ -263,11 +309,19 @@ export class Player { */ private detectQTIVersion(): string { const ns = (this.assessmentItem as any).namespaceURI; + if (ns?.includes('v3p0') || ns?.includes('imsqtiasi_v3p0')) return '3.0'; if (ns?.includes('v2p2') || ns?.includes('imsqti_v2p2')) return '2.2'; if (ns?.includes('v2p1') || ns?.includes('imsqti_v2p1')) return '2.1'; if (ns?.includes('v2p0') || ns?.includes('imsqti_v2p0')) return '2.0'; + // Check element name for QTI 3.0 + const localName = (this.assessmentItem as any).localName || (this.assessmentItem as any).tagName; + if (localName === 'qti-assessment-item' || localName === 'qti-assessment-test') { + return '3.0'; + } + const versionAttr = getAttr(this.assessmentItem, 'version'); + if (versionAttr?.startsWith('3.')) return '3.0'; if (versionAttr === '2.0') return '2.0'; if (versionAttr === '2.1') return '2.1'; if (versionAttr === '2.2') return '2.2'; @@ -834,9 +888,9 @@ export class Player { const decls: DeclarationMap = {}; const addDecl = (kind: DeclKind, el: Element) => { - const identifier = getAttr(el, 'identifier'); - const cardinality = (getAttr(el, 'cardinality') || 'single') as Cardinality; - const baseType = (getAttr(el, 'baseType') || 'string') as BaseType; + const identifier = this.getAttrMapped(el, 'identifier'); + const cardinality = (this.getAttrMapped(el, 'cardinality') || 'single') as Cardinality; + const baseType = (this.getAttrMapped(el, 'baseType') || 'string') as BaseType; if (!identifier) return; const defaultValue = this.parseDefaultValue(el, baseType, cardinality); @@ -854,7 +908,7 @@ export class Player { // Response + outcome declarations may define mappings used by mapResponse/mapOutcome. // (Templates do not participate in mapping.) if (kind === 'response' || kind === 'outcome') { - const mappingEl = findFirstDescendant(el, 'mapping'); + const mappingEl = findFirstDescendant(el, this.mapper.toNative('mapping')); if (mappingEl) { decls[identifier].mapping = this.parseMapping(mappingEl, baseType); } @@ -863,8 +917,8 @@ export class Player { // Outcome declarations may define a lookup table (matchTable or interpolationTable) // used by the lookupOutcomeValue rule. if (kind === 'outcome') { - const matchTableEl = findFirstDescendant(el, 'matchTable'); - const interpolationTableEl = findFirstDescendant(el, 'interpolationTable'); + const matchTableEl = findFirstDescendant(el, this.mapper.toNative('matchtable')); + const interpolationTableEl = findFirstDescendant(el, this.mapper.toNative('interpolationtable')); if (matchTableEl) { decls[identifier].lookupTable = this.parseMatchTable(matchTableEl); } else if (interpolationTableEl) { @@ -873,11 +927,11 @@ export class Player { } if (kind === 'response') { - const correctEl = findFirstDescendant(el, 'correctResponse'); + const correctEl = findFirstDescendant(el, this.mapper.toNative('correctresponse')); if (correctEl) { decls[identifier].correctResponse = this.parseCorrectResponse(correctEl, baseType, cardinality); } - const areaMappingEl = findFirstDescendant(el, 'areaMapping'); + const areaMappingEl = findFirstDescendant(el, this.mapper.toNative('areamapping')); if (areaMappingEl) { decls[identifier].areaMapping = this.parseAreaMapping(areaMappingEl); } @@ -897,7 +951,7 @@ export class Player { return { kind: 'table.matchTable' as const, defaultValue: Number.isFinite(defaultValue as any) ? defaultValue : undefined, - entries: findDescendants(el, 'matchTableEntry').map((e) => ({ + entries: findDescendants(el, this.mapper.toNative('matchtableentry')).map((e) => ({ sourceValue: (getAttr(e, 'sourceValue') || '').trim(), targetValue: (getAttr(e, 'targetValue') || '').trim(), })), @@ -912,7 +966,7 @@ export class Player { kind: 'table.interpolationTable' as const, defaultValue: Number.isFinite(defaultValue as any) ? defaultValue : undefined, interpolationMethod, - entries: findDescendants(el, 'interpolationTableEntry') + entries: findDescendants(el, this.mapper.toNative('interpolationtableentry')) .map((e) => ({ sourceValue: Number((getAttr(e, 'sourceValue') || '').trim()), targetValue: Number((getAttr(e, 'targetValue') || '').trim()), @@ -982,9 +1036,9 @@ export class Player { } private parseDefaultValue(declEl: Element, baseType: BaseType, cardinality: Cardinality): QtiValue { - const defaultEl = findFirstDescendant(declEl, 'defaultValue'); + const defaultEl = findFirstDescendant(declEl, this.mapper.toNative('defaultvalue')); if (!defaultEl) return qtiNull(baseType, cardinality); - const values = findDescendants(defaultEl, 'value').map((v) => (v.textContent || '').trim()); + const values = findDescendants(defaultEl, this.mapper.toNative('value')).map((v) => (v.textContent || '').trim()); if (cardinality === 'multiple' || cardinality === 'ordered') { return qtiValue( baseType, @@ -999,7 +1053,7 @@ export class Player { } private parseCorrectResponse(correctEl: Element, baseType: BaseType, cardinality: Cardinality): QtiValue { - const values = findDescendants(correctEl, 'value').map((v) => (v.textContent || '').trim()); + const values = findDescendants(correctEl, this.mapper.toNative('value')).map((v) => (v.textContent || '').trim()); if (cardinality === 'multiple' || cardinality === 'ordered') { return qtiValue( baseType, @@ -1014,10 +1068,10 @@ export class Player { } private parseMapping(mappingEl: Element, baseType: BaseType) { - const defaultValue = Number(getAttr(mappingEl, 'defaultValue') || 0); - const lowerBound = getAttr(mappingEl, 'lowerBound') ? Number(getAttr(mappingEl, 'lowerBound')) : undefined; - const upperBound = getAttr(mappingEl, 'upperBound') ? Number(getAttr(mappingEl, 'upperBound')) : undefined; - const mappingCaseSensitive = getAttr(mappingEl, 'caseSensitive') || 'true'; + const defaultValue = Number(this.getAttrMapped(mappingEl, 'defaultValue') || 0); + const lowerBound = this.getAttrMapped(mappingEl, 'lowerBound') ? Number(this.getAttrMapped(mappingEl, 'lowerBound')) : undefined; + const upperBound = this.getAttrMapped(mappingEl, 'upperBound') ? Number(this.getAttrMapped(mappingEl, 'upperBound')) : undefined; + const mappingCaseSensitive = this.getAttrMapped(mappingEl, 'caseSensitive') || 'true'; const entries: Record = {}; const normalizePairKey = (k: string): string => { @@ -1032,11 +1086,11 @@ export class Player { return k.trim(); }; - for (const e of findDescendants(mappingEl, 'mapEntry')) { - const mapKey = getAttr(e, 'mapKey'); - const mappedValue = getAttr(e, 'mappedValue'); + for (const e of findDescendants(mappingEl, this.mapper.toNative('mapentry'))) { + const mapKey = this.getAttrMapped(e, 'mapKey'); + const mappedValue = this.getAttrMapped(e, 'mappedValue'); if (!mapKey || mappedValue === null) continue; - const effectiveCaseSensitive = getAttr(e, 'caseSensitive') || mappingCaseSensitive || 'true'; + const effectiveCaseSensitive = this.getAttrMapped(e, 'caseSensitive') || mappingCaseSensitive || 'true'; const storedKey = baseType === 'pair' ? normalizePairKey(mapKey) @@ -1067,7 +1121,7 @@ export class Player { : undefined; const entries: AreaMapEntry[] = []; - for (const e of findDescendants(areaMappingEl, 'areaMapEntry')) { + for (const e of findDescendants(areaMappingEl, this.mapper.toNative('areamapentry'))) { const shape = (getAttr(e, 'shape') || 'default') as AreaMapEntry['shape']; const coords = String(getAttr(e, 'coords') || ''); const mappedValue = Number(getAttr(e, 'mappedValue') || 0); diff --git a/packages/item-player/src/extraction/ExtractionRegistry.ts b/packages/item-player/src/extraction/ExtractionRegistry.ts index 1b1af33..5d31672 100644 --- a/packages/item-player/src/extraction/ExtractionRegistry.ts +++ b/packages/item-player/src/extraction/ExtractionRegistry.ts @@ -8,6 +8,7 @@ * - Validation and error handling */ +import { Qti2xElementNameMapper, type ElementNameMapper } from '@pie-qti/qti-common'; import type { QTIElement } from '../types/interactions.js'; import type { ElementExtractor, @@ -30,6 +31,16 @@ export class ExtractionRegistry { /** Cache for element -> extractor lookups (WeakMap for automatic GC) */ private extractorCache = new WeakMap(); + /** Optional element name mapper for QTI version handling */ + private elementNameMapper?: ElementNameMapper; + + /** QTI 2.x mapper for converting canonical names to extractor registry keys */ + private qti2xMapper = new Qti2xElementNameMapper(); + + constructor(elementNameMapper?: ElementNameMapper) { + this.elementNameMapper = elementNameMapper; + } + /** * Register an extractor with the registry * @@ -76,11 +87,16 @@ export class ExtractionRegistry { this.extractorsById.set(extractor.id, extractor as ElementExtractor); // Register by type for fast lookup + // Normalize element types to canonical (lowercase) form for version-agnostic lookup for (const type of extractor.elementTypes) { - let extractors = this.extractorsByType.get(type); + // Convert to canonical form - extractors specify QTI 2.x names (e.g., 'choiceInteraction') + // but we store them in canonical form (e.g., 'choiceinteraction') for QTI version independence + const canonicalType = this.qti2xMapper.toCanonical(type); + + let extractors = this.extractorsByType.get(canonicalType); if (!extractors) { extractors = []; - this.extractorsByType.set(type, extractors); + this.extractorsByType.set(canonicalType, extractors); } extractors.push(extractor as ElementExtractor); @@ -103,9 +119,10 @@ export class ExtractionRegistry { // Remove from ID map this.extractorsById.delete(id); - // Remove from type maps + // Remove from type maps (using canonical form) for (const type of extractor.elementTypes) { - const extractors = this.extractorsByType.get(type); + const canonicalType = this.qti2xMapper.toCanonical(type); + const extractors = this.extractorsByType.get(canonicalType); if (extractors) { const index = extractors.findIndex((e) => e.id === id); if (index !== -1) { @@ -113,7 +130,7 @@ export class ExtractionRegistry { } // Clean up empty arrays if (extractors.length === 0) { - this.extractorsByType.delete(type); + this.extractorsByType.delete(canonicalType); } } } @@ -137,8 +154,24 @@ export class ExtractionRegistry { const cached = this.extractorCache.get(element); if (cached) return cached; + // Get element type - normalize using mapper for QTI version handling + // Extractors are registered in canonical (lowercase) form for version independence. + // Convert any QTI version element name to canonical form for lookup. + // + // Example for QTI 3.0: + // 'qti-choice-interaction' → 'choiceinteraction' (canonical) + // Example for QTI 2.x: + // 'choiceInteraction' → 'choiceinteraction' (canonical) + let lookupKey = element.rawTagName || ''; + const originalKey = lookupKey; + + if (lookupKey && this.elementNameMapper) { + // Convert element's raw tag to canonical form using the document's mapper + lookupKey = this.elementNameMapper.toCanonical(lookupKey); + } + // Get extractors for this element type (O(1) map lookup) - const extractors = this.extractorsByType.get(element.rawTagName || ''); + const extractors = this.extractorsByType.get(lookupKey); if (!extractors || extractors.length === 0) { return null; } @@ -252,7 +285,9 @@ export class ExtractionRegistry { * @returns Read-only array of extractors for this type, sorted by priority */ getExtractorsForType(elementType: string): ReadonlyArray { - return this.extractorsByType.get(elementType) || []; + // Convert to canonical form for lookup + const canonicalType = this.qti2xMapper.toCanonical(elementType); + return this.extractorsByType.get(canonicalType) || []; } /** @@ -278,7 +313,7 @@ export class ExtractionRegistry { * @returns New registry with same extractors */ clone(): ExtractionRegistry { - const cloned = new ExtractionRegistry(); + const cloned = new ExtractionRegistry(this.elementNameMapper); for (const extractor of this.extractorsById.values()) { cloned.register(extractor); } @@ -288,8 +323,9 @@ export class ExtractionRegistry { /** * Create a new extraction registry + * @param elementNameMapper - Optional element name mapper for QTI version handling * @returns Empty registry ready for extractor registration */ -export function createExtractionRegistry(): ExtractionRegistry { - return new ExtractionRegistry(); +export function createExtractionRegistry(elementNameMapper?: ElementNameMapper): ExtractionRegistry { + return new ExtractionRegistry(elementNameMapper); } diff --git a/packages/item-player/src/extraction/createContext.ts b/packages/item-player/src/extraction/createContext.ts index 476ae17..0a249f2 100644 --- a/packages/item-player/src/extraction/createContext.ts +++ b/packages/item-player/src/extraction/createContext.ts @@ -4,7 +4,7 @@ * Creates ExtractionContext instances with utilities and metadata */ -import type { ElementNameMapper } from '@pie-qti/qti-common'; +import type { ElementNameMapper, AttributeNameMapper } from '@pie-qti/qti-common'; import type { PlayerConfig } from '../types/index.js'; import type { QTIElement } from '../types/interactions.js'; import type { ExtractionContext, VariableDeclaration } from './types.js'; @@ -37,15 +37,16 @@ export function createExtractionContext( declarations: Map, config: PlayerConfig ): ExtractionContext { - // Pass the element name mapper to extraction utils for QTI version handling - const mapper = (config.elementNameMapper as ElementNameMapper | undefined); + // Pass both element and attribute name mappers for QTI version handling + const elementMapper = config.elementNameMapper as ElementNameMapper | undefined; + const attributeMapper = config.attributeNameMapper as AttributeNameMapper | undefined; return { element, responseId, dom, declarations, - utils: createExtractionUtils(config.security, mapper), + utils: createExtractionUtils(config.security, elementMapper, attributeMapper), config, }; } diff --git a/packages/item-player/src/extraction/extractors/extendedTextExtractor.ts b/packages/item-player/src/extraction/extractors/extendedTextExtractor.ts index 67b9c83..93b6fa5 100644 --- a/packages/item-player/src/extraction/extractors/extendedTextExtractor.ts +++ b/packages/item-player/src/extraction/extractors/extendedTextExtractor.ts @@ -28,8 +28,11 @@ export const standardExtendedTextExtractor: ElementExtractor = description: 'Extracts standard QTI extendedTextInteraction (multi-line text input)', canHandle(element, _context) { - // All extendedTextInteraction elements are standard - return element.rawTagName === 'extendedTextInteraction'; + // All extendedTextInteraction elements are standard (both QTI 2.x and 3.0) + return ( + element.rawTagName === 'extendedTextInteraction' || + element.rawTagName === 'qti-extended-text-interaction' + ); }, extract(element, context) { diff --git a/packages/item-player/src/extraction/extractors/matchExtractor.ts b/packages/item-player/src/extraction/extractors/matchExtractor.ts index c6e7a68..236408b 100644 --- a/packages/item-player/src/extraction/extractors/matchExtractor.ts +++ b/packages/item-player/src/extraction/extractors/matchExtractor.ts @@ -29,8 +29,8 @@ export const standardMatchExtractor: ElementExtractor = { description: 'Extracts standard QTI matchInteraction (matching pairs)', canHandle(element, _context) { - // All matchInteraction elements are standard - return element.rawTagName === 'matchInteraction'; + // All matchInteraction elements are standard (both QTI 2.x and 3.0) + return element.rawTagName === 'matchInteraction' || element.rawTagName === 'qti-match-interaction'; }, extract(element, context) { diff --git a/packages/item-player/src/extraction/extractors/textEntryExtractor.ts b/packages/item-player/src/extraction/extractors/textEntryExtractor.ts index 13d69bb..4b7cfef 100644 --- a/packages/item-player/src/extraction/extractors/textEntryExtractor.ts +++ b/packages/item-player/src/extraction/extractors/textEntryExtractor.ts @@ -27,8 +27,10 @@ export const standardTextEntryExtractor: ElementExtractor = { description: 'Extracts standard QTI textEntryInteraction (inline text input)', canHandle(element, _context) { - // All textEntryInteraction elements are standard - return element.rawTagName === 'textEntryInteraction'; + // All textEntryInteraction elements are standard (both QTI 2.x and 3.0) + return ( + element.rawTagName === 'textEntryInteraction' || element.rawTagName === 'qti-text-entry-interaction' + ); }, extract(element, context) { diff --git a/packages/item-player/src/extraction/utils.ts b/packages/item-player/src/extraction/utils.ts index f666d18..b01311d 100644 --- a/packages/item-player/src/extraction/utils.ts +++ b/packages/item-player/src/extraction/utils.ts @@ -4,8 +4,8 @@ * Provides DOM manipulation and content extraction helpers for extractors */ -import type { ElementNameMapper } from '@pie-qti/qti-common'; -import { Qti2xElementNameMapper } from '@pie-qti/qti-common'; +import type { ElementNameMapper, AttributeNameMapper } from '@pie-qti/qti-common'; +import { Qti2xElementNameMapper, Qti2xAttributeNameMapper } from '@pie-qti/qti-common'; import { sanitizeTextContent } from '../core/sanitizer.js'; import type { PlayerSecurityConfig } from '../types/index.js'; import type { QTIElement } from '../types/interactions.js'; @@ -54,34 +54,66 @@ function getChildrenByTagWithMapper( /** * Extract a boolean attribute from a QTI element + * Supports both QTI 2.x (camelCase) and QTI 3.0 (kebab-case) attributes */ function getBooleanAttribute( element: QTIElement, name: string, - defaultValue = false + defaultValue = false, + attributeMapper?: AttributeNameMapper ): boolean { - const value = element.getAttribute(name); + let value = element.getAttribute(name); + + // If not found and we have a mapper, try the native format (QTI 3.0 kebab-case) + if ((value === null || value === undefined) && attributeMapper) { + const nativeName = attributeMapper.toNative(name); + value = element.getAttribute(nativeName); + } + if (value === null || value === undefined) return defaultValue; return value === 'true'; } /** * Extract a number attribute from a QTI element + * Supports both QTI 2.x (camelCase) and QTI 3.0 (kebab-case) attributes */ function getNumberAttribute( element: QTIElement, name: string, - defaultValue: number + defaultValue: number, + attributeMapper?: AttributeNameMapper ): number { - const value = element.getAttribute(name); + let value = element.getAttribute(name); + + // If not found and we have a mapper, try the native format (QTI 3.0 kebab-case) + if ((value === null || value === undefined) && attributeMapper) { + const nativeName = attributeMapper.toNative(name); + value = element.getAttribute(nativeName); + } + return value ? Number(value) : defaultValue; } /** * Extract a string attribute from a QTI element + * Supports both QTI 2.x (camelCase) and QTI 3.0 (kebab-case) attributes */ -function getStringAttribute(element: QTIElement, name: string, defaultValue = ''): string { - return element.getAttribute(name) || defaultValue; +function getStringAttribute( + element: QTIElement, + name: string, + defaultValue = '', + attributeMapper?: AttributeNameMapper +): string { + let value = element.getAttribute(name); + + // If not found and we have a mapper, try the native format (QTI 3.0 kebab-case) + if ((value === null || value === undefined) && attributeMapper) { + const nativeName = attributeMapper.toNative(name); + value = element.getAttribute(nativeName); + } + + return value || defaultValue; } /** @@ -121,14 +153,16 @@ function matchesTagName( /** * Create extraction utilities instance * Provides standard helpers for extracting data from QTI elements. - * Automatically handles both QTI 2.x and QTI 3.0 element naming conventions. + * Automatically handles both QTI 2.x and QTI 3.0 element and attribute naming conventions. */ export function createExtractionUtils( security?: PlayerSecurityConfig, - mapper?: ElementNameMapper + mapper?: ElementNameMapper, + attributeMapper?: AttributeNameMapper ): ExtractionUtils { - // Default to QTI 2.x mapper for backward compatibility + // Default to QTI 2.x mappers for backward compatibility const elementMapper = mapper || new Qti2xElementNameMapper(); + const attrMapper = attributeMapper || new Qti2xAttributeNameMapper(); return { getChildrenByTag(element: QTIElement, tagName: string): QTIElement[] { return getChildrenByTagWithMapper(element, tagName, elementMapper); @@ -186,15 +220,15 @@ export function createExtractionUtils( }, getAttribute(element: QTIElement, name: string, defaultValue = ''): string { - return getStringAttribute(element, name, defaultValue); + return getStringAttribute(element, name, defaultValue, attrMapper); }, getBooleanAttribute(element: QTIElement, name: string, defaultValue = false): boolean { - return getBooleanAttribute(element, name, defaultValue); + return getBooleanAttribute(element, name, defaultValue, attrMapper); }, getNumberAttribute(element: QTIElement, name: string, defaultValue = 0): number { - return getNumberAttribute(element, name, defaultValue); + return getNumberAttribute(element, name, defaultValue, attrMapper); }, getPrompt(element: QTIElement): string | null { diff --git a/packages/item-player/src/types/index.ts b/packages/item-player/src/types/index.ts index 909d23b..88357fd 100644 --- a/packages/item-player/src/types/index.ts +++ b/packages/item-player/src/types/index.ts @@ -180,6 +180,12 @@ export interface PlayerConfig { * @since 0.2.0 */ elementNameMapper?: any; // Will be ElementNameMapper from @pie-qti/qti-common + /** + * Optional attribute name mapper for handling different QTI versions. + * Defaults to Qti2xAttributeNameMapper for backward compatibility. + * @since 0.3.0 + */ + attributeNameMapper?: any; // Will be AttributeNameMapper from @pie-qti/qti-common /** Optional web component registry for custom interaction components */ componentRegistry?: any; // Will be ComponentRegistry, but avoiding circular dependency /** Optional extraction registry for custom element extractors */ diff --git a/packages/qti-common/src/element-mapper/AttributeNameMapper.ts b/packages/qti-common/src/element-mapper/AttributeNameMapper.ts new file mode 100644 index 0000000..a4941c9 --- /dev/null +++ b/packages/qti-common/src/element-mapper/AttributeNameMapper.ts @@ -0,0 +1,69 @@ +/** + * Interface for mapping between QTI version-specific attribute names and canonical forms. + * + * QTI 2.x uses camelCase (e.g., 'responseIdentifier', 'maxChoices', 'baseType') + * QTI 3.0 uses kebab-case (e.g., 'response-identifier', 'max-choices', 'base-type') + * + * This abstraction allows the parser and extractors to work with both versions + * by converting to/from a canonical camelCase form for internal processing. + */ +export interface AttributeNameMapper { + /** + * Convert QTI-version-specific attribute name to canonical camelCase form. + * + * @param attributeName - The attribute name as it appears in the XML + * @returns Canonical camelCase name for internal processing + * + * @example + * // QTI 2.x + * toCanonical('responseIdentifier') // => 'responseIdentifier' + * toCanonical('maxChoices') // => 'maxChoices' + * + * @example + * // QTI 3.0 + * toCanonical('response-identifier') // => 'responseIdentifier' + * toCanonical('max-choices') // => 'maxChoices' + */ + toCanonical(attributeName: string): string; + + /** + * Convert canonical name back to version-specific form. + * + * @param canonicalName - camelCase canonical name + * @returns Version-specific attribute name + * + * @example + * // QTI 2.x + * toNative('responseIdentifier') // => 'responseIdentifier' + * toNative('maxChoices') // => 'maxChoices' + * + * @example + * // QTI 3.0 + * toNative('responseIdentifier') // => 'response-identifier' + * toNative('maxChoices') // => 'max-choices' + */ + toNative(canonicalName: string): string; + + /** + * Check if attribute name matches expected pattern for this QTI version. + * + * @param attributeName - Attribute name to validate + * @returns true if valid for this version + * + * @example + * // QTI 2.x mapper + * isValidAttributeName('responseIdentifier') // => true + * isValidAttributeName('response-identifier') // => false + * + * @example + * // QTI 3.0 mapper + * isValidAttributeName('response-identifier') // => true + * isValidAttributeName('responseIdentifier') // => false + */ + isValidAttributeName(attributeName: string): boolean; + + /** + * Get the QTI version this mapper handles. + */ + readonly version: string; +} diff --git a/packages/qti-common/src/element-mapper/Qti2xAttributeNameMapper.ts b/packages/qti-common/src/element-mapper/Qti2xAttributeNameMapper.ts new file mode 100644 index 0000000..bb0a0f0 --- /dev/null +++ b/packages/qti-common/src/element-mapper/Qti2xAttributeNameMapper.ts @@ -0,0 +1,42 @@ +import type { AttributeNameMapper } from './AttributeNameMapper'; + +/** + * QTI 2.x attribute name mapper. + * + * QTI 2.x uses camelCase for all attribute names, so this mapper + * is essentially a pass-through that validates camelCase format. + * + * @example + * const mapper = new Qti2xAttributeNameMapper(); + * mapper.toCanonical('responseIdentifier'); // => 'responseIdentifier' + * mapper.toNative('maxChoices'); // => 'maxChoices' + */ +export class Qti2xAttributeNameMapper implements AttributeNameMapper { + readonly version = '2.x'; + + /** + * For QTI 2.x, attributes are already in camelCase (canonical form). + */ + toCanonical(attributeName: string): string { + return attributeName; + } + + /** + * For QTI 2.x, attributes are already in camelCase (native form). + */ + toNative(canonicalName: string): string { + return canonicalName; + } + + /** + * Check if attribute name is valid camelCase (no hyphens). + */ + isValidAttributeName(attributeName: string): boolean { + // QTI 2.x attributes should not contain hyphens + // Exception: XML standard attributes like xml:lang, xml:base + if (attributeName.startsWith('xml:') || attributeName.startsWith('xmlns')) { + return true; + } + return !attributeName.includes('-'); + } +} diff --git a/packages/qti-common/src/element-mapper/Qti3AttributeNameMapper.ts b/packages/qti-common/src/element-mapper/Qti3AttributeNameMapper.ts new file mode 100644 index 0000000..d03cbfd --- /dev/null +++ b/packages/qti-common/src/element-mapper/Qti3AttributeNameMapper.ts @@ -0,0 +1,326 @@ +import type { AttributeNameMapper } from './AttributeNameMapper'; + +/** + * QTI 3.0 attribute name mappings from kebab-case to camelCase. + * + * Based on QTI 3.0 specification where all attributes use kebab-case. + * This provides comprehensive mappings for all standard QTI attributes. + */ +const QTI3_ATTRIBUTE_MAPPINGS: Record = { + // Core identifiers + identifier: 'identifier', + 'response-identifier': 'responseIdentifier', + 'outcome-identifier': 'outcomeIdentifier', + 'template-identifier': 'templateIdentifier', + 'context-identifier': 'contextIdentifier', + + // Types and cardinality + 'base-type': 'baseType', + cardinality: 'cardinality', + + // Choice/selection attributes + 'max-choices': 'maxChoices', + 'min-choices': 'minChoices', + shuffle: 'shuffle', + fixed: 'fixed', + orientation: 'orientation', + required: 'required', + + // Match/association attributes + 'match-max': 'matchMax', + 'match-min': 'matchMin', + 'max-associations': 'maxAssociations', + 'min-associations': 'minAssociations', + + // Text interaction attributes + 'expected-length': 'expectedLength', + 'expected-lines': 'expectedLines', + 'pattern-mask': 'patternMask', + 'placeholder-text': 'placeholderText', + format: 'format', + 'string-identifier': 'stringIdentifier', + 'min-strings': 'minStrings', + 'max-strings': 'maxStrings', + + // Slider attributes + 'lower-bound': 'lowerBound', + 'upper-bound': 'upperBound', + step: 'step', + 'step-label': 'stepLabel', + reverse: 'reverse', + + // Hotspot/graphic attributes + shape: 'shape', + coords: 'coords', + 'hotspot-label': 'hotspotLabel', + 'min-hotspots': 'minHotspots', + 'max-hotspots': 'maxHotspots', + + // Media attributes + autostart: 'autostart', + 'min-plays': 'minPlays', + 'max-plays': 'maxPlays', + loop: 'loop', + controls: 'controls', + + // Feedback attributes + 'show-hide': 'showHide', + 'outcome-value': 'outcomeValue', + access: 'access', + view: 'view', + + // Outcome/result attributes + 'normal-maximum': 'normalMaximum', + 'normal-minimum': 'normalMinimum', + 'mastery-value': 'masteryValue', + interpretation: 'interpretation', + 'long-interpretation': 'longInterpretation', + + // Template attributes + 'math-variable': 'mathVariable', + 'param-variable': 'paramVariable', + + // Assessment/test attributes + 'time-dependent': 'timeDependent', + adaptive: 'adaptive', + title: 'title', + label: 'label', + + // Test structure attributes + 'navigation-mode': 'navigationMode', + 'submission-mode': 'submissionMode', + 'item-session-control': 'itemSessionControl', + 'time-limits': 'timeLimits', + + // Time limits + 'min-time': 'minTime', + 'max-time': 'maxTime', + 'allow-late-submission': 'allowLateSubmission', + + // Item session control + 'max-attempts': 'maxAttempts', + 'show-feedback': 'showFeedback', + 'allow-review': 'allowReview', + 'show-solution': 'showSolution', + 'allow-comment': 'allowComment', + 'allow-skipping': 'allowSkipping', + 'validate-responses': 'validateResponses', + + // Processing attributes + template: 'template', + 'template-location': 'templateLocation', + + // Expression attributes + 'field-identifier': 'fieldIdentifier', + 'base-value': 'baseValue', + 'tolerance-mode': 'toleranceMode', + 'include-category': 'includeCategory', + 'exclude-category': 'excludeCategory', + 'section-identifier': 'sectionIdentifier', + 'weight-identifier': 'weightIdentifier', + + // Mapping attributes + 'map-key': 'mapKey', + 'mapped-value': 'mappedValue', + 'case-sensitive': 'caseSensitive', + 'default-value': 'defaultValue', + // Note: lower-bound and upper-bound already defined above (used in slider and mapping contexts) + + // Interpolation attributes + 'source-value': 'sourceValue', + 'target-value': 'targetValue', + 'include-boundary': 'includeBoundary', + + // Ordering attributes + 'keep-together': 'keepTogether', + + // Selection attributes + select: 'select', + 'with-replacement': 'withReplacement', + + // Pre-conditions and branching + 'pre-condition': 'preCondition', + 'branch-rule': 'branchRule', + + // Catalog/accessibility attributes + 'data-catalog-idref': 'dataCatalogIdref', + support: 'support', + 'card-idref': 'cardIdref', + + // PCI attributes + hook: 'hook', + module: 'module', + 'entry-point': 'entryPoint', + + // Object attributes + data: 'data', + type: 'type', + width: 'width', + height: 'height', + codetype: 'codetype', + codebase: 'codebase', + archive: 'archive', + + // Param attributes + name: 'name', + value: 'value', + valuetype: 'valuetype', + + // Stylesheet attributes + href: 'href', + media: 'media', + + // Math operator attributes + operator: 'operator', + + // Stats operator attributes + 'stats-operator': 'statsOperator', + + // Rounding attributes + 'rounding-mode': 'roundingMode', + figures: 'figures', + + // Variable matching + 'variable-identifier': 'variableIdentifier', + + // Index attributes + n: 'n', + + // String matching + 'substring-match': 'substringMatch', + + // Container attributes + 'ordered-container': 'orderedContainer', + + // Custom operator + class: 'class', + definition: 'definition', + + // Standard XML attributes (pass through) + lang: 'lang', + 'xml:lang': 'xml:lang', + 'xml:base': 'xml:base', + id: 'id', + xmlns: 'xmlns', +} as const; + +/** + * QTI 3.0 attribute name mapper. + * + * QTI 3.0 uses kebab-case for attribute names. This mapper converts + * between kebab-case (QTI 3.0) and camelCase (internal canonical form). + * + * @example + * const mapper = new Qti3AttributeNameMapper(); + * mapper.toCanonical('response-identifier'); // => 'responseIdentifier' + * mapper.toCanonical('max-choices'); // => 'maxChoices' + * mapper.toNative('responseIdentifier'); // => 'response-identifier' + * mapper.toNative('maxChoices'); // => 'max-choices' + */ +export class Qti3AttributeNameMapper implements AttributeNameMapper { + readonly version = '3.0'; + + // Reverse mapping cache (camelCase -> kebab-case) + private reverseMap: Map | null = null; + + /** + * Convert QTI 3.0 kebab-case attribute to canonical camelCase. + */ + toCanonical(attributeName: string): string { + // Direct lookup + const canonical = QTI3_ATTRIBUTE_MAPPINGS[attributeName]; + if (canonical) { + return canonical; + } + + // If already in camelCase (no hyphens), return as-is + if (!attributeName.includes('-')) { + return attributeName; + } + + // Fallback: convert kebab-case to camelCase programmatically + return this.kebabToCamelCase(attributeName); + } + + /** + * Convert canonical camelCase to QTI 3.0 kebab-case. + */ + toNative(canonicalName: string): string { + // Build reverse map on first use + if (!this.reverseMap) { + this.reverseMap = new Map(); + for (const [kebab, camel] of Object.entries(QTI3_ATTRIBUTE_MAPPINGS)) { + this.reverseMap.set(camel, kebab); + } + } + + // Direct lookup + const native = this.reverseMap.get(canonicalName); + if (native) { + return native; + } + + // If already in kebab-case, return as-is + if (canonicalName.includes('-')) { + return canonicalName; + } + + // Fallback: convert camelCase to kebab-case programmatically + return this.camelToKebabCase(canonicalName); + } + + /** + * Check if attribute name is valid kebab-case. + */ + isValidAttributeName(attributeName: string): boolean { + // Standard XML attributes are always valid + if ( + attributeName.startsWith('xml:') || + attributeName.startsWith('xmlns') || + attributeName === 'id' + ) { + return true; + } + + // QTI 3.0 attributes should be kebab-case (lowercase with hyphens) + // or simple lowercase (like 'shuffle', 'type', 'value') + return /^[a-z][a-z0-9-]*$/.test(attributeName); + } + + /** + * Convert kebab-case to camelCase. + * @private + */ + private kebabToCamelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + } + + /** + * Convert camelCase to kebab-case. + * @private + */ + private camelToKebabCase(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + } +} + +/** + * Get all QTI 3.0 attribute names (kebab-case). + */ +export function getQti3AttributeNames(): readonly string[] { + return Object.keys(QTI3_ATTRIBUTE_MAPPINGS); +} + +/** + * Get canonical name for a QTI 3.0 attribute. + */ +export function getCanonicalAttributeName(qti3AttributeName: string): string | undefined { + return QTI3_ATTRIBUTE_MAPPINGS[qti3AttributeName]; +} + +/** + * Check if an attribute name is a valid QTI 3.0 attribute. + */ +export function isQti3Attribute(attributeName: string): boolean { + return attributeName in QTI3_ATTRIBUTE_MAPPINGS; +} diff --git a/packages/qti-common/src/element-mapper/__tests__/AttributeNameMapper.test.ts b/packages/qti-common/src/element-mapper/__tests__/AttributeNameMapper.test.ts new file mode 100644 index 0000000..34432c1 --- /dev/null +++ b/packages/qti-common/src/element-mapper/__tests__/AttributeNameMapper.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; +import { Qti2xAttributeNameMapper } from '../Qti2xAttributeNameMapper'; +import { Qti3AttributeNameMapper } from '../Qti3AttributeNameMapper'; + +describe('Qti2xAttributeNameMapper', () => { + const mapper = new Qti2xAttributeNameMapper(); + + describe('toCanonical', () => { + it('should return attribute name unchanged (pass-through)', () => { + expect(mapper.toCanonical('responseIdentifier')).toBe('responseIdentifier'); + expect(mapper.toCanonical('maxChoices')).toBe('maxChoices'); + expect(mapper.toCanonical('shuffle')).toBe('shuffle'); + expect(mapper.toCanonical('baseType')).toBe('baseType'); + }); + }); + + describe('toNative', () => { + it('should return attribute name unchanged (pass-through)', () => { + expect(mapper.toNative('responseIdentifier')).toBe('responseIdentifier'); + expect(mapper.toNative('maxChoices')).toBe('maxChoices'); + expect(mapper.toNative('shuffle')).toBe('shuffle'); + }); + }); + + describe('isValidAttributeName', () => { + it('should accept camelCase attributes', () => { + expect(mapper.isValidAttributeName('responseIdentifier')).toBe(true); + expect(mapper.isValidAttributeName('maxChoices')).toBe(true); + expect(mapper.isValidAttributeName('baseType')).toBe(true); + }); + + it('should accept simple lowercase attributes', () => { + expect(mapper.isValidAttributeName('shuffle')).toBe(true); + expect(mapper.isValidAttributeName('type')).toBe(true); + expect(mapper.isValidAttributeName('value')).toBe(true); + }); + + it('should reject kebab-case attributes', () => { + expect(mapper.isValidAttributeName('response-identifier')).toBe(false); + expect(mapper.isValidAttributeName('max-choices')).toBe(false); + expect(mapper.isValidAttributeName('base-type')).toBe(false); + }); + + it('should accept standard XML attributes', () => { + expect(mapper.isValidAttributeName('xml:lang')).toBe(true); + expect(mapper.isValidAttributeName('xml:base')).toBe(true); + expect(mapper.isValidAttributeName('xmlns')).toBe(true); + expect(mapper.isValidAttributeName('xmlns:xsi')).toBe(true); + }); + }); + + describe('version', () => { + it('should return 2.x', () => { + expect(mapper.version).toBe('2.x'); + }); + }); +}); + +describe('Qti3AttributeNameMapper', () => { + const mapper = new Qti3AttributeNameMapper(); + + describe('toCanonical', () => { + it('should convert kebab-case to camelCase for identifiers', () => { + expect(mapper.toCanonical('response-identifier')).toBe('responseIdentifier'); + expect(mapper.toCanonical('outcome-identifier')).toBe('outcomeIdentifier'); + expect(mapper.toCanonical('template-identifier')).toBe('templateIdentifier'); + }); + + it('should convert kebab-case to camelCase for choice attributes', () => { + expect(mapper.toCanonical('max-choices')).toBe('maxChoices'); + expect(mapper.toCanonical('min-choices')).toBe('minChoices'); + }); + + it('should convert kebab-case to camelCase for types', () => { + expect(mapper.toCanonical('base-type')).toBe('baseType'); + }); + + it('should convert kebab-case to camelCase for text interaction attributes', () => { + expect(mapper.toCanonical('expected-length')).toBe('expectedLength'); + expect(mapper.toCanonical('expected-lines')).toBe('expectedLines'); + expect(mapper.toCanonical('pattern-mask')).toBe('patternMask'); + expect(mapper.toCanonical('placeholder-text')).toBe('placeholderText'); + }); + + it('should convert kebab-case to camelCase for slider attributes', () => { + expect(mapper.toCanonical('lower-bound')).toBe('lowerBound'); + expect(mapper.toCanonical('upper-bound')).toBe('upperBound'); + expect(mapper.toCanonical('step-label')).toBe('stepLabel'); + }); + + it('should convert kebab-case to camelCase for match attributes', () => { + expect(mapper.toCanonical('match-max')).toBe('matchMax'); + expect(mapper.toCanonical('match-min')).toBe('matchMin'); + expect(mapper.toCanonical('max-associations')).toBe('maxAssociations'); + expect(mapper.toCanonical('min-associations')).toBe('minAssociations'); + }); + + it('should convert kebab-case to camelCase for hotspot attributes', () => { + expect(mapper.toCanonical('hotspot-label')).toBe('hotspotLabel'); + expect(mapper.toCanonical('min-hotspots')).toBe('minHotspots'); + expect(mapper.toCanonical('max-hotspots')).toBe('maxHotspots'); + }); + + it('should convert kebab-case to camelCase for feedback attributes', () => { + expect(mapper.toCanonical('show-hide')).toBe('showHide'); + expect(mapper.toCanonical('outcome-value')).toBe('outcomeValue'); + }); + + it('should convert kebab-case to camelCase for outcome attributes', () => { + expect(mapper.toCanonical('normal-maximum')).toBe('normalMaximum'); + expect(mapper.toCanonical('normal-minimum')).toBe('normalMinimum'); + expect(mapper.toCanonical('mastery-value')).toBe('masteryValue'); + }); + + it('should convert kebab-case to camelCase for assessment attributes', () => { + expect(mapper.toCanonical('time-dependent')).toBe('timeDependent'); + }); + + it('should handle simple lowercase attributes unchanged', () => { + expect(mapper.toCanonical('shuffle')).toBe('shuffle'); + expect(mapper.toCanonical('cardinality')).toBe('cardinality'); + expect(mapper.toCanonical('identifier')).toBe('identifier'); + }); + + it('should handle unknown kebab-case attributes programmatically', () => { + expect(mapper.toCanonical('custom-attribute')).toBe('customAttribute'); + expect(mapper.toCanonical('foo-bar-baz')).toBe('fooBarBaz'); + }); + + it('should handle already camelCase attributes (edge case)', () => { + expect(mapper.toCanonical('responseIdentifier')).toBe('responseIdentifier'); + expect(mapper.toCanonical('maxChoices')).toBe('maxChoices'); + }); + }); + + describe('toNative', () => { + it('should convert camelCase to kebab-case for identifiers', () => { + expect(mapper.toNative('responseIdentifier')).toBe('response-identifier'); + expect(mapper.toNative('outcomeIdentifier')).toBe('outcome-identifier'); + expect(mapper.toNative('templateIdentifier')).toBe('template-identifier'); + }); + + it('should convert camelCase to kebab-case for choice attributes', () => { + expect(mapper.toNative('maxChoices')).toBe('max-choices'); + expect(mapper.toNative('minChoices')).toBe('min-choices'); + }); + + it('should convert camelCase to kebab-case for types', () => { + expect(mapper.toNative('baseType')).toBe('base-type'); + }); + + it('should convert camelCase to kebab-case for text interaction attributes', () => { + expect(mapper.toNative('expectedLength')).toBe('expected-length'); + expect(mapper.toNative('expectedLines')).toBe('expected-lines'); + expect(mapper.toNative('patternMask')).toBe('pattern-mask'); + expect(mapper.toNative('placeholderText')).toBe('placeholder-text'); + }); + + it('should handle simple lowercase attributes unchanged', () => { + expect(mapper.toNative('shuffle')).toBe('shuffle'); + expect(mapper.toNative('cardinality')).toBe('cardinality'); + expect(mapper.toNative('identifier')).toBe('identifier'); + }); + + it('should handle unknown camelCase attributes programmatically', () => { + expect(mapper.toNative('customAttribute')).toBe('custom-attribute'); + expect(mapper.toNative('fooBarBaz')).toBe('foo-bar-baz'); + }); + + it('should handle already kebab-case attributes (edge case)', () => { + expect(mapper.toNative('response-identifier')).toBe('response-identifier'); + expect(mapper.toNative('max-choices')).toBe('max-choices'); + }); + }); + + describe('round-trip conversion', () => { + it('should maintain consistency for camelCase -> kebab -> camelCase', () => { + const camelCaseAttrs = [ + 'responseIdentifier', + 'maxChoices', + 'baseType', + 'expectedLength', + 'lowerBound', + 'matchMax', + 'showHide', + 'normalMaximum', + 'timeDependent', + ]; + + for (const attr of camelCaseAttrs) { + const kebab = mapper.toNative(attr); + const backToCamel = mapper.toCanonical(kebab); + expect(backToCamel).toBe(attr); + } + }); + + it('should maintain consistency for kebab-case -> camelCase -> kebab', () => { + const kebabCaseAttrs = [ + 'response-identifier', + 'max-choices', + 'base-type', + 'expected-length', + 'lower-bound', + 'match-max', + 'show-hide', + 'normal-maximum', + 'time-dependent', + ]; + + for (const attr of kebabCaseAttrs) { + const camel = mapper.toCanonical(attr); + const backToKebab = mapper.toNative(camel); + expect(backToKebab).toBe(attr); + } + }); + }); + + describe('isValidAttributeName', () => { + it('should accept kebab-case attributes', () => { + expect(mapper.isValidAttributeName('response-identifier')).toBe(true); + expect(mapper.isValidAttributeName('max-choices')).toBe(true); + expect(mapper.isValidAttributeName('base-type')).toBe(true); + }); + + it('should accept simple lowercase attributes', () => { + expect(mapper.isValidAttributeName('shuffle')).toBe(true); + expect(mapper.isValidAttributeName('type')).toBe(true); + expect(mapper.isValidAttributeName('value')).toBe(true); + }); + + it('should reject camelCase attributes', () => { + expect(mapper.isValidAttributeName('responseIdentifier')).toBe(false); + expect(mapper.isValidAttributeName('maxChoices')).toBe(false); + expect(mapper.isValidAttributeName('baseType')).toBe(false); + }); + + it('should accept standard XML attributes', () => { + expect(mapper.isValidAttributeName('xml:lang')).toBe(true); + expect(mapper.isValidAttributeName('xml:base')).toBe(true); + expect(mapper.isValidAttributeName('xmlns')).toBe(true); + expect(mapper.isValidAttributeName('id')).toBe(true); + }); + + it('should reject uppercase in attribute names', () => { + expect(mapper.isValidAttributeName('Response-Identifier')).toBe(false); + expect(mapper.isValidAttributeName('Max-Choices')).toBe(false); + }); + }); + + describe('version', () => { + it('should return 3.0', () => { + expect(mapper.version).toBe('3.0'); + }); + }); +}); diff --git a/packages/qti-common/src/element-mapper/index.ts b/packages/qti-common/src/element-mapper/index.ts index 1501fb3..afe7c68 100644 --- a/packages/qti-common/src/element-mapper/index.ts +++ b/packages/qti-common/src/element-mapper/index.ts @@ -1,3 +1,7 @@ export type { ElementNameMapper } from './ElementNameMapper.js'; export { Qti2xElementNameMapper } from './Qti2xElementNameMapper.js'; export { Qti3ElementNameMapper } from './Qti3ElementNameMapper.js'; + +export type { AttributeNameMapper } from './AttributeNameMapper.js'; +export { Qti2xAttributeNameMapper } from './Qti2xAttributeNameMapper.js'; +export { Qti3AttributeNameMapper } from './Qti3AttributeNameMapper.js'; diff --git a/packages/qti-common/src/element-mapper/qti3-element-mappings.ts b/packages/qti-common/src/element-mapper/qti3-element-mappings.ts index 38a6b9d..fdc8705 100644 --- a/packages/qti-common/src/element-mapper/qti3-element-mappings.ts +++ b/packages/qti-common/src/element-mapper/qti3-element-mappings.ts @@ -1,10 +1,12 @@ /** * Comprehensive QTI 3.0 element name mappings. * - * Based on amp-up.io QTI 3 Item Player Vue3 implementation and QTI 3.0 specification. + * Based on IMS Global/1EdTech QTI 3.0 specification and amp-up.io implementation. * All QTI 3.0 elements use kebab-case with 'qti-' prefix. * - * This file documents all 250+ QTI 3.0 elements for reference and validation. + * This file documents 172 QTI 3.0 elements providing 100% coverage of the official + * specification including all interactions, processing rules, expressions, and + * accessibility features. */ /** @@ -71,6 +73,9 @@ export const INTERACTION_ELEMENTS = { // Inline interactions 'qti-text-entry-interaction': 'textentryinteraction', 'qti-inline-choice-interaction': 'inlinechoiceinteraction', + + // Composite interaction (QTI 3.0 advanced feature) + 'qti-composite-interaction': 'compositeinteraction', } as const; /** @@ -220,6 +225,12 @@ export const EXPRESSION_ELEMENTS = { 'qti-number-presented': 'numberpresented', 'qti-number-responded': 'numberresponded', 'qti-number-selected': 'numberselected', + + // Duration operators (time-based constraints) + 'qti-duration-gt': 'durationgt', + 'qti-duration-gte': 'durationgte', + 'qti-duration-lt': 'durationlt', + 'qti-duration-lte': 'durationlte', } as const; /** @@ -240,6 +251,8 @@ export const PCI_ELEMENTS = { 'qti-interaction-modules': 'interactionmodules', 'qti-interaction-module': 'interactionmodule', 'qti-interaction-markup': 'interactionmarkup', + 'qti-interaction-hook': 'interactionhook', + 'qti-interaction-config': 'interactionconfig', } as const; /** diff --git a/packages/qti-common/src/index.ts b/packages/qti-common/src/index.ts index 8e57e2c..8e6de34 100644 --- a/packages/qti-common/src/index.ts +++ b/packages/qti-common/src/index.ts @@ -3,6 +3,11 @@ export type { ElementNameMapper } from './element-mapper/ElementNameMapper.js'; export { Qti2xElementNameMapper } from './element-mapper/Qti2xElementNameMapper.js'; export { Qti3ElementNameMapper } from './element-mapper/Qti3ElementNameMapper.js'; +// Attribute name mapping +export type { AttributeNameMapper } from './element-mapper/AttributeNameMapper.js'; +export { Qti2xAttributeNameMapper } from './element-mapper/Qti2xAttributeNameMapper.js'; +export { Qti3AttributeNameMapper } from './element-mapper/Qti3AttributeNameMapper.js'; + // Version detection export { detectQtiVersion } from './version-detection/detectQtiVersion.js'; export type { QtiVersion } from './version-detection/detectQtiVersion.js'; @@ -21,6 +26,7 @@ export { export { createQtiParser, createMapperForVersion, + createAttributeMapperForVersion, isQti2, isQti3, type QtiParserResult, diff --git a/packages/qti-common/src/parser-factory.ts b/packages/qti-common/src/parser-factory.ts index e05be5b..ae83702 100644 --- a/packages/qti-common/src/parser-factory.ts +++ b/packages/qti-common/src/parser-factory.ts @@ -9,6 +9,9 @@ import { detectQtiVersion, type QtiVersion } from './version-detection/detectQti import { Qti2xElementNameMapper } from './element-mapper/Qti2xElementNameMapper.js'; import { Qti3ElementNameMapper } from './element-mapper/Qti3ElementNameMapper.js'; import type { ElementNameMapper } from './element-mapper/ElementNameMapper.js'; +import { Qti2xAttributeNameMapper } from './element-mapper/Qti2xAttributeNameMapper.js'; +import { Qti3AttributeNameMapper } from './element-mapper/Qti3AttributeNameMapper.js'; +import type { AttributeNameMapper } from './element-mapper/AttributeNameMapper.js'; /** * Result of creating a QTI parser. @@ -23,6 +26,11 @@ export interface QtiParserResult { * Element name mapper appropriate for the detected version */ mapper: ElementNameMapper; + + /** + * Attribute name mapper appropriate for the detected version + */ + attributeMapper: AttributeNameMapper; } /** @@ -40,6 +48,12 @@ export interface CreateParserOptions { * If provided, overrides automatic mapper selection. */ mapper?: ElementNameMapper; + + /** + * Custom attribute name mapper to use. + * If provided, overrides automatic mapper selection. + */ + attributeMapper?: AttributeNameMapper; } /** @@ -73,24 +87,17 @@ export interface CreateParserOptions { * ``` */ export function createQtiParser(xml: string, options?: CreateParserOptions): QtiParserResult { - // Use custom mapper if provided - if (options?.mapper) { - const version = options.version ?? detectQtiVersion(xml); - return { - version, - mapper: options.mapper, - }; - } - // Detect or use specified version const version = options?.version ?? detectQtiVersion(xml); - // Create appropriate mapper based on version - const mapper = createMapperForVersion(version); + // Use custom mappers if provided, otherwise create appropriate ones + const mapper = options?.mapper ?? createMapperForVersion(version); + const attributeMapper = options?.attributeMapper ?? createAttributeMapperForVersion(version); return { version, mapper, + attributeMapper, }; } @@ -124,6 +131,35 @@ export function createMapperForVersion(version: QtiVersion): ElementNameMapper { } } +/** + * Create attribute name mapper for a specific QTI version. + * + * @param version - QTI version + * @returns Attribute name mapper for the version + * + * @example + * ```typescript + * const qti2AttrMapper = createAttributeMapperForVersion('2.2'); + * const qti3AttrMapper = createAttributeMapperForVersion('3.0'); + * ``` + */ +export function createAttributeMapperForVersion(version: QtiVersion): AttributeNameMapper { + switch (version) { + case '2.0': + case '2.1': + case '2.2': + return new Qti2xAttributeNameMapper(); + + case '3.0': + return new Qti3AttributeNameMapper(); + + case 'unknown': + default: + // Default to QTI 2.x mapper for unknown versions + return new Qti2xAttributeNameMapper(); + } +} + /** * Check if XML content is QTI 3.0. * diff --git a/packages/qti-common/src/version-detection/detectQtiVersion.ts b/packages/qti-common/src/version-detection/detectQtiVersion.ts index 300a7be..1a33f37 100644 --- a/packages/qti-common/src/version-detection/detectQtiVersion.ts +++ b/packages/qti-common/src/version-detection/detectQtiVersion.ts @@ -69,9 +69,9 @@ function detectFromDocument(doc: any): QtiVersion { } } - // Strategy 2: Check root element name + // Strategy 2: Check root element name (any qti- prefixed element indicates QTI 3.0) const localName = root.localName || root.tagName; - if (localName === 'qti-assessment-item' || localName === 'qti-assessment-test') { + if (localName === 'qti-assessment-item' || localName === 'qti-assessment-test' || localName.startsWith('qti-')) { return '3.0'; } @@ -113,8 +113,8 @@ function detectFromString(xml: string): QtiVersion { return '2.0'; } - // Check root element - if (/]/.test(xml)) { + // Check root element or any QTI 3.0 element (kebab-case with qti- prefix) + if (/]/.test(xml) || /]/.test(xml)) { return '3.0'; } diff --git a/packages/qti-processing/src/ast/build.ts b/packages/qti-processing/src/ast/build.ts index 0822fe4..543a3da 100644 --- a/packages/qti-processing/src/ast/build.ts +++ b/packages/qti-processing/src/ast/build.ts @@ -586,11 +586,11 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope } case 'durationlt': { if (kids.length !== 2) throw new Error(' requires exactly two child expressions'); - return { kind: 'expr.durationLT', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.durationLT', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'durationgte': { if (kids.length !== 2) throw new Error(' requires exactly two child expressions'); - return { kind: 'expr.durationGTE', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.durationGTE', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'inside': { const shape = (getAttr(el, 'shape') || '').trim() as any; @@ -598,13 +598,13 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope if (!shape) throw new Error(' requires shape attribute'); if (!coords) throw new Error(' requires coords attribute'); if (kids.length !== 1) throw new Error(' requires exactly one child expression'); - return { kind: 'expr.inside', id, shape, coords, value: buildExpression(kids[0]!) }; + return { kind: 'expr.inside', id, shape, coords, value: buildExpression(kids[0]!, options) }; } case 'statsoperator': { const name = (getAttr(el, 'name') || '').trim() as any; if (!name) throw new Error(' requires name attribute'); if (kids.length !== 1) throw new Error(' requires exactly one child expression'); - return { kind: 'expr.statsOperator', id, name, values: buildExpression(kids[0]!) }; + return { kind: 'expr.statsOperator', id, name, values: buildExpression(kids[0]!, options) }; } case 'record': { // QTI record: children are VALUE @@ -619,19 +619,19 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope } case 'fieldvalue': { const fieldIdentifier = getAttr(el, 'fieldIdentifier') || ''; - return { kind: 'expr.fieldValue', id, fieldIdentifier, record: buildExpression(kids[0]!) }; + return { kind: 'expr.fieldValue', id, fieldIdentifier, record: buildExpression(kids[0]!, options) }; } case 'substring': { return { kind: 'expr.substring', id, - value: buildExpression(kids[0]!), - start: buildExpression(kids[1]!), + value: buildExpression(kids[0]!, options), + start: buildExpression(kids[1]!, options), length: kids[2] ? buildExpression(kids[2]) : undefined, }; } case 'lookuptable': { - const source = buildExpression(kids[0]!); + const source = buildExpression(kids[0]!, options); const tableEl = kids[1]; if (!tableEl) { throw new Error(' is missing its table element child'); @@ -691,43 +691,43 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope throw new Error(` unsupported table element: <${tableTag}>`); } case 'power': { - return { kind: 'expr.power', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.power', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'mod': { - return { kind: 'expr.mod', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.mod', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'integerdivide': { - return { kind: 'expr.integerDivide', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.integerDivide', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'mean': { - return { kind: 'expr.mean', id, values: buildExpression(kids[0]!) }; + return { kind: 'expr.mean', id, values: buildExpression(kids[0]!, options) }; } case 'samplevariance': { - return { kind: 'expr.sampleVariance', id, values: buildExpression(kids[0]!) }; + return { kind: 'expr.sampleVariance', id, values: buildExpression(kids[0]!, options) }; } case 'samplesd': { - return { kind: 'expr.sampleSD', id, values: buildExpression(kids[0]!) }; + return { kind: 'expr.sampleSD', id, values: buildExpression(kids[0]!, options) }; } case 'popvariance': { - return { kind: 'expr.popVariance', id, values: buildExpression(kids[0]!) }; + return { kind: 'expr.popVariance', id, values: buildExpression(kids[0]!, options) }; } case 'popsd': { - return { kind: 'expr.popSD', id, values: buildExpression(kids[0]!) }; + return { kind: 'expr.popSD', id, values: buildExpression(kids[0]!, options) }; } case 'istypeof': { return { kind: 'expr.isTypeOf', id, - value: buildExpression(kids[0]!), - baseType: buildExpression(kids[1]!), + value: buildExpression(kids[0]!, options), + baseType: buildExpression(kids[1]!, options), }; } case 'stringmatch': { return { kind: 'expr.stringMatch', id, - a: buildExpression(kids[0]!), - b: buildExpression(kids[1]!), + a: buildExpression(kids[0]!, options), + b: buildExpression(kids[1]!, options), caseSensitive: boolAttr('caseSensitive', false), substring: boolAttr('substring', false), }; @@ -736,28 +736,28 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope return { kind: 'expr.patternMatch', id, - value: buildExpression(kids[0]!), - pattern: buildExpression(kids[1]!), + value: buildExpression(kids[0]!, options), + pattern: buildExpression(kids[1]!, options), caseSensitive: boolAttr('caseSensitive', true), }; } case 'isnull': { - return { kind: 'expr.isNull', id, expr: buildExpression(kids[0]!) }; + return { kind: 'expr.isNull', id, expr: buildExpression(kids[0]!, options) }; } case 'isnotnull': { - return { kind: 'expr.isNotNull', id, expr: buildExpression(kids[0]!) }; + return { kind: 'expr.isNotNull', id, expr: buildExpression(kids[0]!, options) }; } case 'lt': { - return { kind: 'expr.lt', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.lt', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'lte': { - return { kind: 'expr.lte', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.lte', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'gt': { - return { kind: 'expr.gt', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.gt', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'gte': { - return { kind: 'expr.gte', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.gte', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'and': { return { kind: 'expr.and', id, ops: kids.map((k) => buildExpression(k, options)) }; @@ -766,7 +766,7 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope return { kind: 'expr.or', id, ops: kids.map((k) => buildExpression(k, options)) }; } case 'not': { - return { kind: 'expr.not', id, expr: buildExpression(kids[0]!) }; + return { kind: 'expr.not', id, expr: buildExpression(kids[0]!, options) }; } case 'anyn': { // QTI anyN: true if min <= #true(children) <= max @@ -783,17 +783,17 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope return { kind: 'expr.sum', id, values: kids.map((k) => buildExpression(k, options)) }; } case 'subtract': { - return { kind: 'expr.subtract', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.subtract', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'product': { return { kind: 'expr.product', id, values: kids.map((k) => buildExpression(k, options)) }; } case 'divide': { - return { kind: 'expr.divide', id, a: buildExpression(kids[0]!), b: buildExpression(kids[1]!) }; + return { kind: 'expr.divide', id, a: buildExpression(kids[0]!, options), b: buildExpression(kids[1]!, options) }; } case 'random': { if (kids.length !== 1) throw new Error(' requires exactly one child expression'); - return { kind: 'expr.random', id, value: buildExpression(kids[0]!) }; + return { kind: 'expr.random', id, value: buildExpression(kids[0]!, options) }; } case 'max': { return { kind: 'expr.max', id, values: kids.map((k) => buildExpression(k, options)) }; @@ -802,10 +802,10 @@ export function buildExpression(el: Element, options?: { scope?: ProcessingScope return { kind: 'expr.min', id, values: kids.map((k) => buildExpression(k, options)) }; } case 'round': { - return { kind: 'expr.round', id, value: buildExpression(kids[0]!) }; + return { kind: 'expr.round', id, value: buildExpression(kids[0]!, options) }; } case 'truncate': { - return { kind: 'expr.truncate', id, value: buildExpression(kids[0]!) }; + return { kind: 'expr.truncate', id, value: buildExpression(kids[0]!, options) }; } case 'mapresponse': { return { kind: 'expr.mapResponse', id, identifier: getAttr(el, 'identifier') || '' }; diff --git a/packages/qti-processing/src/xml/traverse.ts b/packages/qti-processing/src/xml/traverse.ts index c646a04..793c278 100644 --- a/packages/qti-processing/src/xml/traverse.ts +++ b/packages/qti-processing/src/xml/traverse.ts @@ -73,12 +73,22 @@ export function findAssessmentItem(doc: Document): Element { const root = doc.documentElement; if (!root) throw new Error('XML has no documentElement'); - // QTI items often have as the root element, but some wrappers exist. - if (localName(root)?.toLowerCase() === 'assessmentitem') return root; + const rootName = localName(root)?.toLowerCase(); - const found = findFirstDescendant(root, 'assessmentItem'); - if (!found) throw new Error('Could not find assessmentItem element in XML'); - return found; + // QTI items can have (QTI 2.x) or (QTI 3.0) as root + if (rootName === 'assessmentitem' || rootName === 'qti-assessment-item') { + return root; + } + + // Try QTI 2.x first + let found = findFirstDescendant(root, 'assessmentItem'); + if (found) return found; + + // Try QTI 3.0 + found = findFirstDescendant(root, 'qti-assessment-item'); + if (found) return found; + + throw new Error('Could not find assessmentItem or qti-assessment-item element in XML'); } diff --git a/test-attr-debug.js b/test-attr-debug.js new file mode 100644 index 0000000..0cbcbd6 --- /dev/null +++ b/test-attr-debug.js @@ -0,0 +1,11 @@ +const { parse } = require('node-html-parser'); + +const xml = ''; +const dom = parse(xml); +const el = dom.querySelector('qti-choice-interaction'); + +console.log('Element found:', !!el); +console.log('max-choices attr:', el?.getAttribute('max-choices')); +console.log('maxChoices attr:', el?.getAttribute('maxChoices')); +console.log('All attrs:', el?.attrs); +console.log('getAttribute type:', typeof el?.getAttribute); diff --git a/test-debug.ts b/test-debug.ts new file mode 100644 index 0000000..29c4ed7 --- /dev/null +++ b/test-debug.ts @@ -0,0 +1,17 @@ +import { parse } from 'node-html-parser'; +import { createQtiParser } from '@pie-qti/qti-common'; + +const xml = ``; + +const { attributeMapper } = createQtiParser(xml); +const dom = parse(xml); +const element = dom.querySelector('qti-choice-interaction'); + +console.log('Element found:', !!element); +console.log('Element type:', element?.constructor.name); +console.log('max-choices:', element?.getAttribute('max-choices')); +console.log('maxChoices:', element?.getAttribute('maxChoices')); +console.log('attrs:', element?.attrs); + +// Test mapper +console.log('Mapper toNative(maxChoices):', attributeMapper?.toNative('maxChoices'));