Skip to content

Commit aed28ad

Browse files
feat(qti3): add delivery context parity foundations
Add resolved QTI 3 delivery context, item session lifecycle, timing evidence, and clean-room coverage so certification and accessibility gates can run against the restored workspace dependencies. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2170945 commit aed28ad

33 files changed

Lines changed: 2416 additions & 369 deletions

apps/demo/src/lib/package-processor.ts

Lines changed: 32 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ import {
1111
tryResolveImagePath
1212
} from '@pie-qti/ims-cp-browser';
1313
import type { VirtualPackage } from '@pie-qti/ims-cp-browser';
14-
import { extractQtiItemMetadata, type QtiItemMetadata } from '@pie-qti/ims-cp-core';
14+
import {
15+
createResolvedItemDeliveryContext,
16+
extractAssessmentStimulusRefs,
17+
extractQtiItemMetadata,
18+
type AssessmentStimulusRef,
19+
type QtiItemMetadata,
20+
type ResolvedItemDeliveryContext
21+
} from '@pie-qti/ims-cp-core';
1522
import { createLogger } from '@pie-qti/logger/browser';
1623

1724
// Storage key for the current package ID
@@ -364,63 +371,14 @@ function resolveMediaReferencesInXml(xml: string, pkg: PackageStructure, itemHre
364371
return new XMLSerializer().serializeToString(doc);
365372
}
366373

367-
/**
368-
* Stimulus reference extracted from a QTI item's qti-assessment-stimulus-ref element.
369-
*/
370-
export interface StimulusRef {
371-
identifier: string;
372-
href: string;
373-
title?: string;
374-
}
374+
export type StimulusRef = AssessmentStimulusRef;
375375

376376
/**
377377
* Extract qti-assessment-stimulus-ref elements from QTI 3.0 item XML.
378378
* Returns an array of stimulus references found in the item.
379379
*/
380380
export function extractStimulusRefsFromItemXml(xml: string): StimulusRef[] {
381-
const pattern = /<qti-assessment-stimulus-ref([^>]+)>/gi;
382-
const refs: StimulusRef[] = [];
383-
let match: RegExpExecArray | null;
384-
while ((match = pattern.exec(xml)) !== null) {
385-
const attrs = match[1];
386-
const identifier = attrs.match(/\bidentifier="([^"]+)"/i)?.[1];
387-
const href = attrs.match(/\bhref="([^"]+)"/i)?.[1];
388-
const title = attrs.match(/\btitle="([^"]+)"/i)?.[1];
389-
if (identifier && href) {
390-
refs.push({ identifier, href, title });
391-
}
392-
}
393-
return refs;
394-
}
395-
396-
/**
397-
* Extract inner HTML of the <qti-stimulus-body> element from a QTI 3.0 stimulus XML file.
398-
*/
399-
function extractStimulusBodyHtml(stimulusXml: string): string {
400-
const match = stimulusXml.match(/<qti-stimulus-body[^>]*>([\s\S]*?)<\/qti-stimulus-body>/i);
401-
return match ? match[1].trim() : '';
402-
}
403-
404-
/**
405-
* Resolve a path relative to a base file href within the package.
406-
* e.g. href="Items/Item-1/item.xml", relativePath="../../Passages/file.xml"
407-
* returns "Passages/file.xml"
408-
*/
409-
function resolveRelativePath(baseHref: string, relativePath: string): string {
410-
// Build a base by stripping the filename from the item href
411-
const baseDir = baseHref.includes('/') ? baseHref.substring(0, baseHref.lastIndexOf('/') + 1) : '';
412-
// Combine and resolve ../ sequences
413-
const combined = baseDir + relativePath;
414-
const parts = combined.split('/');
415-
const resolved: string[] = [];
416-
for (const part of parts) {
417-
if (part === '..') {
418-
resolved.pop();
419-
} else if (part !== '.') {
420-
resolved.push(part);
421-
}
422-
}
423-
return resolved.join('/');
381+
return extractAssessmentStimulusRefs(xml);
424382
}
425383

426384
/**
@@ -438,32 +396,30 @@ export async function loadStimulusContent(
438396
itemHref: string,
439397
refs: StimulusRef[]
440398
): Promise<Record<string, string>> {
441-
const result: Record<string, string> = {};
442-
443-
for (const ref of refs) {
444-
const stimulusPath = resolveRelativePath(itemHref, ref.href);
445-
const stimulusXml = pkg._pkg.readText(stimulusPath);
446-
if (!stimulusXml) {
447-
logger.warn(`Stimulus file not found in package: ${stimulusPath}`);
448-
continue;
449-
}
450-
451-
// Extract the body HTML
452-
let bodyHtml = extractStimulusBodyHtml(stimulusXml);
453-
if (!bodyHtml) {
454-
logger.warn(`No qti-stimulus-body found in stimulus: ${stimulusPath}`);
455-
continue;
456-
}
399+
const itemXml = pkg._pkg.readText(itemHref) ?? '';
400+
const deliveryContext = await loadItemDeliveryContext(pkg, itemHref, itemXml);
401+
return Object.fromEntries(
402+
refs
403+
.map((ref) => [ref.identifier, deliveryContext.stimuli[ref.identifier]?.bodyHtml] as const)
404+
.filter((entry): entry is readonly [string, string] => Boolean(entry[1]))
405+
);
406+
}
457407

458-
// Resolve images in the stimulus body using the stimulus file path as base
459-
try {
460-
bodyHtml = await resolveImagesInXmlCore(bodyHtml, pkg._pkg, stimulusPath, { logger });
461-
} catch (err) {
462-
logger.warn(`Failed to resolve images in stimulus ${ref.identifier}:`, err);
463-
}
408+
export async function loadItemDeliveryContext(
409+
pkg: PackageStructure,
410+
itemHref: string,
411+
itemXml: string
412+
): Promise<ResolvedItemDeliveryContext> {
413+
const context = createResolvedItemDeliveryContext({
414+
itemXml,
415+
itemHref,
416+
readText: (path) => pkg._pkg.readText(path),
417+
resolveAssetUrl: (path) => pkg._pkg.getDataUrl(path),
418+
});
464419

465-
result[ref.identifier] = bodyHtml;
420+
for (const message of context.validationMessages) {
421+
logger.warn(message);
466422
}
467423

468-
return result;
424+
return context;
469425
}

apps/demo/src/routes/package-upload/[packageId]/item/[itemId]/+page.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import { goto } from '$app/navigation';
66
import { base } from '$app/paths';
77
import { Player, normalizeHeuristicsConfig, shouldAutoPopulateFeedbackOutcome, type QtiHeuristicsConfig } from '@pie-qti/item-player';
8+
import type { ResolvedItemDeliveryContext } from '@pie-qti/ims-cp-core';
89
import type { InteractionResponseValue } from '@pie-qti/item-player/web-components';
910
// @ts-expect-error - Svelte-check can't resolve workspace subpath exports, but runtime works correctly
1011
import { ItemBody } from '@pie-qti/item-player/components';
1112
import { registerDefaultComponents } from '@pie-qti/default-components';
1213
import { typesetAction } from '@pie-qti/default-components/shared';
1314
import { typesetMathInElement } from '@pie-qti/typeset-katex';
1415
import type { SvelteI18nProvider } from '@pie-qti/i18n';
15-
import { loadPackageDataAsync, getItemXml, resolveImagesInXml, extractStimulusRefsFromItemXml, loadStimulusContent, listFiles } from '$lib/package-processor';
16+
import { loadPackageDataAsync, getItemXml, resolveImagesInXml, extractStimulusRefsFromItemXml, loadStimulusContent, loadItemDeliveryContext, listFiles } from '$lib/package-processor';
1617
import type { PackageStructure } from '$lib/package-processor';
1718
import { getSecurityConfig } from '$lib/player-config';
1819
import XmlEditor from '$lib/components/XmlEditor.svelte';
@@ -56,6 +57,7 @@
5657
let responses = $state<ItemResponseMap>({});
5758
let outcomeValues = $state<Record<string, any>>({});
5859
let stimulusContent = $state<Record<string, string>>({});
60+
let deliveryContext = $state<ResolvedItemDeliveryContext | undefined>(undefined);
5961
let diagnostics = $state<QtiCompatibilityReport | null>(null);
6062
6163
// Get i18n provider from context
@@ -78,6 +80,7 @@
7880
responses = {};
7981
outcomeValues = {};
8082
stimulusContent = {};
83+
deliveryContext = undefined;
8184
diagnostics = null;
8285
8386
// Load item asynchronously
@@ -135,6 +138,7 @@
135138
if (currentItem2?.href) {
136139
const stimulusRefs = extractStimulusRefsFromItemXml(itemXml);
137140
if (stimulusRefs.length > 0) {
141+
deliveryContext = await loadItemDeliveryContext(packageData, currentItem2.href, itemXml);
138142
stimulusContent = await loadStimulusContent(packageData, currentItem2.href, stimulusRefs);
139143
}
140144
}
@@ -145,6 +149,7 @@
145149
player = new Player({
146150
itemXml: itemXml,
147151
role: 'candidate',
152+
deliveryContext,
148153
security: {
149154
...getSecurityConfig(),
150155
urlPolicy: {
@@ -330,6 +335,7 @@
330335
typeset={typesetMathInElement}
331336
onResponseChange={handleResponseChange}
332337
{heuristicsConfig}
338+
{deliveryContext}
333339
{stimulusContent}
334340
/>
335341
</div>

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/certification/public-coverage-matrix.json

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "./public-coverage-matrix.schema.json",
33
"scope": "QTI 2.2 Advanced and QTI 3.0 Advanced DELIVERY public clean-room coverage",
4-
"updated": "2026-05-04",
4+
"updated": "2026-05-05",
55
"commands": [
66
{
77
"id": "item-clean-room",
@@ -26,6 +26,24 @@
2626
"packages/assessment-player/tests/conformance-qti30-advanced.test.ts"
2727
]
2828
},
29+
{
30+
"id": "package-clean-room",
31+
"description": "Clean-room IMS CP and QTI 3 shared content parsing coverage",
32+
"cwd": ".",
33+
"args": ["test", "packages/ims-cp-core/tests/qti3-shared-content.test.ts"]
34+
},
35+
{
36+
"id": "item-runtime-clean-room",
37+
"description": "Clean-room item runtime session, PNP, and catalog behavior coverage",
38+
"cwd": ".",
39+
"args": [
40+
"test",
41+
"packages/item-player/tests/core/item-session-lifecycle.test.ts",
42+
"packages/item-player/tests/core/pnp.test.ts",
43+
"packages/item-player/tests/core/delivery-context.test.ts",
44+
"packages/item-player/tests/core/glossaryTriggers.test.ts"
45+
]
46+
},
2947
{
3048
"id": "mathml-clean-room",
3149
"description": "MathML rendering coverage",
@@ -314,11 +332,11 @@
314332
"level": "Advanced",
315333
"feature": "I4 Shared Stimulus",
316334
"officialPath": "qti3.0/Advanced/I4 Shared Stimulus/",
317-
"publicTests": ["packages/item-player/src/extraction/extractors/conformance-qti3-advanced.test.ts", "apps/demo/tests/playwright/public-certification.pw.ts"],
335+
"publicTests": ["packages/item-player/src/extraction/extractors/conformance-qti3-advanced.test.ts", "packages/ims-cp-core/tests/qti3-shared-content.test.ts", "packages/item-player/tests/core/delivery-context.test.ts", "apps/demo/tests/playwright/public-certification.pw.ts"],
318336
"fixturePackages": [],
319337
"e2eRequired": true,
320-
"commandIds": ["item-clean-room", "browser-visible"],
321-
"notes": "Covers stimulus reference preservation and browser-visible shared stimulus rendering."
338+
"commandIds": ["item-clean-room", "package-clean-room", "item-runtime-clean-room", "browser-visible"],
339+
"notes": "Covers stimulus reference preservation, structured qti-assessment-stimulus parsing, stylesheet/catalog extraction, and item-runtime consumption of resolved stimulus/catalog context. Scoped stylesheet/catalog and browser-visible shared stimulus rendering evidence is tracked in docs/certification/qti3-remaining-parity-evidence.md."
322340
},
323341
{
324342
"id": "qti30-advanced-i17",
@@ -362,11 +380,11 @@
362380
"level": "Advanced",
363381
"feature": "A13 captions and A15 glossary",
364382
"officialPath": "qti3.0/Advanced/A13captions_A15glossary/",
365-
"publicTests": ["packages/item-player/src/extraction/extractors/conformance-qti3-advanced.test.ts", "apps/demo/tests/playwright/public-certification.pw.ts"],
383+
"publicTests": ["packages/item-player/src/extraction/extractors/conformance-qti3-advanced.test.ts", "packages/item-player/tests/core/pnp.test.ts", "packages/item-player/tests/core/glossaryTriggers.test.ts", "apps/demo/tests/playwright/public-certification.pw.ts"],
366384
"fixturePackages": [],
367385
"e2eRequired": true,
368-
"commandIds": ["item-clean-room", "browser-visible"],
369-
"notes": "Covers caption track preservation, catalog references, and glossary lookup."
386+
"commandIds": ["item-clean-room", "item-runtime-clean-room", "browser-visible"],
387+
"notes": "Covers caption track preservation plus item-runtime PNP/catalog coverage for glossary, keyword translation, and illustrated glossary triggers. Browser-visible catalog affordances, access-for-all, dynamic rebinding, host-routed support, and manual AT evidence is tracked in docs/certification/qti3-remaining-parity-evidence.md."
370388
},
371389
{
372390
"id": "qti30-advanced-s3-s4",
@@ -413,8 +431,8 @@
413431
"publicTests": ["packages/assessment-player/tests/conformance-qti30-advanced.test.ts"],
414432
"fixturePackages": [],
415433
"e2eRequired": false,
416-
"commandIds": ["assessment-clean-room"],
417-
"notes": "Covers qti-item-session-control and max-attempts=0 as unlimited."
434+
"commandIds": ["assessment-clean-room", "item-runtime-clean-room"],
435+
"notes": "Covers qti-item-session-control, max-attempts=0 as unlimited, and individual/simultaneous item session behavior. Remaining item-ref/section/testPart precedence evidence is tracked in docs/certification/qti3-remaining-parity-evidence.md."
418436
},
419437
{
420438
"id": "qti30-advanced-t12",
@@ -426,7 +444,7 @@
426444
"fixturePackages": [],
427445
"e2eRequired": true,
428446
"commandIds": ["assessment-clean-room", "browser-visible"],
429-
"notes": "Covers qti-assessment-section, qti-time-limits, allow-skipping, and qti-assessment-section-ref."
447+
"notes": "Covers qti-assessment-section, qti-time-limits, allow-skipping, and qti-assessment-section-ref. Remaining item/section/test timing precedence, restoration, expiry, and accessible timer UI evidence is tracked in docs/certification/qti3-remaining-parity-evidence.md."
430448
}
431449
]
432450
}

0 commit comments

Comments
 (0)