@conform-ed/qti-react 0.0.15 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -4
- package/dist/index.js +2486 -405
- package/dist/normalized-item.d.ts +7 -5
- package/dist/pnp.d.ts +115 -0
- package/dist/response-validity.d.ts +28 -0
- package/dist/rp/evaluate.d.ts +6 -1
- package/dist/rp/index.d.ts +2 -2
- package/dist/rp/interpreter.d.ts +7 -1
- package/dist/rp/lookup-table.d.ts +17 -0
- package/dist/rp/template-processing.d.ts +5 -0
- package/dist/rp/types.d.ts +71 -7
- package/dist/runtime.d.ts +95 -0
- package/dist/store.d.ts +47 -0
- package/dist/test/controller.d.ts +22 -0
- package/dist/test/index.d.ts +2 -1
- package/dist/test/results.d.ts +102 -0
- package/dist/test/session-store.d.ts +32 -0
- package/dist/test/types.d.ts +173 -5
- package/dist/types.d.ts +5 -0
- package/package.json +5 -1
- package/src/content-model.ts +44 -4
- package/src/index.ts +43 -1
- package/src/normalized-item.ts +106 -4
- package/src/pnp.ts +333 -0
- package/src/reference-skin/choice.ts +3 -0
- package/src/response-validity.ts +163 -0
- package/src/rp/evaluate.ts +280 -32
- package/src/rp/index.ts +5 -0
- package/src/rp/interpreter.ts +81 -1
- package/src/rp/lookup-table.ts +46 -0
- package/src/rp/template-processing.ts +41 -0
- package/src/rp/types.ts +75 -7
- package/src/runtime.ts +397 -20
- package/src/store.ts +146 -8
- package/src/test/controller.ts +856 -82
- package/src/test/index.ts +23 -0
- package/src/test/results.ts +378 -0
- package/src/test/session-store.ts +109 -1
- package/src/test/types.ts +172 -5
- package/src/types.ts +1 -0
- package/src/xspattern.d.ts +11 -0
package/src/runtime.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
v0ContentModel,
|
|
29
29
|
type ContentModel,
|
|
30
30
|
} from "./content-model";
|
|
31
|
+
import { resolveCatalogSupports, type CatalogView, type PnpView, type ResolvedCatalogSupport } from "./pnp";
|
|
32
|
+
import { collectInteractionConstraints } from "./response-validity";
|
|
31
33
|
import { collectRpIssues, collectTemplateIssues } from "./rp";
|
|
32
34
|
import type {
|
|
33
35
|
CustomOperatorImplementation,
|
|
@@ -47,6 +49,8 @@ export type { CapabilityIssue, CapabilityIssueType, CapabilityReport } from "./c
|
|
|
47
49
|
|
|
48
50
|
export interface XmlContentNode {
|
|
49
51
|
kind: "xml";
|
|
52
|
+
/** XML namespace URI; foreign vocabularies (SSML) are recognized by it. */
|
|
53
|
+
namespace?: string;
|
|
50
54
|
name: string;
|
|
51
55
|
value?: string;
|
|
52
56
|
attributes?: Record<string, unknown>;
|
|
@@ -69,6 +73,34 @@ export interface FeedbackView {
|
|
|
69
73
|
content?: readonly BodyNode[];
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/** An item's reference to a shared AssessmentStimulus document (§7.6). */
|
|
77
|
+
export interface AssessmentStimulusRefView {
|
|
78
|
+
readonly identifier: string;
|
|
79
|
+
readonly href: string;
|
|
80
|
+
readonly title?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Companion materials, structurally as normalized (calculators, rules, protractors, materials). */
|
|
84
|
+
export interface CompanionMaterialsView {
|
|
85
|
+
readonly calculators?: readonly Record<string, unknown>[];
|
|
86
|
+
readonly rules?: readonly Record<string, unknown>[];
|
|
87
|
+
readonly protractors?: readonly Record<string, unknown>[];
|
|
88
|
+
readonly digitalMaterials?: readonly {
|
|
89
|
+
readonly fileHref: string;
|
|
90
|
+
readonly label?: string;
|
|
91
|
+
readonly mimeType?: string;
|
|
92
|
+
readonly resourceIcon?: string;
|
|
93
|
+
}[];
|
|
94
|
+
readonly physicalMaterials?: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The resolved stimulus body, rendered through the same content walk as the item body. */
|
|
98
|
+
export interface StimulusContentView {
|
|
99
|
+
readonly content: readonly BodyNode[];
|
|
100
|
+
/** The stimulus document's catalogs (dormant alternative content, §5.29). */
|
|
101
|
+
readonly catalogs?: readonly CatalogView[];
|
|
102
|
+
}
|
|
103
|
+
|
|
72
104
|
export interface AssessmentItemView {
|
|
73
105
|
responseDeclarations: readonly ResponseDeclarationView[];
|
|
74
106
|
outcomeDeclarations?: readonly OutcomeDeclarationView[];
|
|
@@ -78,6 +110,15 @@ export interface AssessmentItemView {
|
|
|
78
110
|
/** QTI adaptive item: multiple attempts until completionStatus reaches "completed". */
|
|
79
111
|
adaptive?: boolean;
|
|
80
112
|
modalFeedbacks?: readonly FeedbackView[];
|
|
113
|
+
assessmentStimulusRefs?: readonly AssessmentStimulusRefView[];
|
|
114
|
+
/** Every catalog in the item (item-level and nested), pooled for idref resolution. */
|
|
115
|
+
catalogs?: readonly CatalogView[];
|
|
116
|
+
/**
|
|
117
|
+
* Companion materials (§2.13.1): "content props that provide key information to be
|
|
118
|
+
* used when answering an Item, e.g. a calculator, protractor, lookup chart". The
|
|
119
|
+
* runtime exposes them; the delivery platform owns the tools themselves.
|
|
120
|
+
*/
|
|
121
|
+
companionMaterials?: CompanionMaterialsView;
|
|
81
122
|
itemBody: { content?: BodyNode[] };
|
|
82
123
|
}
|
|
83
124
|
|
|
@@ -140,6 +181,12 @@ export interface InteractionRenderProps {
|
|
|
140
181
|
* interactions like PCI own their response state). Returns the unregister function.
|
|
141
182
|
*/
|
|
142
183
|
registerResponseCollector: (collector: () => ResponseValue | undefined) => () => void;
|
|
184
|
+
/**
|
|
185
|
+
* Render the active catalog supports for a skin-owned node's data-catalog-idref
|
|
186
|
+
* (e.g. a choice label) — the same resolution and presentation the core walk
|
|
187
|
+
* applies to generic flow nodes. Null when nothing is active.
|
|
188
|
+
*/
|
|
189
|
+
renderCatalogSupports: (catalogIdref: string | undefined) => ReactNode;
|
|
143
190
|
}
|
|
144
191
|
|
|
145
192
|
/** Per-kind render overrides a skin passes to `renderContent` for nodes it owns. */
|
|
@@ -166,6 +213,19 @@ export interface QtiRuntimeConfig {
|
|
|
166
213
|
* registered classes pass the capability gate; everything else stays unsupported.
|
|
167
214
|
*/
|
|
168
215
|
readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
|
|
216
|
+
/**
|
|
217
|
+
* Resolve an item's shared-stimulus reference to its normalized body content
|
|
218
|
+
* (synchronous by design, like the session store's resolveItem: load the package's
|
|
219
|
+
* stimuli before mounting; `stimulusContentFromNormalized` reshapes a normalized
|
|
220
|
+
* document). Unresolved refs are capability issues, never silent drops (ADR-0003).
|
|
221
|
+
*/
|
|
222
|
+
readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
|
|
223
|
+
/**
|
|
224
|
+
* Replaces the default rendering of an active catalog support (the note-role span
|
|
225
|
+
* appended beside the referenced content). The delivery engine owns presentation —
|
|
226
|
+
* tooltips, players, glossary panels — the runtime owns resolution.
|
|
227
|
+
*/
|
|
228
|
+
readonly renderCatalogSupport?: (support: ResolvedCatalogSupport, catalogIdref: string) => ReactNode;
|
|
169
229
|
}
|
|
170
230
|
|
|
171
231
|
export interface ItemRendererProps {
|
|
@@ -178,6 +238,32 @@ export interface ItemRendererProps {
|
|
|
178
238
|
store?: AttemptStore | undefined;
|
|
179
239
|
/** Clone seed for template processing; store it to replay the same clone. */
|
|
180
240
|
seed?: number | undefined;
|
|
241
|
+
/**
|
|
242
|
+
* The item-session state to render. `review` is read-only: "the candidate can
|
|
243
|
+
* review the qti-item-body along with the responses they gave, but cannot update
|
|
244
|
+
* or resubmit them". `solution` additionally swaps in the clone's resolved correct
|
|
245
|
+
* responses ("a way of entering the solution state"); the show-solution gate is
|
|
246
|
+
* the consumer's (effective ItemSessionControl). Default: "interact".
|
|
247
|
+
*/
|
|
248
|
+
mode?: ItemRenderMode | undefined;
|
|
249
|
+
/**
|
|
250
|
+
* Effective ItemSessionControl show-feedback; consulted only outside `interact`.
|
|
251
|
+
* `false` withholds modal and integrated feedback — visibility is then
|
|
252
|
+
* "determined by the default values of the outcome variables" — and is ignored
|
|
253
|
+
* for adaptive items, per spec.
|
|
254
|
+
*/
|
|
255
|
+
showFeedback?: boolean | undefined;
|
|
256
|
+
/**
|
|
257
|
+
* The candidate's AfA PNP. Activates the item's dormant catalog supports (§5.29):
|
|
258
|
+
* "A candidate's profile (or assessment program settings) will indicate whether the
|
|
259
|
+
* candidate should be presented any of the possible supports."
|
|
260
|
+
*/
|
|
261
|
+
pnp?: PnpView | undefined;
|
|
262
|
+
/**
|
|
263
|
+
* Supports in effect beyond the PNP's initial activation — program settings and
|
|
264
|
+
* candidate-toggled options (activate-as-option-set). Prohibited supports stay out.
|
|
265
|
+
*/
|
|
266
|
+
activeSupports?: readonly string[] | undefined;
|
|
181
267
|
// Rendered inside the same runtime context as the item body, after it. Lets a consumer
|
|
182
268
|
// drop controls (a Submit bar, a score panel) that call `useAttempt()` for this item —
|
|
183
269
|
// the attempt store is per-item and scoped to this subtree.
|
|
@@ -188,6 +274,10 @@ export interface ContentRendererProps {
|
|
|
188
274
|
nodes?: readonly BodyNode[] | undefined;
|
|
189
275
|
/** Values for printedVariable (and showHide-gated feedback) inside the content. */
|
|
190
276
|
outcomes?: Readonly<Record<string, OutcomeValue>> | undefined;
|
|
277
|
+
/** Catalogs referenced by this content (e.g. a test rubric block's catalogInfo). */
|
|
278
|
+
catalogs?: readonly CatalogView[] | undefined;
|
|
279
|
+
pnp?: PnpView | undefined;
|
|
280
|
+
activeSupports?: readonly string[] | undefined;
|
|
191
281
|
}
|
|
192
282
|
|
|
193
283
|
export interface QtiRuntime {
|
|
@@ -198,6 +288,13 @@ export interface QtiRuntime {
|
|
|
198
288
|
*/
|
|
199
289
|
ContentRenderer: ComponentType<ContentRendererProps>;
|
|
200
290
|
useAttempt: () => AttemptController;
|
|
291
|
+
/**
|
|
292
|
+
* The active supports resolved for a catalog idref — for skins whose own nodes
|
|
293
|
+
* carry data-catalog-idref (e.g. a choice label) and consumers building support
|
|
294
|
+
* UI (glossary panels, toggles). The core walk already decorates generic flow
|
|
295
|
+
* and block nodes; this is the same resolution by hand.
|
|
296
|
+
*/
|
|
297
|
+
useCatalogSupports: (catalogIdref: string | undefined) => readonly ResolvedCatalogSupport[];
|
|
201
298
|
/**
|
|
202
299
|
* The Capability Report for an item against this runtime's injected descriptors,
|
|
203
300
|
* skins, and content model. Consumers gate delivery on it (ADR-0003); the
|
|
@@ -214,9 +311,21 @@ export interface AttemptController extends AttemptSnapshot {
|
|
|
214
311
|
|
|
215
312
|
// ---------- Internal context ----------
|
|
216
313
|
|
|
314
|
+
/** The item-session states the renderer knows (ItemSessionControl review/solution). */
|
|
315
|
+
export type ItemRenderMode = "interact" | "review" | "solution";
|
|
316
|
+
|
|
217
317
|
interface RuntimeContextValue {
|
|
218
318
|
store: AttemptStore;
|
|
219
319
|
declarationsById: ReadonlyMap<string, ResponseDeclarationView>;
|
|
320
|
+
mode: ItemRenderMode;
|
|
321
|
+
/** show-feedback=false outside interact: modal + integrated feedback withheld. */
|
|
322
|
+
suppressFeedback: boolean;
|
|
323
|
+
/** The clone's resolved correct responses — what the solution state displays. */
|
|
324
|
+
solutionResponses: Readonly<Record<string, ResponseValue>>;
|
|
325
|
+
/** Declared outcome defaults — feedback visibility "as at the start of each attempt". */
|
|
326
|
+
defaultOutcomes: Readonly<Record<string, OutcomeValue>>;
|
|
327
|
+
/** Resolved active catalog supports by catalog id (§5.28 idref resolution). */
|
|
328
|
+
catalogSupports: ReadonlyMap<string, readonly ResolvedCatalogSupport[]>;
|
|
220
329
|
}
|
|
221
330
|
|
|
222
331
|
const RuntimeContext = createContext<RuntimeContextValue | null>(null);
|
|
@@ -294,6 +403,9 @@ function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): At
|
|
|
294
403
|
outcomes,
|
|
295
404
|
templateValues: {},
|
|
296
405
|
attemptCount: 1,
|
|
406
|
+
durationSeconds: null,
|
|
407
|
+
responseViolations: [],
|
|
408
|
+
correctResponses: {},
|
|
297
409
|
};
|
|
298
410
|
|
|
299
411
|
return {
|
|
@@ -303,6 +415,8 @@ function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): At
|
|
|
303
415
|
registerResponseCollector: () => () => {},
|
|
304
416
|
submit: () => [],
|
|
305
417
|
reset: () => {},
|
|
418
|
+
suspend: () => {},
|
|
419
|
+
resume: () => {},
|
|
306
420
|
};
|
|
307
421
|
}
|
|
308
422
|
|
|
@@ -322,7 +436,21 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
322
436
|
const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
|
|
323
437
|
const resolveAsset = config.assetResolver ?? ((href: string) => href);
|
|
324
438
|
|
|
439
|
+
/** SSML 1.1 (§2.13.2): aural annotations whose text renders transparently. */
|
|
440
|
+
const ssmlNamespace = "http://www.w3.org/2001/10/synthesis";
|
|
441
|
+
|
|
325
442
|
function renderFlow(node: XmlContentNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
|
|
443
|
+
// SSML wraps visual text for speech synthesis (alias substitutions, prosody);
|
|
444
|
+
// rendering the element as HTML would misread it (ssml:sub is not a subscript).
|
|
445
|
+
// The annotated text passes through; the aural semantics belong to TTS hosts.
|
|
446
|
+
if (node.namespace === ssmlNamespace) {
|
|
447
|
+
return createElement(
|
|
448
|
+
Fragment,
|
|
449
|
+
{ key },
|
|
450
|
+
node.value ?? node.children?.map((child, index) => renderNode(child, index, overrides, inMath)),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
326
454
|
const isMath = inMath || node.name === model.mathRoot;
|
|
327
455
|
|
|
328
456
|
if (!isMath && !isAllowedFlowElement(model, node.name)) {
|
|
@@ -361,7 +489,96 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
361
489
|
);
|
|
362
490
|
}
|
|
363
491
|
|
|
364
|
-
|
|
492
|
+
/** The catalog reference a node carries: data-catalog-idref on generic xml nodes, the structured field on QTI nodes. */
|
|
493
|
+
function catalogIdrefOf(node: BodyNode): string | undefined {
|
|
494
|
+
const value =
|
|
495
|
+
node.kind === "xml"
|
|
496
|
+
? (node as XmlContentNode).attributes?.["data-catalog-idref"]
|
|
497
|
+
: (node as { dataCatalogIdref?: unknown }).dataCatalogIdref;
|
|
498
|
+
|
|
499
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The default support presentation: a note beside the referenced content. */
|
|
503
|
+
function renderSupportDefault(
|
|
504
|
+
support: ResolvedCatalogSupport,
|
|
505
|
+
catalogIdref: string,
|
|
506
|
+
key: number,
|
|
507
|
+
overrides?: NodeOverrides,
|
|
508
|
+
): ReactNode {
|
|
509
|
+
return createElement(
|
|
510
|
+
"span",
|
|
511
|
+
{
|
|
512
|
+
key: `support-${key}`,
|
|
513
|
+
role: "note",
|
|
514
|
+
"data-qti-catalog-idref": catalogIdref,
|
|
515
|
+
"data-qti-support": support.support,
|
|
516
|
+
...(support.xmlLang !== undefined ? { lang: support.xmlLang } : {}),
|
|
517
|
+
},
|
|
518
|
+
support.content?.map((child, index) => renderNode(child, index, overrides)),
|
|
519
|
+
// File-backed alternatives default to an accessible link through the Asset
|
|
520
|
+
// Resolver; players and panels are the delivery engine's (renderCatalogSupport).
|
|
521
|
+
support.fileHrefs?.map((file, index) =>
|
|
522
|
+
createElement(
|
|
523
|
+
"a",
|
|
524
|
+
{ key: `file-${index}`, href: resolveAsset(file.href), type: file.mimeType, "data-qti-support-file": true },
|
|
525
|
+
support.support,
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Renders a catalog-referencing node: the authored content as-is, then each active
|
|
533
|
+
* support's resolved alternative content. Dormant content stays dormant — no
|
|
534
|
+
* resolved supports means the original alone.
|
|
535
|
+
*/
|
|
536
|
+
function CatalogSupportHost({
|
|
537
|
+
catalogIdref,
|
|
538
|
+
node,
|
|
539
|
+
overrides,
|
|
540
|
+
inMath,
|
|
541
|
+
}: {
|
|
542
|
+
catalogIdref: string;
|
|
543
|
+
node: BodyNode;
|
|
544
|
+
overrides?: NodeOverrides | undefined;
|
|
545
|
+
inMath: boolean;
|
|
546
|
+
}): ReactNode {
|
|
547
|
+
const { catalogSupports } = useRuntimeContext();
|
|
548
|
+
const supports = catalogSupports.get(catalogIdref) ?? [];
|
|
549
|
+
const original = renderNode(node, 0, overrides, inMath, true);
|
|
550
|
+
|
|
551
|
+
if (!supports.length) {
|
|
552
|
+
return original;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return createElement(
|
|
556
|
+
Fragment,
|
|
557
|
+
null,
|
|
558
|
+
original,
|
|
559
|
+
supports.map((support, index) =>
|
|
560
|
+
config.renderCatalogSupport
|
|
561
|
+
? createElement(Fragment, { key: `support-${index}` }, config.renderCatalogSupport(support, catalogIdref))
|
|
562
|
+
: renderSupportDefault(support, catalogIdref, index, overrides),
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function renderNode(
|
|
568
|
+
node: BodyNode,
|
|
569
|
+
key: number,
|
|
570
|
+
overrides?: NodeOverrides,
|
|
571
|
+
inMath = false,
|
|
572
|
+
skipCatalog = false,
|
|
573
|
+
): ReactNode {
|
|
574
|
+
if (!skipCatalog) {
|
|
575
|
+
const catalogIdref = catalogIdrefOf(node);
|
|
576
|
+
|
|
577
|
+
if (catalogIdref !== undefined) {
|
|
578
|
+
return createElement(CatalogSupportHost, { key, catalogIdref, node, overrides, inMath });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
365
582
|
const override = overrides?.[node.kind];
|
|
366
583
|
|
|
367
584
|
if (override) {
|
|
@@ -439,11 +656,17 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
439
656
|
element: "span" | "div";
|
|
440
657
|
overrides?: NodeOverrides | undefined;
|
|
441
658
|
}): ReactNode {
|
|
442
|
-
const { store } = useRuntimeContext();
|
|
659
|
+
const { store, suppressFeedback, defaultOutcomes } = useRuntimeContext();
|
|
443
660
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
661
|
+
// Withheld feedback shows "the version of the qti-item-body displayed to the
|
|
662
|
+
// candidate at the start of each attempt … with the visibility of any integrated
|
|
663
|
+
// feedback determined by the default values of the outcome variables and not the
|
|
664
|
+
// values … updated by the invocation of response processing".
|
|
665
|
+
const outcome = suppressFeedback
|
|
666
|
+
? (defaultOutcomes[feedback.outcomeIdentifier] ?? null)
|
|
667
|
+
: (snapshot.outcomes[feedback.outcomeIdentifier] ?? null);
|
|
668
|
+
|
|
669
|
+
if (!feedbackVisible(outcome, feedback, suppressFeedback || snapshot.submitted)) {
|
|
447
670
|
return null;
|
|
448
671
|
}
|
|
449
672
|
|
|
@@ -488,11 +711,14 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
488
711
|
}
|
|
489
712
|
|
|
490
713
|
function ModalFeedbackHost({ feedback }: { feedback: FeedbackView }): ReactNode {
|
|
491
|
-
const { store } = useRuntimeContext();
|
|
714
|
+
const { store, suppressFeedback } = useRuntimeContext();
|
|
492
715
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
493
716
|
const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
|
|
494
717
|
|
|
495
|
-
|
|
718
|
+
// "If it is 'false' then feedback is not shown. This includes both Modal
|
|
719
|
+
// Feedback and Integrated Feedback even if the candidate has access to the
|
|
720
|
+
// review state."
|
|
721
|
+
if (suppressFeedback || !feedbackVisible(outcome, feedback, snapshot.submitted)) {
|
|
496
722
|
return null;
|
|
497
723
|
}
|
|
498
724
|
|
|
@@ -504,14 +730,24 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
504
730
|
}
|
|
505
731
|
|
|
506
732
|
function InteractionHost({ node }: { node: InteractionNode }): ReactNode {
|
|
507
|
-
const { store, declarationsById } = useRuntimeContext();
|
|
733
|
+
const { store, declarationsById, mode, suppressFeedback, solutionResponses } = useRuntimeContext();
|
|
508
734
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
509
735
|
|
|
510
736
|
const responseIdentifier = node.responseIdentifier;
|
|
511
737
|
const declaration = declarationsById.get(responseIdentifier);
|
|
512
738
|
const cardinality: Cardinality = declaration?.cardinality ?? "single";
|
|
513
|
-
|
|
514
|
-
|
|
739
|
+
// The solution state displays the clone's correct response in place of the
|
|
740
|
+
// candidate's ("a way of entering the solution state").
|
|
741
|
+
const value =
|
|
742
|
+
mode === "solution"
|
|
743
|
+
? (solutionResponses[responseIdentifier] ?? null)
|
|
744
|
+
: (snapshot.responses[responseIdentifier] ?? null);
|
|
745
|
+
// Review and solution are read-only: "can review the qti-item-body along with
|
|
746
|
+
// the responses they gave, but cannot update or resubmit them".
|
|
747
|
+
const disabled = snapshot.submitted || mode !== "interact";
|
|
748
|
+
// Correctness chrome is feedback: shown after a submitted attempt unless
|
|
749
|
+
// show-feedback withholds it — and always in the solution state (its point).
|
|
750
|
+
const revealed = mode === "solution" || (snapshot.submitted && !suppressFeedback);
|
|
515
751
|
|
|
516
752
|
const answered =
|
|
517
753
|
value !== null &&
|
|
@@ -520,12 +756,16 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
520
756
|
|
|
521
757
|
let status: InteractionStatus = answered ? "answered" : "unanswered";
|
|
522
758
|
|
|
523
|
-
if (
|
|
759
|
+
if (revealed) {
|
|
524
760
|
const scored = snapshot.scores.find((score) => score.identifier === responseIdentifier);
|
|
525
|
-
status = scored?.correct ? "correct" : "incorrect";
|
|
761
|
+
status = mode === "solution" || scored?.correct ? "correct" : "incorrect";
|
|
526
762
|
}
|
|
527
763
|
|
|
528
764
|
const setValue = (next: ResponseValue): void => {
|
|
765
|
+
if (disabled) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
529
769
|
store.setResponse(responseIdentifier, next);
|
|
530
770
|
};
|
|
531
771
|
|
|
@@ -534,7 +774,7 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
534
774
|
|
|
535
775
|
let status: OptionStatus = selected ? "selected" : "idle";
|
|
536
776
|
|
|
537
|
-
if (
|
|
777
|
+
if (revealed) {
|
|
538
778
|
if (isCorrectOption(declaration, optionIdentifier)) {
|
|
539
779
|
status = "correct";
|
|
540
780
|
} else if (selected) {
|
|
@@ -574,6 +814,21 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
574
814
|
const renderContent = (nodes: readonly BodyNode[] | undefined, overrides?: NodeOverrides): ReactNode =>
|
|
575
815
|
nodes ? nodes.map((child, index) => renderNode(child, index, overrides)) : null;
|
|
576
816
|
|
|
817
|
+
const { catalogSupports } = useRuntimeContext();
|
|
818
|
+
const renderCatalogSupportsForSkin = (catalogIdref: string | undefined): ReactNode => {
|
|
819
|
+
const supports = catalogIdref !== undefined ? (catalogSupports.get(catalogIdref) ?? []) : [];
|
|
820
|
+
|
|
821
|
+
if (!supports.length) {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return supports.map((support, index) =>
|
|
826
|
+
config.renderCatalogSupport
|
|
827
|
+
? createElement(Fragment, { key: `support-${index}` }, config.renderCatalogSupport(support, catalogIdref!))
|
|
828
|
+
: renderSupportDefault(support, catalogIdref!, index),
|
|
829
|
+
);
|
|
830
|
+
};
|
|
831
|
+
|
|
577
832
|
const Skin = config.skin[node.kind];
|
|
578
833
|
|
|
579
834
|
if (!Skin) {
|
|
@@ -585,8 +840,9 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
585
840
|
responseIdentifier,
|
|
586
841
|
value,
|
|
587
842
|
setValue,
|
|
843
|
+
renderCatalogSupports: renderCatalogSupportsForSkin,
|
|
588
844
|
disabled,
|
|
589
|
-
showFeedback:
|
|
845
|
+
showFeedback: revealed,
|
|
590
846
|
status,
|
|
591
847
|
getOptionProps,
|
|
592
848
|
renderContent,
|
|
@@ -599,18 +855,41 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
599
855
|
});
|
|
600
856
|
}
|
|
601
857
|
|
|
602
|
-
function ContentRenderer({ nodes, outcomes }: ContentRendererProps): ReactNode {
|
|
858
|
+
function ContentRenderer({ nodes, outcomes, catalogs, pnp, activeSupports }: ContentRendererProps): ReactNode {
|
|
603
859
|
const store = useMemo(() => createStaticStore(outcomes ?? {}), [outcomes]);
|
|
604
860
|
const declarationsById = useMemo(() => new Map<string, ResponseDeclarationView>(), []);
|
|
861
|
+
const catalogSupports = useMemo(
|
|
862
|
+
() => resolveCatalogSupports({ catalogs, pnp, activeSupports }).byCatalogId,
|
|
863
|
+
[catalogs, pnp, activeSupports],
|
|
864
|
+
);
|
|
605
865
|
|
|
606
866
|
return createElement(
|
|
607
867
|
RuntimeContext.Provider,
|
|
608
|
-
{
|
|
868
|
+
{
|
|
869
|
+
value: {
|
|
870
|
+
store,
|
|
871
|
+
declarationsById,
|
|
872
|
+
mode: "interact",
|
|
873
|
+
suppressFeedback: false,
|
|
874
|
+
solutionResponses: {},
|
|
875
|
+
defaultOutcomes: {},
|
|
876
|
+
catalogSupports,
|
|
877
|
+
},
|
|
878
|
+
},
|
|
609
879
|
nodes?.map((node, index) => renderNode(node, index)),
|
|
610
880
|
);
|
|
611
881
|
}
|
|
612
882
|
|
|
613
|
-
function ItemRenderer({
|
|
883
|
+
function ItemRenderer({
|
|
884
|
+
item,
|
|
885
|
+
store: externalStore,
|
|
886
|
+
seed,
|
|
887
|
+
mode = "interact",
|
|
888
|
+
showFeedback,
|
|
889
|
+
pnp,
|
|
890
|
+
activeSupports,
|
|
891
|
+
children,
|
|
892
|
+
}: ItemRendererProps): ReactNode {
|
|
614
893
|
const store = useMemo(
|
|
615
894
|
() =>
|
|
616
895
|
externalStore ??
|
|
@@ -626,6 +905,7 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
626
905
|
seed,
|
|
627
906
|
normalization: config.normalization,
|
|
628
907
|
customOperators: config.customOperators,
|
|
908
|
+
constraints: collectInteractionConstraints(item.itemBody.content),
|
|
629
909
|
},
|
|
630
910
|
),
|
|
631
911
|
[item, externalStore, seed],
|
|
@@ -636,12 +916,83 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
636
916
|
[item],
|
|
637
917
|
);
|
|
638
918
|
|
|
919
|
+
// "the setting of show-feedback should be ignored for adaptive items when
|
|
920
|
+
// allow-review is 'true'. When in the review state, the final values of the
|
|
921
|
+
// outcome variables should be used."
|
|
922
|
+
const suppressFeedback = mode !== "interact" && showFeedback === false && item.adaptive !== true;
|
|
923
|
+
|
|
924
|
+
// Declared outcome defaults, flat-encoded like snapshot outcomes: withheld
|
|
925
|
+
// feedback re-evaluates against these ("as at the start of each attempt").
|
|
926
|
+
const defaultOutcomes = useMemo(() => {
|
|
927
|
+
const defaults: Record<string, OutcomeValue> = {};
|
|
928
|
+
|
|
929
|
+
for (const declaration of item.outcomeDeclarations ?? []) {
|
|
930
|
+
const values = declaration.defaultValue?.values;
|
|
931
|
+
|
|
932
|
+
if (values !== undefined) {
|
|
933
|
+
defaults[declaration.identifier] =
|
|
934
|
+
declaration.cardinality === "single"
|
|
935
|
+
? ((values[0]?.value ?? null) as OutcomeValue)
|
|
936
|
+
: (values.map((entry) => entry.value) as never);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return defaults;
|
|
941
|
+
}, [item]);
|
|
942
|
+
|
|
943
|
+
// Shared stimulus content renders before the body through the same sanitized
|
|
944
|
+
// walk; an unresolved ref gets the explicit placeholder (ADR-0003 backstop —
|
|
945
|
+
// canDeliver already reported it).
|
|
946
|
+
const stimulusViews = (item.assessmentStimulusRefs ?? []).map((ref) => ({
|
|
947
|
+
ref,
|
|
948
|
+
view: config.resolveStimulus?.(ref) ?? null,
|
|
949
|
+
}));
|
|
950
|
+
|
|
951
|
+
// Catalog ids are document-unique; the item's pool and the resolved stimuli's
|
|
952
|
+
// pool resolve together so idrefs reach across both (§5.28).
|
|
953
|
+
const catalogSupports = resolveCatalogSupports({
|
|
954
|
+
catalogs: [...(item.catalogs ?? []), ...stimulusViews.flatMap(({ view }) => view?.catalogs ?? [])],
|
|
955
|
+
pnp,
|
|
956
|
+
activeSupports,
|
|
957
|
+
}).byCatalogId;
|
|
958
|
+
|
|
959
|
+
const stimuli = stimulusViews.map(({ ref, view }, index) =>
|
|
960
|
+
createElement(
|
|
961
|
+
"section",
|
|
962
|
+
{ key: `stimulus-${index}`, "data-qti-stimulus": ref.identifier },
|
|
963
|
+
view === null
|
|
964
|
+
? createElement(
|
|
965
|
+
"div",
|
|
966
|
+
{ role: "note", "data-qti-unsupported": "assessmentStimulusRef" },
|
|
967
|
+
`This content requires a shared stimulus (${ref.href}) this runtime cannot resolve.`,
|
|
968
|
+
)
|
|
969
|
+
: view.content.map((node, nodeIndex) => renderNode(node, nodeIndex)),
|
|
970
|
+
),
|
|
971
|
+
);
|
|
972
|
+
|
|
639
973
|
const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
|
|
640
974
|
const modals = (item.modalFeedbacks ?? []).map((feedback, index) =>
|
|
641
975
|
createElement(ModalFeedbackHost, { key: index, feedback }),
|
|
642
976
|
);
|
|
643
977
|
|
|
644
|
-
return createElement(
|
|
978
|
+
return createElement(
|
|
979
|
+
RuntimeContext.Provider,
|
|
980
|
+
{
|
|
981
|
+
value: {
|
|
982
|
+
store,
|
|
983
|
+
declarationsById,
|
|
984
|
+
mode,
|
|
985
|
+
suppressFeedback,
|
|
986
|
+
solutionResponses: store.getSnapshot().correctResponses,
|
|
987
|
+
defaultOutcomes,
|
|
988
|
+
catalogSupports,
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
stimuli,
|
|
992
|
+
body,
|
|
993
|
+
modals,
|
|
994
|
+
children,
|
|
995
|
+
);
|
|
645
996
|
}
|
|
646
997
|
|
|
647
998
|
function useAttempt(): AttemptController {
|
|
@@ -655,6 +1006,14 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
655
1006
|
};
|
|
656
1007
|
}
|
|
657
1008
|
|
|
1009
|
+
const noSupports: readonly ResolvedCatalogSupport[] = [];
|
|
1010
|
+
|
|
1011
|
+
function useCatalogSupports(catalogIdref: string | undefined): readonly ResolvedCatalogSupport[] {
|
|
1012
|
+
const { catalogSupports } = useRuntimeContext();
|
|
1013
|
+
|
|
1014
|
+
return catalogIdref !== undefined ? (catalogSupports.get(catalogIdref) ?? noSupports) : noSupports;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
658
1017
|
function canDeliver(item: AssessmentItemView): CapabilityReport {
|
|
659
1018
|
const issues: CapabilityIssue[] = [];
|
|
660
1019
|
const seen = new Set<string>();
|
|
@@ -740,6 +1099,21 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
740
1099
|
walk(node);
|
|
741
1100
|
}
|
|
742
1101
|
|
|
1102
|
+
// Shared stimulus refs must resolve to be deliverable; resolved content passes
|
|
1103
|
+
// through the same content-model gate as the body.
|
|
1104
|
+
for (const ref of item.assessmentStimulusRefs ?? []) {
|
|
1105
|
+
const stimulus = config.resolveStimulus?.(ref) ?? null;
|
|
1106
|
+
|
|
1107
|
+
if (stimulus === null) {
|
|
1108
|
+
report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
for (const node of stimulus.content) {
|
|
1113
|
+
walk(node);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
743
1117
|
for (const feedback of item.modalFeedbacks ?? []) {
|
|
744
1118
|
for (const child of feedback.content ?? []) {
|
|
745
1119
|
walk(child);
|
|
@@ -748,7 +1122,10 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
748
1122
|
|
|
749
1123
|
const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
|
|
750
1124
|
|
|
751
|
-
for (const issue of collectRpIssues(item.responseProcessing, {
|
|
1125
|
+
for (const issue of collectRpIssues(item.responseProcessing, {
|
|
1126
|
+
customOperatorClasses,
|
|
1127
|
+
outcomeDeclarations: item.outcomeDeclarations ?? [],
|
|
1128
|
+
})) {
|
|
752
1129
|
report(issue);
|
|
753
1130
|
}
|
|
754
1131
|
|
|
@@ -759,5 +1136,5 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
759
1136
|
return { deliverable: issues.length === 0, issues };
|
|
760
1137
|
}
|
|
761
1138
|
|
|
762
|
-
return { ItemRenderer, ContentRenderer, useAttempt, canDeliver };
|
|
1139
|
+
return { ItemRenderer, ContentRenderer, useAttempt, useCatalogSupports, canDeliver };
|
|
763
1140
|
}
|