Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
174 changes: 174 additions & 0 deletions packages/item-player/src/__tests__/qti3-attribute-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<qti-choice-interaction response-identifier="RESPONSE" shuffle="false" max-choices="1">
<qti-prompt>Select one</qti-prompt>
<qti-simple-choice identifier="A">Choice A</qti-simple-choice>
</qti-choice-interaction>`;

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 = `
<qti-text-entry-interaction response-identifier="RESPONSE" expected-length="15" />`;

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 = `
<qti-extended-text-interaction
response-identifier="RESPONSE"
expected-lines="5"
expected-length="500"
placeholder-text="Type here..." />`;

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 = `
<qti-match-interaction response-identifier="RESPONSE" shuffle="false" max-associations="3">
<qti-simple-match-set>
<qti-simple-associable-choice identifier="A" match-max="1">Item A</qti-simple-associable-choice>
</qti-simple-match-set>
</qti-match-interaction>`;

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 = `
<choiceInteraction responseIdentifier="RESPONSE" shuffle="true" maxChoices="2">
<simpleChoice identifier="A">Choice A</simpleChoice>
</choiceInteraction>`;

// 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 = `<qti-choice-interaction shuffle="true" />`;
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 = `<choiceInteraction shuffle="true" />`;
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 = `<qti-choice-interaction response-identifier="RESPONSE" />`;
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 = `
<qti-slider-interaction response-identifier="RESPONSE" lower-bound="0" upper-bound="100" step="5" />`;

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);
});
});
140 changes: 140 additions & 0 deletions packages/item-player/src/__tests__/qti3-debug.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="test"
title="Test">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier"/>
<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" max-choices="1">
<qti-simple-choice identifier="A">A</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
</qti-assessment-item>`;

const QTI3_TEXT_ENTRY = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="qti3-text-entry"
title="Text Entry">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string"/>
<qti-item-body>
<p>What is the capital? <qti-text-entry-interaction response-identifier="RESPONSE" expected-length="15"/></p>
</qti-item-body>
</qti-assessment-item>`;

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 = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-item
xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
identifier="test"
title="Test">
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">
<qti-correct-response>
<qti-value>A</qti-value>
</qti-correct-response>
</qti-response-declaration>
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float">
<qti-default-value>
<qti-value>0</qti-value>
</qti-default-value>
</qti-outcome-declaration>
<qti-item-body>
<qti-choice-interaction response-identifier="RESPONSE" max-choices="1">
<qti-simple-choice identifier="A">Correct</qti-simple-choice>
<qti-simple-choice identifier="B">Wrong</qti-simple-choice>
</qti-choice-interaction>
</qti-item-body>
<qti-response-processing>
<qti-response-condition>
<qti-response-if>
<qti-match>
<qti-variable identifier="RESPONSE"/>
<qti-correct identifier="RESPONSE"/>
</qti-match>
<qti-set-outcome-value identifier="SCORE">
<qti-base-value base-type="float">1.0</qti-base-value>
</qti-set-outcome-value>
</qti-response-if>
</qti-response-condition>
</qti-response-processing>
</qti-assessment-item>`;

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);
});
});
Loading