@conform-ed/qti-react 0.0.12 → 0.0.14
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/capability.d.ts +17 -0
- package/dist/content-model.d.ts +42 -0
- package/dist/graphic.d.ts +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +4556 -212
- package/dist/interactions/associate.d.ts +2 -0
- package/dist/interactions/choice.d.ts +2 -0
- package/dist/interactions/drawing.d.ts +2 -0
- package/dist/interactions/end-attempt.d.ts +2 -0
- package/dist/interactions/extended-text.d.ts +2 -0
- package/dist/interactions/gap-match.d.ts +2 -0
- package/dist/interactions/graphic.d.ts +13 -0
- package/dist/interactions/hottext.d.ts +2 -0
- package/dist/interactions/index.d.ts +18 -0
- package/dist/interactions/inline-choice.d.ts +2 -0
- package/dist/interactions/match.d.ts +2 -0
- package/dist/interactions/media.d.ts +2 -0
- package/dist/interactions/order.d.ts +2 -0
- package/dist/interactions/slider.d.ts +2 -0
- package/dist/interactions/text-entry.d.ts +2 -0
- package/dist/interactions/upload.d.ts +2 -0
- package/dist/normalized-item.d.ts +30 -0
- package/dist/pci/index.d.ts +6 -0
- package/dist/pci/interaction.d.ts +8 -0
- package/dist/pci/markup.d.ts +10 -0
- package/dist/pci/mount.d.ts +50 -0
- package/dist/pci/registry.d.ts +53 -0
- package/dist/pci/response.d.ts +11 -0
- package/dist/pci/skin.d.ts +12 -0
- package/dist/reference-skin/associate.d.ts +8 -0
- package/dist/reference-skin/choice.d.ts +8 -0
- package/dist/reference-skin/content.d.ts +6 -0
- package/dist/reference-skin/drawing.d.ts +9 -0
- package/dist/reference-skin/end-attempt.d.ts +7 -0
- package/dist/reference-skin/extended-text.d.ts +6 -0
- package/dist/reference-skin/gap-match.d.ts +8 -0
- package/dist/reference-skin/graphic-associate.d.ts +8 -0
- package/dist/reference-skin/graphic-base.d.ts +39 -0
- package/dist/reference-skin/graphic-gap-match.d.ts +8 -0
- package/dist/reference-skin/graphic-order.d.ts +8 -0
- package/dist/reference-skin/hotspot.d.ts +8 -0
- package/dist/reference-skin/hottext.d.ts +8 -0
- package/dist/reference-skin/index.d.ts +30 -0
- package/dist/reference-skin/inline-choice.d.ts +7 -0
- package/dist/reference-skin/match.d.ts +8 -0
- package/dist/reference-skin/media.d.ts +9 -0
- package/dist/reference-skin/order.d.ts +8 -0
- package/dist/reference-skin/position-object.d.ts +9 -0
- package/dist/reference-skin/select-point.d.ts +8 -0
- package/dist/reference-skin/slider.d.ts +8 -0
- package/dist/reference-skin/text-entry.d.ts +6 -0
- package/dist/reference-skin/upload.d.ts +8 -0
- package/dist/response-processing.d.ts +48 -0
- package/dist/rp/evaluate.d.ts +35 -0
- package/dist/rp/index.d.ts +4 -0
- package/dist/rp/interpreter.d.ts +15 -0
- package/dist/rp/template-processing.d.ts +49 -0
- package/dist/rp/templates.d.ts +8 -0
- package/dist/rp/types.d.ts +158 -0
- package/dist/rp/values.d.ts +27 -0
- package/dist/runtime.d.ts +164 -0
- package/dist/store.d.ts +61 -0
- package/dist/test/controller.d.ts +11 -0
- package/dist/test/index.d.ts +3 -0
- package/dist/test/session-store.d.ts +46 -0
- package/dist/test/types.d.ts +194 -0
- package/dist/types.d.ts +58 -0
- package/package.json +8 -6
- package/src/capability.ts +24 -0
- package/src/content-model.ts +104 -5
- package/src/graphic.ts +103 -0
- package/src/index.ts +139 -3
- package/src/interactions/associate.ts +22 -0
- package/src/interactions/choice.ts +2 -2
- package/src/interactions/drawing.ts +24 -0
- package/src/interactions/end-attempt.ts +19 -0
- package/src/interactions/extended-text.ts +21 -0
- package/src/interactions/gap-match.ts +22 -0
- package/src/interactions/graphic.ts +104 -0
- package/src/interactions/hottext.ts +21 -0
- package/src/interactions/index.ts +57 -3
- package/src/interactions/inline-choice.ts +2 -2
- package/src/interactions/match.ts +27 -0
- package/src/interactions/media.ts +24 -0
- package/src/interactions/order.ts +21 -0
- package/src/interactions/slider.ts +24 -0
- package/src/interactions/text-entry.ts +2 -2
- package/src/interactions/upload.ts +19 -0
- package/src/normalized-item.ts +563 -0
- package/src/pci/index.ts +22 -0
- package/src/pci/interaction.ts +42 -0
- package/src/pci/markup.ts +102 -0
- package/src/pci/mount.ts +134 -0
- package/src/pci/registry.ts +240 -0
- package/src/pci/response.ts +138 -0
- package/src/pci/skin.ts +86 -0
- package/src/reference-skin/associate.ts +98 -0
- package/src/reference-skin/choice.ts +44 -0
- package/src/reference-skin/content.ts +30 -0
- package/src/reference-skin/drawing.ts +160 -0
- package/src/reference-skin/end-attempt.ts +27 -0
- package/src/reference-skin/extended-text.ts +35 -0
- package/src/reference-skin/gap-match.ts +69 -0
- package/src/reference-skin/graphic-associate.ts +123 -0
- package/src/reference-skin/graphic-base.ts +142 -0
- package/src/reference-skin/graphic-gap-match.ts +143 -0
- package/src/reference-skin/graphic-order.ts +76 -0
- package/src/reference-skin/hotspot.ts +43 -0
- package/src/reference-skin/hottext.ts +42 -0
- package/src/reference-skin/index.ts +74 -0
- package/src/reference-skin/inline-choice.ts +42 -0
- package/src/reference-skin/match.ts +80 -0
- package/src/reference-skin/media.ts +74 -0
- package/src/reference-skin/order.ts +79 -0
- package/src/reference-skin/position-object.ts +84 -0
- package/src/reference-skin/select-point.ts +87 -0
- package/src/reference-skin/slider.ts +41 -0
- package/src/reference-skin/text-entry.ts +31 -0
- package/src/reference-skin/upload.ts +46 -0
- package/src/response-processing.ts +178 -29
- package/src/rp/evaluate.ts +827 -0
- package/src/rp/index.ts +30 -0
- package/src/rp/interpreter.ts +254 -0
- package/src/rp/template-processing.ts +290 -0
- package/src/rp/templates.ts +190 -0
- package/src/rp/types.ts +167 -0
- package/src/rp/values.ts +211 -0
- package/src/runtime.ts +476 -28
- package/src/store.ts +161 -5
- package/src/test/controller.ts +809 -0
- package/src/test/index.ts +25 -0
- package/src/test/session-store.ts +243 -0
- package/src/test/types.ts +203 -0
- package/src/types.ts +27 -1
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The headless runtime (ADR-
|
|
2
|
+
* The headless runtime (ADR-0001): a factory that assembles a QTI item renderer from an
|
|
3
3
|
* injected set of interaction descriptors plus a skin registry. The kind-union is the
|
|
4
4
|
* injected set — no global registry, no module augmentation. The core owns response
|
|
5
5
|
* state and a11y wiring; skins are controlled components.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
Fragment,
|
|
12
13
|
createContext,
|
|
13
14
|
createElement,
|
|
14
15
|
useContext,
|
|
@@ -19,16 +20,29 @@ import {
|
|
|
19
20
|
} from "react";
|
|
20
21
|
import type { ZodType } from "zod";
|
|
21
22
|
|
|
23
|
+
import type { CapabilityIssue, CapabilityReport } from "./capability";
|
|
22
24
|
import {
|
|
23
25
|
isAllowedFlowElement,
|
|
24
|
-
isInteractionKind,
|
|
25
26
|
sanitizeAttributes,
|
|
27
|
+
sanitizeMathAttributes,
|
|
26
28
|
v0ContentModel,
|
|
27
29
|
type ContentModel,
|
|
28
30
|
} from "./content-model";
|
|
31
|
+
import { collectRpIssues, collectTemplateIssues } from "./rp";
|
|
32
|
+
import type {
|
|
33
|
+
CustomOperatorImplementation,
|
|
34
|
+
OutcomeDeclarationView,
|
|
35
|
+
OutcomeValue,
|
|
36
|
+
ResponseNormalization,
|
|
37
|
+
ResponseProcessingView,
|
|
38
|
+
TemplateDeclarationView,
|
|
39
|
+
TemplateProcessingView,
|
|
40
|
+
} from "./rp";
|
|
29
41
|
import { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "./store";
|
|
30
42
|
import type { Cardinality, ResponseDeclarationView, ResponseValue, ScoreResult } from "./types";
|
|
31
43
|
|
|
44
|
+
export type { CapabilityIssue, CapabilityIssueType, CapabilityReport } from "./capability";
|
|
45
|
+
|
|
32
46
|
// ---------- Node views (structural; validated upstream by @conform-ed/contracts) ----------
|
|
33
47
|
|
|
34
48
|
export interface XmlContentNode {
|
|
@@ -47,8 +61,23 @@ export interface InteractionNode {
|
|
|
47
61
|
|
|
48
62
|
export type BodyNode = XmlContentNode | InteractionNode | { kind: string; value?: string; children?: BodyNode[] };
|
|
49
63
|
|
|
64
|
+
/** A feedback element's view: feedbackInline/feedbackBlock in the body, or modalFeedback. */
|
|
65
|
+
export interface FeedbackView {
|
|
66
|
+
outcomeIdentifier: string;
|
|
67
|
+
identifier: string;
|
|
68
|
+
showHide?: "show" | "hide";
|
|
69
|
+
content?: readonly BodyNode[];
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
export interface AssessmentItemView {
|
|
51
73
|
responseDeclarations: readonly ResponseDeclarationView[];
|
|
74
|
+
outcomeDeclarations?: readonly OutcomeDeclarationView[];
|
|
75
|
+
responseProcessing?: ResponseProcessingView;
|
|
76
|
+
templateDeclarations?: readonly TemplateDeclarationView[];
|
|
77
|
+
templateProcessing?: TemplateProcessingView;
|
|
78
|
+
/** QTI adaptive item: multiple attempts until completionStatus reaches "completed". */
|
|
79
|
+
adaptive?: boolean;
|
|
80
|
+
modalFeedbacks?: readonly FeedbackView[];
|
|
52
81
|
itemBody: { content?: BodyNode[] };
|
|
53
82
|
}
|
|
54
83
|
|
|
@@ -83,7 +112,7 @@ export interface OptionProps {
|
|
|
83
112
|
onClick: () => void;
|
|
84
113
|
}
|
|
85
114
|
|
|
86
|
-
/** Controlled props every interaction skin receives by default (ADR-
|
|
115
|
+
/** Controlled props every interaction skin receives by default (ADR-0001). */
|
|
87
116
|
export interface InteractionRenderProps {
|
|
88
117
|
node: InteractionNode;
|
|
89
118
|
responseIdentifier: string;
|
|
@@ -96,10 +125,26 @@ export interface InteractionRenderProps {
|
|
|
96
125
|
/** Whole-interaction status (drives feedback for option-less interactions). */
|
|
97
126
|
status: InteractionStatus;
|
|
98
127
|
getOptionProps: (optionIdentifier: string) => OptionProps;
|
|
99
|
-
/**
|
|
100
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Render body fragments (prompt, choice labels) through the core allowlist walk.
|
|
130
|
+
* `overrides` lets a skin take over specific node kinds nested anywhere in the
|
|
131
|
+
* fragment (e.g. `hottext`, `gap`) while the core keeps walking everything else.
|
|
132
|
+
*/
|
|
133
|
+
renderContent: (nodes: readonly BodyNode[] | undefined, overrides?: NodeOverrides) => ReactNode;
|
|
134
|
+
/** The runtime's Asset Resolver (identity when none configured) for skin-owned media. */
|
|
135
|
+
resolveAsset: (href: string) => string;
|
|
136
|
+
/** Set this interaction's response to true and submit the attempt (endAttemptInteraction). */
|
|
137
|
+
endAttempt: () => void;
|
|
138
|
+
/**
|
|
139
|
+
* Register a submit-time response collector for this interaction (imperative
|
|
140
|
+
* interactions like PCI own their response state). Returns the unregister function.
|
|
141
|
+
*/
|
|
142
|
+
registerResponseCollector: (collector: () => ResponseValue | undefined) => () => void;
|
|
101
143
|
}
|
|
102
144
|
|
|
145
|
+
/** Per-kind render overrides a skin passes to `renderContent` for nodes it owns. */
|
|
146
|
+
export type NodeOverrides = Readonly<Record<string, (node: BodyNode, key: number) => ReactNode>>;
|
|
147
|
+
|
|
103
148
|
export type InteractionSkin = ComponentType<InteractionRenderProps>;
|
|
104
149
|
export type SkinRegistry = Readonly<Record<string, InteractionSkin>>;
|
|
105
150
|
|
|
@@ -107,19 +152,59 @@ export interface QtiRuntimeConfig {
|
|
|
107
152
|
readonly interactions: readonly InteractionDescriptor[];
|
|
108
153
|
readonly skin: SkinRegistry;
|
|
109
154
|
readonly contentModel?: ContentModel;
|
|
155
|
+
/** Replaces the default Unsupported Placeholder for interaction nodes this runtime cannot render. */
|
|
156
|
+
readonly renderUnsupported?: (node: InteractionNode) => ReactNode;
|
|
157
|
+
/** The Response Normalization hook (ADR-0004): opt-in candidate-input leniency. */
|
|
158
|
+
readonly normalization?: ResponseNormalization;
|
|
159
|
+
/**
|
|
160
|
+
* The Asset Resolver: maps package-relative media references (img/audio/video `src`,
|
|
161
|
+
* `poster`) to real URLs at render time. Identity when omitted.
|
|
162
|
+
*/
|
|
163
|
+
readonly assetResolver?: (href: string) => string;
|
|
164
|
+
/**
|
|
165
|
+
* Registered vendor `customOperator` implementations by class. Items using only
|
|
166
|
+
* registered classes pass the capability gate; everything else stays unsupported.
|
|
167
|
+
*/
|
|
168
|
+
readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
|
|
110
169
|
}
|
|
111
170
|
|
|
112
171
|
export interface ItemRendererProps {
|
|
113
172
|
item: AssessmentItemView;
|
|
173
|
+
/**
|
|
174
|
+
* An externally owned attempt store. Without it the renderer creates a fresh
|
|
175
|
+
* per-mount store; passing one enables review/replay modes (rehydrate a stored,
|
|
176
|
+
* already-submitted attempt) and server-side rendering of submitted states.
|
|
177
|
+
*/
|
|
178
|
+
store?: AttemptStore | undefined;
|
|
179
|
+
/** Clone seed for template processing; store it to replay the same clone. */
|
|
180
|
+
seed?: number | undefined;
|
|
114
181
|
// Rendered inside the same runtime context as the item body, after it. Lets a consumer
|
|
115
182
|
// drop controls (a Submit bar, a score panel) that call `useAttempt()` for this item —
|
|
116
183
|
// the attempt store is per-item and scoped to this subtree.
|
|
117
184
|
children?: ReactNode;
|
|
118
185
|
}
|
|
119
186
|
|
|
187
|
+
export interface ContentRendererProps {
|
|
188
|
+
nodes?: readonly BodyNode[] | undefined;
|
|
189
|
+
/** Values for printedVariable (and showHide-gated feedback) inside the content. */
|
|
190
|
+
outcomes?: Readonly<Record<string, OutcomeValue>> | undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
120
193
|
export interface QtiRuntime {
|
|
121
194
|
ItemRenderer: ComponentType<ItemRendererProps>;
|
|
195
|
+
/**
|
|
196
|
+
* Flow content outside an item attempt — test feedback, rubric copy. Same sanitizer
|
|
197
|
+
* and node walk as the item body, over caller-supplied outcome values.
|
|
198
|
+
*/
|
|
199
|
+
ContentRenderer: ComponentType<ContentRendererProps>;
|
|
122
200
|
useAttempt: () => AttemptController;
|
|
201
|
+
/**
|
|
202
|
+
* The Capability Report for an item against this runtime's injected descriptors,
|
|
203
|
+
* skins, and content model. Consumers gate delivery on it (ADR-0003); the
|
|
204
|
+
* ItemRenderer placeholder is only the backstop for content that reaches
|
|
205
|
+
* rendering anyway.
|
|
206
|
+
*/
|
|
207
|
+
canDeliver: (item: AssessmentItemView) => CapabilityReport;
|
|
123
208
|
}
|
|
124
209
|
|
|
125
210
|
export interface AttemptController extends AttemptSnapshot {
|
|
@@ -151,37 +236,193 @@ function responseIncludes(value: ResponseValue, optionIdentifier: string): boole
|
|
|
151
236
|
return false;
|
|
152
237
|
}
|
|
153
238
|
|
|
154
|
-
return typeof value === "string"
|
|
239
|
+
return typeof value === "string"
|
|
240
|
+
? value === optionIdentifier
|
|
241
|
+
: Array.isArray(value) && value.includes(optionIdentifier);
|
|
155
242
|
}
|
|
156
243
|
|
|
157
244
|
function isCorrectOption(declaration: ResponseDeclarationView | undefined, optionIdentifier: string): boolean {
|
|
158
245
|
return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
|
|
159
246
|
}
|
|
160
247
|
|
|
248
|
+
/**
|
|
249
|
+
* An interaction node is any non-xml node carrying a `responseIdentifier` — including
|
|
250
|
+
* kinds this runtime has never heard of (lagging consumers, foreign extensions). The
|
|
251
|
+
* discriminator must not depend on the injected descriptor set, or unknown interactions
|
|
252
|
+
* would be indistinguishable from text and silently dropped.
|
|
253
|
+
*/
|
|
254
|
+
function isInteractionNode(node: BodyNode): node is InteractionNode {
|
|
255
|
+
return node.kind !== "xml" && typeof (node as { responseIdentifier?: unknown }).responseIdentifier === "string";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
|
|
259
|
+
|
|
260
|
+
function isFeedbackNode(node: BodyNode): boolean {
|
|
261
|
+
return feedbackKinds.has(node.kind);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const templateContentKinds = new Set(["templateInline", "templateBlock"]);
|
|
265
|
+
|
|
266
|
+
/** templateInline/templateBlock: visibility decided by a template variable's value. */
|
|
267
|
+
interface TemplateContentView {
|
|
268
|
+
readonly templateIdentifier: string;
|
|
269
|
+
readonly identifier: string;
|
|
270
|
+
readonly showHide?: "show" | "hide";
|
|
271
|
+
readonly content?: readonly BodyNode[];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isTemplateContentNode(node: BodyNode): boolean {
|
|
275
|
+
return templateContentKinds.has(node.kind);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Same show/hide semantics as feedback, but against the clone's template values. */
|
|
279
|
+
function templateVisible(value: OutcomeValue, view: TemplateContentView): boolean {
|
|
280
|
+
const matched = Array.isArray(value) ? value.includes(view.identifier) : value === view.identifier;
|
|
281
|
+
|
|
282
|
+
return matched !== (view.showHide === "hide");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Body node kinds that render without a descriptor, skin, or content-model entry. */
|
|
286
|
+
const intrinsicLeafKinds = new Set(["text", "printedVariable"]);
|
|
287
|
+
|
|
288
|
+
/** A read-only, already-"submitted" store: backs content rendered outside an attempt. */
|
|
289
|
+
function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): AttemptStore {
|
|
290
|
+
const snapshot: AttemptSnapshot = {
|
|
291
|
+
responses: {},
|
|
292
|
+
submitted: true,
|
|
293
|
+
scores: [],
|
|
294
|
+
outcomes,
|
|
295
|
+
templateValues: {},
|
|
296
|
+
attemptCount: 1,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
getSnapshot: () => snapshot,
|
|
301
|
+
subscribe: () => () => {},
|
|
302
|
+
setResponse: () => {},
|
|
303
|
+
registerResponseCollector: () => () => {},
|
|
304
|
+
submit: () => [],
|
|
305
|
+
reset: () => {},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** QTI showHide semantics: `show` reveals on a matched outcome, `hide` reveals on a miss. */
|
|
310
|
+
function feedbackVisible(outcome: OutcomeValue, feedback: FeedbackView, submitted: boolean): boolean {
|
|
311
|
+
if (!submitted) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const matched = Array.isArray(outcome) ? outcome.includes(feedback.identifier) : outcome === feedback.identifier;
|
|
316
|
+
|
|
317
|
+
return matched !== (feedback.showHide === "hide");
|
|
318
|
+
}
|
|
319
|
+
|
|
161
320
|
export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
162
321
|
const model = config.contentModel ?? v0ContentModel;
|
|
163
322
|
const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
|
|
323
|
+
const resolveAsset = config.assetResolver ?? ((href: string) => href);
|
|
164
324
|
|
|
165
|
-
function renderFlow(node: XmlContentNode, key: number): ReactNode {
|
|
166
|
-
const isMath = node.name === model.mathRoot;
|
|
325
|
+
function renderFlow(node: XmlContentNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
|
|
326
|
+
const isMath = inMath || node.name === model.mathRoot;
|
|
167
327
|
|
|
168
328
|
if (!isMath && !isAllowedFlowElement(model, node.name)) {
|
|
169
329
|
return null; // not allowlisted → dropped (the sanitizer)
|
|
170
330
|
}
|
|
171
331
|
|
|
172
|
-
|
|
173
|
-
|
|
332
|
+
// Inside math the subtree renders structurally: element names pass, attribute
|
|
333
|
+
// hardening still applies (see the content model's mathRoot note).
|
|
334
|
+
const attributes = isMath
|
|
335
|
+
? sanitizeMathAttributes(node.attributes)
|
|
336
|
+
: sanitizeAttributes(model, node.name, node.attributes);
|
|
337
|
+
|
|
338
|
+
for (const name of model.urlAttributes) {
|
|
339
|
+
const value = attributes[name];
|
|
340
|
+
|
|
341
|
+
if (value !== undefined) {
|
|
342
|
+
attributes[name] = resolveAsset(value);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const children = node.children?.map((child, index) => renderNode(child, index, overrides, isMath));
|
|
174
347
|
|
|
175
348
|
return createElement(node.name, { key, ...attributes }, node.value ?? children);
|
|
176
349
|
}
|
|
177
350
|
|
|
178
|
-
function
|
|
179
|
-
if (
|
|
180
|
-
return createElement(
|
|
351
|
+
function renderUnsupported(node: InteractionNode, key: number): ReactNode {
|
|
352
|
+
if (config.renderUnsupported) {
|
|
353
|
+
return createElement("span", { key }, config.renderUnsupported(node));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The Unsupported Placeholder (ADR-0003): explicit and accessible, never a silent drop.
|
|
357
|
+
return createElement(
|
|
358
|
+
"div",
|
|
359
|
+
{ key, role: "note", "data-qti-unsupported": node.kind },
|
|
360
|
+
`This content requires an interaction type (${node.kind}) this runtime does not support.`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function renderNode(node: BodyNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
|
|
365
|
+
const override = overrides?.[node.kind];
|
|
366
|
+
|
|
367
|
+
if (override) {
|
|
368
|
+
return createElement(Fragment, { key }, override(node, key));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (isInteractionNode(node)) {
|
|
372
|
+
// Dispatch is governed by the injected descriptor + skin sets alone (ADR-0001:
|
|
373
|
+
// the kind-union is the injected set), so consumer extension kinds render
|
|
374
|
+
// without being named in the content model.
|
|
375
|
+
if (descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
|
|
376
|
+
return createElement(InteractionHost, { key, node });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return renderUnsupported(node, key);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isFeedbackNode(node)) {
|
|
383
|
+
return createElement(FeedbackHost, {
|
|
384
|
+
key,
|
|
385
|
+
feedback: node as unknown as FeedbackView,
|
|
386
|
+
element: node.kind === "feedbackInline" ? "span" : "div",
|
|
387
|
+
overrides,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (isTemplateContentNode(node)) {
|
|
392
|
+
return createElement(TemplateContentHost, {
|
|
393
|
+
key,
|
|
394
|
+
view: node as unknown as TemplateContentView,
|
|
395
|
+
element: node.kind === "templateInline" ? "span" : "div",
|
|
396
|
+
overrides,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (node.kind === "rubricBlock") {
|
|
401
|
+
const rubric = node as unknown as { view?: readonly string[]; content?: readonly BodyNode[] };
|
|
402
|
+
|
|
403
|
+
// Rubric blocks are addressed by view; a delivery engine shows candidates theirs.
|
|
404
|
+
if (!rubric.view?.includes("candidate")) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return createElement(
|
|
409
|
+
"div",
|
|
410
|
+
{ key, "data-qti-rubric-block": true },
|
|
411
|
+
rubric.content?.map((child, index) => renderNode(child, index, overrides)),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (node.kind === "printedVariable") {
|
|
416
|
+
const identifier = (node as { identifier?: unknown }).identifier;
|
|
417
|
+
|
|
418
|
+
return createElement(PrintedVariableHost, {
|
|
419
|
+
key,
|
|
420
|
+
identifier: typeof identifier === "string" ? identifier : "",
|
|
421
|
+
});
|
|
181
422
|
}
|
|
182
423
|
|
|
183
424
|
if (node.kind === "xml") {
|
|
184
|
-
return renderFlow(node as XmlContentNode, key);
|
|
425
|
+
return renderFlow(node as XmlContentNode, key, overrides, inMath);
|
|
185
426
|
}
|
|
186
427
|
|
|
187
428
|
const value = (node as { value?: string }).value;
|
|
@@ -189,6 +430,79 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
189
430
|
return typeof value === "string" ? value : null;
|
|
190
431
|
}
|
|
191
432
|
|
|
433
|
+
function FeedbackHost({
|
|
434
|
+
feedback,
|
|
435
|
+
element,
|
|
436
|
+
overrides,
|
|
437
|
+
}: {
|
|
438
|
+
feedback: FeedbackView;
|
|
439
|
+
element: "span" | "div";
|
|
440
|
+
overrides?: NodeOverrides | undefined;
|
|
441
|
+
}): ReactNode {
|
|
442
|
+
const { store } = useRuntimeContext();
|
|
443
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
444
|
+
const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
|
|
445
|
+
|
|
446
|
+
if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return createElement(
|
|
451
|
+
element,
|
|
452
|
+
{ "data-qti-feedback": feedback.identifier },
|
|
453
|
+
feedback.content?.map((child, index) => renderNode(child, index, overrides)),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function TemplateContentHost({
|
|
458
|
+
view,
|
|
459
|
+
element,
|
|
460
|
+
overrides,
|
|
461
|
+
}: {
|
|
462
|
+
view: TemplateContentView;
|
|
463
|
+
element: "span" | "div";
|
|
464
|
+
overrides?: NodeOverrides | undefined;
|
|
465
|
+
}): ReactNode {
|
|
466
|
+
const { store } = useRuntimeContext();
|
|
467
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
468
|
+
const value = snapshot.templateValues[view.templateIdentifier] ?? null;
|
|
469
|
+
|
|
470
|
+
if (!templateVisible(value, view)) {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return createElement(
|
|
475
|
+
element,
|
|
476
|
+
{ "data-qti-template": view.identifier },
|
|
477
|
+
view.content?.map((child, index) => renderNode(child, index, overrides)),
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function PrintedVariableHost({ identifier }: { identifier: string }): ReactNode {
|
|
482
|
+
const { store } = useRuntimeContext();
|
|
483
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
484
|
+
const value = snapshot.templateValues[identifier] ?? snapshot.outcomes[identifier] ?? null;
|
|
485
|
+
const text = value === null ? "" : Array.isArray(value) ? value.join(" ") : String(value);
|
|
486
|
+
|
|
487
|
+
return createElement("span", { "data-qti-printed-variable": identifier }, text);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function ModalFeedbackHost({ feedback }: { feedback: FeedbackView }): ReactNode {
|
|
491
|
+
const { store } = useRuntimeContext();
|
|
492
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
493
|
+
const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
|
|
494
|
+
|
|
495
|
+
if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return createElement(
|
|
500
|
+
"div",
|
|
501
|
+
{ role: "status", "data-qti-modal-feedback": feedback.identifier },
|
|
502
|
+
feedback.content?.map((child, index) => renderNode(child, index)),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
192
506
|
function InteractionHost({ node }: { node: InteractionNode }): ReactNode {
|
|
193
507
|
const { store, declarationsById } = useRuntimeContext();
|
|
194
508
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
@@ -246,7 +560,8 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
246
560
|
return;
|
|
247
561
|
}
|
|
248
562
|
|
|
249
|
-
const current =
|
|
563
|
+
const current =
|
|
564
|
+
value === null ? [] : typeof value === "string" ? [value] : Array.isArray(value) ? [...value] : [];
|
|
250
565
|
const next = selected
|
|
251
566
|
? current.filter((entry) => entry !== optionIdentifier)
|
|
252
567
|
: [...current, optionIdentifier];
|
|
@@ -256,8 +571,8 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
256
571
|
};
|
|
257
572
|
};
|
|
258
573
|
|
|
259
|
-
const renderContent = (nodes: readonly BodyNode[] | undefined): ReactNode =>
|
|
260
|
-
nodes ? nodes.map((child, index) => renderNode(child, index)) : null;
|
|
574
|
+
const renderContent = (nodes: readonly BodyNode[] | undefined, overrides?: NodeOverrides): ReactNode =>
|
|
575
|
+
nodes ? nodes.map((child, index) => renderNode(child, index, overrides)) : null;
|
|
261
576
|
|
|
262
577
|
const Skin = config.skin[node.kind];
|
|
263
578
|
|
|
@@ -275,20 +590,46 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
275
590
|
status,
|
|
276
591
|
getOptionProps,
|
|
277
592
|
renderContent,
|
|
593
|
+
resolveAsset,
|
|
594
|
+
endAttempt: () => {
|
|
595
|
+
store.setResponse(responseIdentifier, "true");
|
|
596
|
+
store.submit();
|
|
597
|
+
},
|
|
598
|
+
registerResponseCollector: (collector) => store.registerResponseCollector(responseIdentifier, collector),
|
|
278
599
|
});
|
|
279
600
|
}
|
|
280
601
|
|
|
281
|
-
function
|
|
282
|
-
const store = useMemo(() => {
|
|
283
|
-
|
|
602
|
+
function ContentRenderer({ nodes, outcomes }: ContentRendererProps): ReactNode {
|
|
603
|
+
const store = useMemo(() => createStaticStore(outcomes ?? {}), [outcomes]);
|
|
604
|
+
const declarationsById = useMemo(() => new Map<string, ResponseDeclarationView>(), []);
|
|
284
605
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
606
|
+
return createElement(
|
|
607
|
+
RuntimeContext.Provider,
|
|
608
|
+
{ value: { store, declarationsById } },
|
|
609
|
+
nodes?.map((node, index) => renderNode(node, index)),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
289
612
|
|
|
290
|
-
|
|
291
|
-
|
|
613
|
+
function ItemRenderer({ item, store: externalStore, seed, children }: ItemRendererProps): ReactNode {
|
|
614
|
+
const store = useMemo(
|
|
615
|
+
() =>
|
|
616
|
+
externalStore ??
|
|
617
|
+
createAttemptStore(
|
|
618
|
+
item.responseDeclarations,
|
|
619
|
+
{},
|
|
620
|
+
{
|
|
621
|
+
outcomeDeclarations: item.outcomeDeclarations,
|
|
622
|
+
responseProcessing: item.responseProcessing,
|
|
623
|
+
templateDeclarations: item.templateDeclarations,
|
|
624
|
+
templateProcessing: item.templateProcessing,
|
|
625
|
+
adaptive: item.adaptive,
|
|
626
|
+
seed,
|
|
627
|
+
normalization: config.normalization,
|
|
628
|
+
customOperators: config.customOperators,
|
|
629
|
+
},
|
|
630
|
+
),
|
|
631
|
+
[item, externalStore, seed],
|
|
632
|
+
);
|
|
292
633
|
|
|
293
634
|
const declarationsById = useMemo(
|
|
294
635
|
() => new Map(item.responseDeclarations.map((declaration) => [declaration.identifier, declaration])),
|
|
@@ -296,8 +637,11 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
296
637
|
);
|
|
297
638
|
|
|
298
639
|
const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
|
|
640
|
+
const modals = (item.modalFeedbacks ?? []).map((feedback, index) =>
|
|
641
|
+
createElement(ModalFeedbackHost, { key: index, feedback }),
|
|
642
|
+
);
|
|
299
643
|
|
|
300
|
-
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, children);
|
|
644
|
+
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, modals, children);
|
|
301
645
|
}
|
|
302
646
|
|
|
303
647
|
function useAttempt(): AttemptController {
|
|
@@ -311,5 +655,109 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
|
|
|
311
655
|
};
|
|
312
656
|
}
|
|
313
657
|
|
|
314
|
-
|
|
658
|
+
function canDeliver(item: AssessmentItemView): CapabilityReport {
|
|
659
|
+
const issues: CapabilityIssue[] = [];
|
|
660
|
+
const seen = new Set<string>();
|
|
661
|
+
|
|
662
|
+
function report(issue: CapabilityIssue): void {
|
|
663
|
+
const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
|
|
664
|
+
|
|
665
|
+
if (!seen.has(dedupeKey)) {
|
|
666
|
+
seen.add(dedupeKey);
|
|
667
|
+
issues.push(issue);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function walk(node: BodyNode): void {
|
|
672
|
+
if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
|
|
673
|
+
for (const child of (node as unknown as { content?: readonly BodyNode[] }).content ?? []) {
|
|
674
|
+
walk(child);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (isInteractionNode(node)) {
|
|
681
|
+
const descriptor = descriptorsByKind.get(node.kind);
|
|
682
|
+
|
|
683
|
+
if (!descriptor || !config.skin[node.kind]) {
|
|
684
|
+
report({
|
|
685
|
+
type: "unsupported-interaction",
|
|
686
|
+
name: node.kind,
|
|
687
|
+
responseIdentifier: node.responseIdentifier,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const parsed = descriptor.schema.safeParse(node);
|
|
694
|
+
|
|
695
|
+
if (!parsed.success) {
|
|
696
|
+
const detail = parsed.error.issues[0]?.message;
|
|
697
|
+
|
|
698
|
+
report({
|
|
699
|
+
type: "invalid-interaction",
|
|
700
|
+
name: node.kind,
|
|
701
|
+
responseIdentifier: node.responseIdentifier,
|
|
702
|
+
...(detail !== undefined ? { detail } : {}),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Interaction-internal content (prompt, choice bodies) is structurally
|
|
707
|
+
// validated by the descriptor schema; its flow elements are walked when the
|
|
708
|
+
// descriptor surfaces them. Generic field-sniffing is deliberately avoided.
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (node.kind === "xml") {
|
|
713
|
+
const xmlNode = node as XmlContentNode;
|
|
714
|
+
|
|
715
|
+
if (xmlNode.name === model.mathRoot) {
|
|
716
|
+
return; // MathML renders structurally; its subtree is not flow content
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!isAllowedFlowElement(model, xmlNode.name)) {
|
|
720
|
+
report({ type: "unsupported-element", name: xmlNode.name });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
for (const child of xmlNode.children ?? []) {
|
|
724
|
+
walk(child);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (intrinsicLeafKinds.has(node.kind)) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Any other kind (include, multi-stage groups, future vocabulary) has no
|
|
735
|
+
// rendering path: report it rather than let the renderer drop it (ADR-0003).
|
|
736
|
+
report({ type: "unsupported-element", name: node.kind });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
for (const node of item.itemBody.content ?? []) {
|
|
740
|
+
walk(node);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
for (const feedback of item.modalFeedbacks ?? []) {
|
|
744
|
+
for (const child of feedback.content ?? []) {
|
|
745
|
+
walk(child);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
|
|
750
|
+
|
|
751
|
+
for (const issue of collectRpIssues(item.responseProcessing, { customOperatorClasses })) {
|
|
752
|
+
report(issue);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
|
|
756
|
+
report(issue);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return { deliverable: issues.length === 0, issues };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return { ItemRenderer, ContentRenderer, useAttempt, canDeliver };
|
|
315
763
|
}
|