@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
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter from `@conform-ed/qti-xml`'s normalized assessment-item JSON (the contracts
|
|
3
|
+
* vocabulary) to the runtime's `AssessmentItemView`. Pure data reshaping with no
|
|
4
|
+
* qti-xml dependency. The contracts shapes and the runtime's descriptor shapes
|
|
5
|
+
* deliberately differ in places; this seam is where they reconcile:
|
|
6
|
+
*
|
|
7
|
+
* - bare-string text fragments become `{ kind: "text", value }` nodes
|
|
8
|
+
* - `hotTextInteraction`/`hotText` rename to the runtime's `hottextInteraction`/`hottext`
|
|
9
|
+
* - graphic stages: contract `image` xml nodes become `{ data, width, height, type }`
|
|
10
|
+
* objects, and `coords` strings become number arrays (also in `areaMapping`)
|
|
11
|
+
* - `gapChoices` split into the runtime's `gapTexts` (gapMatch) / `gapImgs` (graphic)
|
|
12
|
+
* - media/upload/positionObjectStage flatten to the descriptor fields
|
|
13
|
+
* - processing trees: `children` → `expressions`, `actions` → `rules`,
|
|
14
|
+
* `responseElseIf`/`templateElseIf` pluralize
|
|
15
|
+
*
|
|
16
|
+
* Used by the corpus delivery meter (ADR-0002) and by any consumer ingesting
|
|
17
|
+
* normalized XML.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { parseCoords } from "./graphic";
|
|
21
|
+
import type {
|
|
22
|
+
OutcomeDeclarationView,
|
|
23
|
+
ResponseProcessingView,
|
|
24
|
+
RpExpressionView,
|
|
25
|
+
RpRuleView,
|
|
26
|
+
TemplateDeclarationView,
|
|
27
|
+
TemplateProcessingView,
|
|
28
|
+
TemplateRuleView,
|
|
29
|
+
} from "./rp";
|
|
30
|
+
import type { AssessmentItemView, BodyNode, FeedbackView } from "./runtime";
|
|
31
|
+
import type {
|
|
32
|
+
AssessmentItemRefView,
|
|
33
|
+
AssessmentSectionView,
|
|
34
|
+
AssessmentTestView,
|
|
35
|
+
BranchRuleView,
|
|
36
|
+
ItemSessionControlView,
|
|
37
|
+
OutcomeRuleView,
|
|
38
|
+
TestFeedbackView,
|
|
39
|
+
TestPartView,
|
|
40
|
+
TimeLimitsView,
|
|
41
|
+
} from "./test";
|
|
42
|
+
import type { ResponseDeclarationView } from "./types";
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function asRecords(value: unknown): Record<string, unknown>[] {
|
|
49
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- content ----------
|
|
53
|
+
|
|
54
|
+
const kindRenames: Readonly<Record<string, string>> = {
|
|
55
|
+
hotTextInteraction: "hottextInteraction",
|
|
56
|
+
hotText: "hottext",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function numberValue(value: unknown): number | undefined {
|
|
60
|
+
if (typeof value === "number") {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof value === "string" && value !== "") {
|
|
65
|
+
const parsed = Number(value);
|
|
66
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The element carrying the image source: the node itself, or (for wrappers like `picture`) the first `img`/`object` descendant. */
|
|
73
|
+
function findImageSource(node: unknown): Record<string, unknown> {
|
|
74
|
+
if (!isRecord(node)) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const attributes = isRecord(node["attributes"]) ? node["attributes"] : {};
|
|
79
|
+
|
|
80
|
+
if (typeof attributes["data"] === "string" || typeof attributes["src"] === "string") {
|
|
81
|
+
return attributes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const child of asRecords(node["children"])) {
|
|
85
|
+
const found = findImageSource(child);
|
|
86
|
+
|
|
87
|
+
if (typeof found["data"] === "string" || typeof found["src"] === "string") {
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return attributes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A contract media node (`object`/`img`/`picture` xml node) as the runtime's stage-object shape. */
|
|
96
|
+
function toStageObject(node: unknown): Record<string, unknown> {
|
|
97
|
+
const attributes = findImageSource(node);
|
|
98
|
+
const data = attributes["data"] ?? attributes["src"];
|
|
99
|
+
const width = numberValue(attributes["width"]);
|
|
100
|
+
const height = numberValue(attributes["height"]);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
data: typeof data === "string" ? data : "",
|
|
104
|
+
...(width !== undefined ? { width } : {}),
|
|
105
|
+
...(height !== undefined ? { height } : {}),
|
|
106
|
+
...(typeof attributes["type"] === "string" ? { type: attributes["type"] } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Plain text of converted content nodes (for gapText labels in graphic trays). */
|
|
111
|
+
function flattenText(value: unknown): string {
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
return value.map(flattenText).join("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isRecord(value)) {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (value["kind"] === "text") {
|
|
125
|
+
return typeof value["value"] === "string" ? value["value"] : "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return flattenText(value["children"]) + flattenText(value["content"]) + flattenText(value["value"]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function withNumericCoords(node: Record<string, unknown>): Record<string, unknown> {
|
|
132
|
+
return typeof node["coords"] === "string" ? { ...node, coords: parseCoords(node["coords"]) } : node;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Kind-specific reshaping from contract nodes to the runtime descriptor shapes. */
|
|
136
|
+
function reshapeContentNode(node: Record<string, unknown>): Record<string, unknown> {
|
|
137
|
+
const kind = node["kind"];
|
|
138
|
+
|
|
139
|
+
if (typeof kind !== "string") {
|
|
140
|
+
return node;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const renamed = kindRenames[kind];
|
|
144
|
+
if (renamed !== undefined) {
|
|
145
|
+
return { ...node, kind: renamed };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
switch (kind) {
|
|
149
|
+
case "hotspotChoice":
|
|
150
|
+
case "associableHotspot":
|
|
151
|
+
return withNumericCoords(node);
|
|
152
|
+
|
|
153
|
+
case "hotspotInteraction":
|
|
154
|
+
case "graphicOrderInteraction":
|
|
155
|
+
case "graphicAssociateInteraction":
|
|
156
|
+
case "selectPointInteraction": {
|
|
157
|
+
const { image, ...rest } = node;
|
|
158
|
+
return { ...rest, object: toStageObject(image) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case "drawingInteraction": {
|
|
162
|
+
// The stage arrives as generic content (<object> or <picture>/<img>); adapt it
|
|
163
|
+
// to the graphic-family object shape the descriptor expects.
|
|
164
|
+
const { content, ...rest } = node;
|
|
165
|
+
const media = asRecords(content).find(
|
|
166
|
+
(fragment) =>
|
|
167
|
+
fragment["kind"] === "xml" &&
|
|
168
|
+
typeof fragment["name"] === "string" &&
|
|
169
|
+
["object", "picture", "img"].includes(fragment["name"]),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return { ...rest, object: toStageObject(media) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case "graphicGapMatchInteraction": {
|
|
176
|
+
const { image, gapChoices, ...rest } = node;
|
|
177
|
+
// Both gap choice kinds become tray entries: gapImg carries an image object,
|
|
178
|
+
// gapText a plain-text label.
|
|
179
|
+
const gapImgs = asRecords(gapChoices).map(({ media, content, ...choice }) =>
|
|
180
|
+
choice["kind"] === "gapImg"
|
|
181
|
+
? { ...choice, object: toStageObject(media) }
|
|
182
|
+
: { ...choice, label: flattenText(content) },
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return { ...rest, object: toStageObject(image), gapImgs };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "gapMatchInteraction": {
|
|
189
|
+
const gapTexts = asRecords(node["gapChoices"]).filter((choice) => choice["kind"] === "gapText");
|
|
190
|
+
return { ...node, gapTexts };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case "mediaInteraction": {
|
|
194
|
+
const { media, ...rest } = node;
|
|
195
|
+
return { ...rest, content: media === undefined ? [] : [media] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case "uploadInteraction": {
|
|
199
|
+
const acceptedTypes = node["acceptedTypes"];
|
|
200
|
+
return Array.isArray(acceptedTypes) ? { ...node, type: acceptedTypes.join(",") } : node;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case "positionObjectStage": {
|
|
204
|
+
const interactions = asRecords(node["positionObjectInteractions"]);
|
|
205
|
+
const [first] = interactions;
|
|
206
|
+
|
|
207
|
+
if (interactions.length === 1 && first) {
|
|
208
|
+
return {
|
|
209
|
+
kind: "positionObjectStage",
|
|
210
|
+
responseIdentifier: first["responseIdentifier"],
|
|
211
|
+
stageObject: toStageObject(node["image"]),
|
|
212
|
+
object: toStageObject(first["image"]),
|
|
213
|
+
...(first["maxChoices"] !== undefined ? { maxChoices: first["maxChoices"] } : {}),
|
|
214
|
+
...(first["minChoices"] !== undefined ? { minChoices: first["minChoices"] } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Multi-interaction stages are beyond the runtime's single-stage descriptor;
|
|
219
|
+
// keep a responseIdentifier so the capability gate reports the node instead of
|
|
220
|
+
// the renderer silently dropping it (ADR-0003).
|
|
221
|
+
const responseIdentifier = first?.["responseIdentifier"];
|
|
222
|
+
return { ...node, ...(typeof responseIdentifier === "string" ? { responseIdentifier } : {}) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
return node;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function convertContentEntry(entry: unknown): unknown {
|
|
231
|
+
if (typeof entry === "string") {
|
|
232
|
+
return { kind: "text", value: entry };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return convertContentValue(entry);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function convertContentValue(value: unknown): unknown {
|
|
239
|
+
if (Array.isArray(value)) {
|
|
240
|
+
return value.map(convertContentValue);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!isRecord(value)) {
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const converted = Object.fromEntries(
|
|
248
|
+
Object.entries(value).map(([key, entry]) => {
|
|
249
|
+
if ((key === "children" || key === "content") && Array.isArray(entry)) {
|
|
250
|
+
return [key, entry.map(convertContentEntry)];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return [key, convertContentValue(entry)];
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return reshapeContentNode(converted);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------- processing trees ----------
|
|
261
|
+
|
|
262
|
+
function convertExpression(expression: unknown): RpExpressionView {
|
|
263
|
+
const record = isRecord(expression) ? expression : {};
|
|
264
|
+
const { children, ...rest } = record;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
...rest,
|
|
268
|
+
...(Array.isArray(children) ? { expressions: children.map(convertExpression) } : {}),
|
|
269
|
+
} as unknown as RpExpressionView;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function convertBranch(branch: unknown, convertRule: (rule: unknown) => Record<string, unknown>) {
|
|
273
|
+
const record = isRecord(branch) ? branch : {};
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
expression: convertExpression(record["expression"]),
|
|
277
|
+
rules: (Array.isArray(record["actions"]) ? record["actions"] : []).map(convertRule),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function convertRpRule(rule: unknown): Record<string, unknown> {
|
|
282
|
+
const record = isRecord(rule) ? rule : {};
|
|
283
|
+
const kind = typeof record["kind"] === "string" ? record["kind"] : "";
|
|
284
|
+
|
|
285
|
+
if (kind === "responseCondition") {
|
|
286
|
+
const elseIfs = Array.isArray(record["responseElseIf"])
|
|
287
|
+
? record["responseElseIf"].map((branch) => convertBranch(branch, convertRpRule))
|
|
288
|
+
: [];
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
kind,
|
|
292
|
+
responseIf: convertBranch(record["responseIf"], convertRpRule),
|
|
293
|
+
...(elseIfs.length ? { responseElseIfs: elseIfs } : {}),
|
|
294
|
+
...(isRecord(record["responseElse"])
|
|
295
|
+
? {
|
|
296
|
+
responseElse: {
|
|
297
|
+
rules: (Array.isArray(record["responseElse"]["actions"]) ? record["responseElse"]["actions"] : []).map(
|
|
298
|
+
convertRpRule,
|
|
299
|
+
),
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
: {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
kind,
|
|
308
|
+
...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
|
|
309
|
+
...(record["expression"] !== undefined ? { expression: convertExpression(record["expression"]) } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function convertTemplateRule(rule: unknown): Record<string, unknown> {
|
|
314
|
+
const record = isRecord(rule) ? rule : {};
|
|
315
|
+
const kind = typeof record["kind"] === "string" ? record["kind"] : "";
|
|
316
|
+
|
|
317
|
+
if (kind === "templateCondition") {
|
|
318
|
+
const elseIfs = Array.isArray(record["templateElseIf"])
|
|
319
|
+
? record["templateElseIf"].map((branch) => convertBranch(branch, convertTemplateRule))
|
|
320
|
+
: [];
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
kind,
|
|
324
|
+
templateIf: convertBranch(record["templateIf"], convertTemplateRule),
|
|
325
|
+
...(elseIfs.length ? { templateElseIfs: elseIfs } : {}),
|
|
326
|
+
...(isRecord(record["templateElse"])
|
|
327
|
+
? {
|
|
328
|
+
templateElse: {
|
|
329
|
+
rules: (Array.isArray(record["templateElse"]["actions"]) ? record["templateElse"]["actions"] : []).map(
|
|
330
|
+
convertTemplateRule,
|
|
331
|
+
),
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
: {}),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
kind,
|
|
340
|
+
...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
|
|
341
|
+
...(record["expression"] !== undefined ? { expression: convertExpression(record["expression"]) } : {}),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function convertResponseProcessing(value: Record<string, unknown>): ResponseProcessingView {
|
|
346
|
+
return {
|
|
347
|
+
...(typeof value["template"] === "string" ? { template: value["template"] } : {}),
|
|
348
|
+
...(Array.isArray(value["rules"])
|
|
349
|
+
? { rules: value["rules"].map(convertRpRule) as unknown as readonly RpRuleView[] }
|
|
350
|
+
: {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------- declarations ----------
|
|
355
|
+
|
|
356
|
+
function convertResponseDeclaration(declaration: Record<string, unknown>): ResponseDeclarationView {
|
|
357
|
+
const areaMapping = declaration["areaMapping"];
|
|
358
|
+
|
|
359
|
+
if (!isRecord(areaMapping)) {
|
|
360
|
+
return declaration as unknown as ResponseDeclarationView;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const areaMapEntries = asRecords(areaMapping["areaMapEntries"]).map((entry) => withNumericCoords(entry));
|
|
364
|
+
|
|
365
|
+
return { ...declaration, areaMapping: { ...areaMapping, areaMapEntries } } as unknown as ResponseDeclarationView;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Reshape a normalized QTI document (the `normalizedDocument` from qti-xml validation)
|
|
370
|
+
* into an `AssessmentItemView`, or null when it is not an assessment item.
|
|
371
|
+
*/
|
|
372
|
+
export function assessmentItemViewFromNormalized(document: unknown): AssessmentItemView | null {
|
|
373
|
+
if (!isRecord(document) || !isRecord(document["assessmentItem"])) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const item = document["assessmentItem"];
|
|
378
|
+
const itemBody = isRecord(item["itemBody"]) ? item["itemBody"] : {};
|
|
379
|
+
const content = Array.isArray(itemBody["content"]) ? itemBody["content"].map(convertContentEntry) : [];
|
|
380
|
+
const templateRules = isRecord(item["templateProcessing"]) ? item["templateProcessing"]["rules"] : undefined;
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
responseDeclarations: asRecords(item["responseDeclarations"]).map(convertResponseDeclaration),
|
|
384
|
+
outcomeDeclarations: (item["outcomeDeclarations"] as OutcomeDeclarationView[] | undefined) ?? [],
|
|
385
|
+
...(isRecord(item["responseProcessing"])
|
|
386
|
+
? { responseProcessing: convertResponseProcessing(item["responseProcessing"]) }
|
|
387
|
+
: {}),
|
|
388
|
+
...(Array.isArray(item["templateDeclarations"])
|
|
389
|
+
? { templateDeclarations: item["templateDeclarations"] as TemplateDeclarationView[] }
|
|
390
|
+
: {}),
|
|
391
|
+
...(Array.isArray(templateRules)
|
|
392
|
+
? {
|
|
393
|
+
templateProcessing: {
|
|
394
|
+
rules: templateRules.map(convertTemplateRule) as unknown as readonly TemplateRuleView[],
|
|
395
|
+
} satisfies TemplateProcessingView,
|
|
396
|
+
}
|
|
397
|
+
: {}),
|
|
398
|
+
...(typeof item["adaptive"] === "boolean" ? { adaptive: item["adaptive"] } : {}),
|
|
399
|
+
...(Array.isArray(item["modalFeedbacks"])
|
|
400
|
+
? { modalFeedbacks: item["modalFeedbacks"].map(convertContentValue) as unknown as readonly FeedbackView[] }
|
|
401
|
+
: {}),
|
|
402
|
+
itemBody: { content: content as BodyNode[] },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------- assessment tests (ADR-0005) ----------
|
|
407
|
+
|
|
408
|
+
/** Normalized `{kind: "preCondition", expression}` wrappers as bare expressions. */
|
|
409
|
+
function convertPreConditions(value: unknown): RpExpressionView[] {
|
|
410
|
+
return asRecords(value).map((wrapper) => convertExpression(wrapper["expression"]));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function convertBranchRules(value: unknown): BranchRuleView[] {
|
|
414
|
+
return asRecords(value).map((rule) => ({
|
|
415
|
+
target: typeof rule["target"] === "string" ? rule["target"] : "",
|
|
416
|
+
expression: convertExpression(rule["expression"]),
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function convertOutcomeRule(rule: unknown): Record<string, unknown> {
|
|
421
|
+
const record = isRecord(rule) ? rule : {};
|
|
422
|
+
const kind = typeof record["kind"] === "string" ? record["kind"] : "";
|
|
423
|
+
|
|
424
|
+
if (kind === "outcomeCondition") {
|
|
425
|
+
const elseIfs = Array.isArray(record["outcomeElseIf"])
|
|
426
|
+
? record["outcomeElseIf"].map((branch) => convertBranch(branch, convertOutcomeRule))
|
|
427
|
+
: [];
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
kind,
|
|
431
|
+
outcomeIf: convertBranch(record["outcomeIf"], convertOutcomeRule),
|
|
432
|
+
...(elseIfs.length ? { outcomeElseIfs: elseIfs } : {}),
|
|
433
|
+
...(isRecord(record["outcomeElse"])
|
|
434
|
+
? {
|
|
435
|
+
outcomeElse: {
|
|
436
|
+
rules: (Array.isArray(record["outcomeElse"]["actions"]) ? record["outcomeElse"]["actions"] : []).map(
|
|
437
|
+
convertOutcomeRule,
|
|
438
|
+
),
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
: {}),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
kind,
|
|
447
|
+
...(typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {}),
|
|
448
|
+
...(record["expression"] !== undefined ? { expression: convertExpression(record["expression"]) } : {}),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** `itemSessionControl`/`timeLimits` keep their normalized field names verbatim. */
|
|
453
|
+
function sessionControlAndTimeLimits(record: Record<string, unknown>): {
|
|
454
|
+
itemSessionControl?: ItemSessionControlView;
|
|
455
|
+
timeLimits?: TimeLimitsView;
|
|
456
|
+
} {
|
|
457
|
+
return {
|
|
458
|
+
...(isRecord(record["itemSessionControl"])
|
|
459
|
+
? { itemSessionControl: record["itemSessionControl"] as ItemSessionControlView }
|
|
460
|
+
: {}),
|
|
461
|
+
...(isRecord(record["timeLimits"]) ? { timeLimits: record["timeLimits"] as TimeLimitsView } : {}),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function convertItemRef(ref: Record<string, unknown>): AssessmentItemRefView {
|
|
466
|
+
return {
|
|
467
|
+
kind: "assessmentItemRef",
|
|
468
|
+
identifier: typeof ref["identifier"] === "string" ? ref["identifier"] : "",
|
|
469
|
+
...(typeof ref["href"] === "string" ? { href: ref["href"] } : {}),
|
|
470
|
+
...(Array.isArray(ref["category"]) ? { categories: ref["category"] as string[] } : {}),
|
|
471
|
+
...(typeof ref["fixed"] === "boolean" ? { fixed: ref["fixed"] } : {}),
|
|
472
|
+
...(typeof ref["required"] === "boolean" ? { required: ref["required"] } : {}),
|
|
473
|
+
...(ref["preConditions"] !== undefined ? { preConditions: convertPreConditions(ref["preConditions"]) } : {}),
|
|
474
|
+
...(ref["branchRules"] !== undefined ? { branchRules: convertBranchRules(ref["branchRules"]) } : {}),
|
|
475
|
+
...(Array.isArray(ref["weights"])
|
|
476
|
+
? { weights: ref["weights"] as NonNullable<AssessmentItemRefView["weights"]> }
|
|
477
|
+
: {}),
|
|
478
|
+
...sessionControlAndTimeLimits(ref),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function convertSection(section: Record<string, unknown>): AssessmentSectionView {
|
|
483
|
+
// No `kind` discriminator in the normalized shape: sections carry `visible`/`title`,
|
|
484
|
+
// item refs an `href`. (Unresolved section-refs share the item-ref shape; the corpus
|
|
485
|
+
// has none and external sections need a package loader anyway.)
|
|
486
|
+
const children = asRecords(section["children"]).map((child) =>
|
|
487
|
+
child["visible"] !== undefined || child["children"] !== undefined ? convertSection(child) : convertItemRef(child),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
kind: "assessmentSection",
|
|
492
|
+
identifier: typeof section["identifier"] === "string" ? section["identifier"] : "",
|
|
493
|
+
...(typeof section["title"] === "string" ? { title: section["title"] } : {}),
|
|
494
|
+
...(typeof section["visible"] === "boolean" ? { visible: section["visible"] } : {}),
|
|
495
|
+
...(typeof section["fixed"] === "boolean" ? { fixed: section["fixed"] } : {}),
|
|
496
|
+
...(typeof section["required"] === "boolean" ? { required: section["required"] } : {}),
|
|
497
|
+
...(isRecord(section["selection"])
|
|
498
|
+
? { selection: section["selection"] as unknown as NonNullable<AssessmentSectionView["selection"]> }
|
|
499
|
+
: {}),
|
|
500
|
+
...(isRecord(section["ordering"])
|
|
501
|
+
? { ordering: section["ordering"] as unknown as NonNullable<AssessmentSectionView["ordering"]> }
|
|
502
|
+
: {}),
|
|
503
|
+
...(section["preConditions"] !== undefined
|
|
504
|
+
? { preConditions: convertPreConditions(section["preConditions"]) }
|
|
505
|
+
: {}),
|
|
506
|
+
...(section["branchRules"] !== undefined ? { branchRules: convertBranchRules(section["branchRules"]) } : {}),
|
|
507
|
+
...sessionControlAndTimeLimits(section),
|
|
508
|
+
children,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function convertTestFeedback(feedback: Record<string, unknown>): TestFeedbackView {
|
|
513
|
+
const converted = convertContentValue(feedback) as Record<string, unknown>;
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
outcomeIdentifier: typeof converted["outcomeIdentifier"] === "string" ? converted["outcomeIdentifier"] : "",
|
|
517
|
+
identifier: typeof converted["identifier"] === "string" ? converted["identifier"] : "",
|
|
518
|
+
...(converted["access"] === "atEnd" || converted["access"] === "during" ? { access: converted["access"] } : {}),
|
|
519
|
+
...(converted["showHide"] === "show" || converted["showHide"] === "hide"
|
|
520
|
+
? { showHide: converted["showHide"] }
|
|
521
|
+
: {}),
|
|
522
|
+
...(Array.isArray(converted["content"]) ? { content: converted["content"] as BodyNode[] } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Reshape a normalized QTI document into the Test Controller's `AssessmentTestView`,
|
|
528
|
+
* or null when it is not an assessment test.
|
|
529
|
+
*/
|
|
530
|
+
export function assessmentTestViewFromNormalized(document: unknown): AssessmentTestView | null {
|
|
531
|
+
if (!isRecord(document) || !isRecord(document["assessmentTest"])) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const testDocument = document["assessmentTest"];
|
|
536
|
+
const outcomeRules = isRecord(testDocument["outcomeProcessing"])
|
|
537
|
+
? testDocument["outcomeProcessing"]["rules"]
|
|
538
|
+
: undefined;
|
|
539
|
+
|
|
540
|
+
const testParts: TestPartView[] = asRecords(testDocument["testParts"]).map((part) => ({
|
|
541
|
+
identifier: typeof part["identifier"] === "string" ? part["identifier"] : "",
|
|
542
|
+
navigationMode: part["navigationMode"] === "nonlinear" ? "nonlinear" : "linear",
|
|
543
|
+
submissionMode: part["submissionMode"] === "simultaneous" ? "simultaneous" : "individual",
|
|
544
|
+
...(part["preConditions"] !== undefined ? { preConditions: convertPreConditions(part["preConditions"]) } : {}),
|
|
545
|
+
...(part["branchRules"] !== undefined ? { branchRules: convertBranchRules(part["branchRules"]) } : {}),
|
|
546
|
+
...sessionControlAndTimeLimits(part),
|
|
547
|
+
assessmentSections: asRecords(part["children"]).map(convertSection),
|
|
548
|
+
}));
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
identifier: typeof testDocument["identifier"] === "string" ? testDocument["identifier"] : "",
|
|
552
|
+
...(typeof testDocument["title"] === "string" ? { title: testDocument["title"] } : {}),
|
|
553
|
+
outcomeDeclarations: (testDocument["outcomeDeclarations"] as OutcomeDeclarationView[] | undefined) ?? [],
|
|
554
|
+
...(isRecord(testDocument["timeLimits"]) ? { timeLimits: testDocument["timeLimits"] as TimeLimitsView } : {}),
|
|
555
|
+
testParts,
|
|
556
|
+
...(Array.isArray(outcomeRules)
|
|
557
|
+
? { outcomeProcessing: { rules: outcomeRules.map(convertOutcomeRule) as unknown as readonly OutcomeRuleView[] } }
|
|
558
|
+
: {}),
|
|
559
|
+
...(Array.isArray(testDocument["testFeedbacks"])
|
|
560
|
+
? { testFeedbacks: asRecords(testDocument["testFeedbacks"]).map(convertTestFeedback) }
|
|
561
|
+
: {}),
|
|
562
|
+
};
|
|
563
|
+
}
|
package/src/pci/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// PCI (IMS Portable Custom Interactions v1) support. Opt-in by design: PCI executes
|
|
2
|
+
// item-supplied JavaScript, so nothing here is part of qtiCoreInteractions — consumers
|
|
3
|
+
// add the descriptor and a created skin explicitly (ADR-0003: no silent capability).
|
|
4
|
+
|
|
5
|
+
export { pciResponseToValue, valueToPciResponse } from "./response";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
createPciModuleRegistry,
|
|
9
|
+
type PciConfiguration,
|
|
10
|
+
type PciInstance,
|
|
11
|
+
type PciModule,
|
|
12
|
+
type PciModuleRegistry,
|
|
13
|
+
type PciModuleRegistryOptions,
|
|
14
|
+
} from "./registry";
|
|
15
|
+
|
|
16
|
+
export { serializePciMarkup } from "./markup";
|
|
17
|
+
|
|
18
|
+
export { mountPci, type PciInteractionNode, type PciMountHandle, type PciMountOptions } from "./mount";
|
|
19
|
+
|
|
20
|
+
export { portableCustomInteraction } from "./interaction";
|
|
21
|
+
|
|
22
|
+
export { createPciSkin, type PciSkinOptions } from "./skin";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { defineInteraction, type InteractionDescriptor } from "../runtime";
|
|
4
|
+
import type { ResponseValue } from "../types";
|
|
5
|
+
|
|
6
|
+
const interactionModuleSchema = z.object({
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
primaryPath: z.string().optional(),
|
|
9
|
+
fallbackPath: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const pciNodeSchema = z.object({
|
|
13
|
+
kind: z.literal("portableCustomInteraction"),
|
|
14
|
+
responseIdentifier: z.string().min(1),
|
|
15
|
+
customInteractionTypeIdentifier: z.string().min(1),
|
|
16
|
+
module: z.string().optional(),
|
|
17
|
+
properties: z.record(z.string(), z.string()).optional(),
|
|
18
|
+
// Markup is module-owned and deliberately opaque to the content model.
|
|
19
|
+
interactionMarkup: z.object({ content: z.array(z.unknown()).optional() }).optional(),
|
|
20
|
+
interactionModules: z
|
|
21
|
+
.object({
|
|
22
|
+
primaryConfiguration: z.string().optional(),
|
|
23
|
+
secondaryConfiguration: z.string().optional(),
|
|
24
|
+
modules: z.array(interactionModuleSchema).optional(),
|
|
25
|
+
})
|
|
26
|
+
.optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The PCI interaction descriptor. Deliberately NOT part of `qtiCoreInteractions`:
|
|
31
|
+
* delivering a PCI executes item-supplied JavaScript, so consumers opt in by adding
|
|
32
|
+
* this descriptor plus a skin from `createPciSkin` (ADR-0003 — the capability gate
|
|
33
|
+
* keeps PCI items undeliverable until then).
|
|
34
|
+
*/
|
|
35
|
+
export const portableCustomInteraction: InteractionDescriptor<"portableCustomInteraction"> = defineInteraction({
|
|
36
|
+
kind: "portableCustomInteraction",
|
|
37
|
+
schema: pciNodeSchema,
|
|
38
|
+
scoring: "qti-standard",
|
|
39
|
+
initialResponse(): ResponseValue {
|
|
40
|
+
return null;
|
|
41
|
+
},
|
|
42
|
+
});
|