@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/dist/index.js
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
// src/content-model.ts
|
|
2
|
-
var v0InteractionKinds = [
|
|
2
|
+
var v0InteractionKinds = [
|
|
3
|
+
"associateInteraction",
|
|
4
|
+
"choiceInteraction",
|
|
5
|
+
"drawingInteraction",
|
|
6
|
+
"endAttemptInteraction",
|
|
7
|
+
"extendedTextInteraction",
|
|
8
|
+
"gapMatchInteraction",
|
|
9
|
+
"graphicAssociateInteraction",
|
|
10
|
+
"graphicGapMatchInteraction",
|
|
11
|
+
"graphicOrderInteraction",
|
|
12
|
+
"hotspotInteraction",
|
|
13
|
+
"hottextInteraction",
|
|
14
|
+
"inlineChoiceInteraction",
|
|
15
|
+
"matchInteraction",
|
|
16
|
+
"mediaInteraction",
|
|
17
|
+
"orderInteraction",
|
|
18
|
+
"positionObjectStage",
|
|
19
|
+
"selectPointInteraction",
|
|
20
|
+
"sliderInteraction",
|
|
21
|
+
"textEntryInteraction",
|
|
22
|
+
"uploadInteraction"
|
|
23
|
+
];
|
|
3
24
|
var v0FlowElements = new Set([
|
|
4
25
|
"p",
|
|
5
26
|
"span",
|
|
@@ -7,21 +28,62 @@ var v0FlowElements = new Set([
|
|
|
7
28
|
"em",
|
|
8
29
|
"b",
|
|
9
30
|
"i",
|
|
31
|
+
"sub",
|
|
32
|
+
"sup",
|
|
10
33
|
"br",
|
|
11
34
|
"ul",
|
|
12
35
|
"ol",
|
|
13
36
|
"li",
|
|
14
37
|
"ruby",
|
|
38
|
+
"rb",
|
|
15
39
|
"rt",
|
|
16
|
-
"rp"
|
|
40
|
+
"rp",
|
|
41
|
+
"img",
|
|
42
|
+
"audio",
|
|
43
|
+
"video",
|
|
44
|
+
"source",
|
|
45
|
+
"track",
|
|
46
|
+
"picture",
|
|
47
|
+
"figure",
|
|
48
|
+
"figcaption",
|
|
49
|
+
"object",
|
|
50
|
+
"div",
|
|
51
|
+
"section",
|
|
52
|
+
"h1",
|
|
53
|
+
"h2",
|
|
54
|
+
"h3",
|
|
55
|
+
"h4",
|
|
56
|
+
"h5",
|
|
57
|
+
"h6",
|
|
58
|
+
"blockquote",
|
|
59
|
+
"hr",
|
|
60
|
+
"table",
|
|
61
|
+
"caption",
|
|
62
|
+
"thead",
|
|
63
|
+
"tbody",
|
|
64
|
+
"tfoot",
|
|
65
|
+
"tr",
|
|
66
|
+
"th",
|
|
67
|
+
"td"
|
|
68
|
+
]);
|
|
69
|
+
var v0ElementAttributes = new Map([
|
|
70
|
+
["img", new Set(["src", "alt", "width", "height"])],
|
|
71
|
+
["audio", new Set(["src", "controls", "loop", "muted", "preload"])],
|
|
72
|
+
["video", new Set(["src", "controls", "loop", "muted", "preload", "poster", "width", "height"])],
|
|
73
|
+
["source", new Set(["src", "type"])],
|
|
74
|
+
["track", new Set(["src", "kind", "srclang", "label", "default"])],
|
|
75
|
+
["object", new Set(["data", "type", "width", "height"])]
|
|
17
76
|
]);
|
|
77
|
+
var v0UrlAttributes = new Set(["src", "poster", "data"]);
|
|
18
78
|
var v0MathRoot = "math";
|
|
19
79
|
var v0GlobalAttributes = new Set(["id", "class", "lang", "xml:lang", "dir"]);
|
|
20
80
|
var v0ContentModel = {
|
|
21
81
|
interactionKinds: new Set(v0InteractionKinds),
|
|
22
82
|
flowElements: v0FlowElements,
|
|
23
83
|
mathRoot: v0MathRoot,
|
|
24
|
-
globalAttributes: v0GlobalAttributes
|
|
84
|
+
globalAttributes: v0GlobalAttributes,
|
|
85
|
+
elementAttributes: v0ElementAttributes,
|
|
86
|
+
urlAttributes: v0UrlAttributes
|
|
25
87
|
};
|
|
26
88
|
function isAllowedFlowElement(model, name) {
|
|
27
89
|
return model.flowElements.has(name) || name === model.mathRoot;
|
|
@@ -39,16 +101,17 @@ function isUnsafeAttribute(name, value) {
|
|
|
39
101
|
}
|
|
40
102
|
return false;
|
|
41
103
|
}
|
|
42
|
-
function sanitizeAttributes(model, attributes) {
|
|
104
|
+
function sanitizeAttributes(model, elementName, attributes) {
|
|
43
105
|
const safe = {};
|
|
44
106
|
if (!attributes) {
|
|
45
107
|
return safe;
|
|
46
108
|
}
|
|
109
|
+
const elementAllowed = model.elementAttributes.get(elementName);
|
|
47
110
|
for (const [name, value] of Object.entries(attributes)) {
|
|
48
111
|
if (isUnsafeAttribute(name, value)) {
|
|
49
112
|
continue;
|
|
50
113
|
}
|
|
51
|
-
if (!model.globalAttributes.has(name)) {
|
|
114
|
+
if (!model.globalAttributes.has(name) && !elementAllowed?.has(name)) {
|
|
52
115
|
continue;
|
|
53
116
|
}
|
|
54
117
|
if (typeof value === "string") {
|
|
@@ -57,6 +120,78 @@ function sanitizeAttributes(model, attributes) {
|
|
|
57
120
|
}
|
|
58
121
|
return safe;
|
|
59
122
|
}
|
|
123
|
+
function sanitizeMathAttributes(attributes) {
|
|
124
|
+
const safe = {};
|
|
125
|
+
if (!attributes) {
|
|
126
|
+
return safe;
|
|
127
|
+
}
|
|
128
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
129
|
+
if (!isUnsafeAttribute(name, value) && typeof value === "string") {
|
|
130
|
+
safe[name] = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return safe;
|
|
134
|
+
}
|
|
135
|
+
// src/graphic.ts
|
|
136
|
+
function parseCoords(coords) {
|
|
137
|
+
return coords.split(",").map((entry) => Number(entry.trim())).filter((value) => !Number.isNaN(value));
|
|
138
|
+
}
|
|
139
|
+
function parsePoint(value) {
|
|
140
|
+
const [x, y, ...rest] = value.trim().split(/\s+/u).map(Number);
|
|
141
|
+
if (x === undefined || y === undefined || rest.length > 0 || Number.isNaN(x) || Number.isNaN(y)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return { x, y };
|
|
145
|
+
}
|
|
146
|
+
function formatPoint(point) {
|
|
147
|
+
return `${point.x} ${point.y}`;
|
|
148
|
+
}
|
|
149
|
+
function pointInPolygon(coords, point) {
|
|
150
|
+
let inside = false;
|
|
151
|
+
for (let i = 0, j = coords.length - 2;i < coords.length; j = i, i += 2) {
|
|
152
|
+
const xi = coords[i];
|
|
153
|
+
const yi = coords[i + 1];
|
|
154
|
+
const xj = coords[j];
|
|
155
|
+
const yj = coords[j + 1];
|
|
156
|
+
const intersects = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
|
|
157
|
+
if (intersects) {
|
|
158
|
+
inside = !inside;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return inside;
|
|
162
|
+
}
|
|
163
|
+
function pointInShape(shape, coords, point) {
|
|
164
|
+
switch (shape) {
|
|
165
|
+
case "default":
|
|
166
|
+
return true;
|
|
167
|
+
case "circle": {
|
|
168
|
+
const [cx, cy, r] = coords;
|
|
169
|
+
if (cx === undefined || cy === undefined || r === undefined) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return (point.x - cx) ** 2 + (point.y - cy) ** 2 <= r ** 2;
|
|
173
|
+
}
|
|
174
|
+
case "rect": {
|
|
175
|
+
const [left, top, right, bottom] = coords;
|
|
176
|
+
if (left === undefined || top === undefined || right === undefined || bottom === undefined) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
|
|
180
|
+
}
|
|
181
|
+
case "ellipse": {
|
|
182
|
+
const [cx, cy, rx, ry] = coords;
|
|
183
|
+
if (cx === undefined || cy === undefined || rx === undefined || ry === undefined || rx === 0 || ry === 0) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return (point.x - cx) ** 2 / rx ** 2 + (point.y - cy) ** 2 / ry ** 2 <= 1;
|
|
187
|
+
}
|
|
188
|
+
case "poly":
|
|
189
|
+
return coords.length >= 6 && pointInPolygon(coords, point);
|
|
190
|
+
default:
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
60
195
|
// src/response-processing.ts
|
|
61
196
|
function foldString(value) {
|
|
62
197
|
return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLocaleLowerCase();
|
|
@@ -65,31 +200,62 @@ function asList(response) {
|
|
|
65
200
|
if (response === null) {
|
|
66
201
|
return [];
|
|
67
202
|
}
|
|
68
|
-
return typeof response === "string" ? [response] : [...response];
|
|
203
|
+
return typeof response === "string" ? [response] : Array.isArray(response) ? [...response] : [];
|
|
69
204
|
}
|
|
70
205
|
function isStringBaseType(declaration) {
|
|
71
206
|
return declaration.baseType === "string" || declaration.baseType === undefined;
|
|
72
207
|
}
|
|
73
|
-
function
|
|
74
|
-
|
|
208
|
+
function pairsEqual(a, b, directed) {
|
|
209
|
+
const [a1, a2] = a.trim().split(/\s+/u);
|
|
210
|
+
const [b1, b2] = b.trim().split(/\s+/u);
|
|
211
|
+
if (a1 === undefined || a2 === undefined || b1 === undefined || b2 === undefined) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (a1 === b1 && a2 === b2) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return !directed && a1 === b2 && a2 === b1;
|
|
218
|
+
}
|
|
219
|
+
var numericBaseTypes = new Set(["float", "integer"]);
|
|
220
|
+
function makeValueComparator(declaration, normalize) {
|
|
221
|
+
if (declaration.baseType === "pair") {
|
|
222
|
+
return (a, b) => pairsEqual(a, b, false);
|
|
223
|
+
}
|
|
224
|
+
if (declaration.baseType === "directedPair") {
|
|
225
|
+
return (a, b) => pairsEqual(a, b, true);
|
|
226
|
+
}
|
|
227
|
+
if (declaration.baseType !== undefined && numericBaseTypes.has(declaration.baseType)) {
|
|
228
|
+
return (a, b) => a.trim() !== "" && b.trim() !== "" && Number(a) === Number(b);
|
|
229
|
+
}
|
|
230
|
+
if (declaration.baseType === "point") {
|
|
231
|
+
return (a, b) => {
|
|
232
|
+
const pointA = parsePoint(a);
|
|
233
|
+
const pointB = parsePoint(b);
|
|
234
|
+
return pointA !== null && pointB !== null && pointA.x === pointB.x && pointA.y === pointB.y;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (normalize && isStringBaseType(declaration)) {
|
|
238
|
+
return (a, b) => normalize(a, declaration) === normalize(b, declaration);
|
|
239
|
+
}
|
|
240
|
+
return (a, b) => a === b;
|
|
75
241
|
}
|
|
76
|
-
function matchCorrect(declaration, response) {
|
|
242
|
+
function matchCorrect(declaration, response, normalize) {
|
|
77
243
|
const correct = declaration.correctResponse;
|
|
78
244
|
if (!correct) {
|
|
79
245
|
return false;
|
|
80
246
|
}
|
|
81
|
-
const
|
|
247
|
+
const equals = makeValueComparator(declaration, normalize);
|
|
82
248
|
const expected = correct.values.map((entry) => entry.value);
|
|
83
249
|
const actual = asList(response);
|
|
84
250
|
if (expected.length !== actual.length) {
|
|
85
251
|
return false;
|
|
86
252
|
}
|
|
87
253
|
if (declaration.cardinality === "ordered") {
|
|
88
|
-
return expected.every((value, index) =>
|
|
254
|
+
return expected.every((value, index) => equals(value, actual[index] ?? ""));
|
|
89
255
|
}
|
|
90
256
|
const remaining = [...actual];
|
|
91
257
|
for (const value of expected) {
|
|
92
|
-
const matchIndex = remaining.findIndex((candidate) =>
|
|
258
|
+
const matchIndex = remaining.findIndex((candidate) => equals(candidate, value));
|
|
93
259
|
if (matchIndex === -1) {
|
|
94
260
|
return false;
|
|
95
261
|
}
|
|
@@ -107,22 +273,73 @@ function clamp(value, lower, upper) {
|
|
|
107
273
|
}
|
|
108
274
|
return result;
|
|
109
275
|
}
|
|
110
|
-
function mapResponse(declaration, response) {
|
|
276
|
+
function mapResponse(declaration, response, normalize) {
|
|
111
277
|
const mapping = declaration.mapping;
|
|
112
278
|
if (!mapping) {
|
|
113
279
|
return 0;
|
|
114
280
|
}
|
|
281
|
+
const isPairType = declaration.baseType === "pair" || declaration.baseType === "directedPair";
|
|
282
|
+
const applyNormalize = normalize && isStringBaseType(declaration) ? normalize : undefined;
|
|
115
283
|
const defaultValue = mapping.defaultValue ?? 0;
|
|
116
284
|
let total = 0;
|
|
117
285
|
for (const member of asList(response)) {
|
|
118
|
-
const entry = mapping.mapEntries.find((candidate) =>
|
|
286
|
+
const entry = mapping.mapEntries.find((candidate) => {
|
|
287
|
+
if (isPairType) {
|
|
288
|
+
return pairsEqual(candidate.mapKey, member, declaration.baseType === "directedPair");
|
|
289
|
+
}
|
|
290
|
+
let key = candidate.mapKey;
|
|
291
|
+
let candidateMember = member;
|
|
292
|
+
if (candidate.caseSensitive === false) {
|
|
293
|
+
key = key.toLocaleLowerCase();
|
|
294
|
+
candidateMember = candidateMember.toLocaleLowerCase();
|
|
295
|
+
}
|
|
296
|
+
if (applyNormalize) {
|
|
297
|
+
key = applyNormalize(key, declaration);
|
|
298
|
+
candidateMember = applyNormalize(candidateMember, declaration);
|
|
299
|
+
}
|
|
300
|
+
return key === candidateMember;
|
|
301
|
+
});
|
|
119
302
|
total += entry ? entry.mappedValue : defaultValue;
|
|
120
303
|
}
|
|
121
304
|
return clamp(total, mapping.lowerBound, mapping.upperBound);
|
|
122
305
|
}
|
|
123
|
-
function
|
|
306
|
+
function mapResponsePoint(declaration, response) {
|
|
307
|
+
const areaMapping = declaration.areaMapping;
|
|
308
|
+
if (!areaMapping) {
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
const defaultValue = areaMapping.defaultValue ?? 0;
|
|
312
|
+
const usedAreas = new Set;
|
|
313
|
+
let total = 0;
|
|
314
|
+
for (const member of asList(response)) {
|
|
315
|
+
const point = parsePoint(member);
|
|
316
|
+
if (!point) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const areaIndex = areaMapping.areaMapEntries.findIndex((entry, index) => !usedAreas.has(index) && pointInShape(entry.shape, entry.coords, point));
|
|
320
|
+
if (areaIndex === -1) {
|
|
321
|
+
total += defaultValue;
|
|
322
|
+
} else {
|
|
323
|
+
usedAreas.add(areaIndex);
|
|
324
|
+
total += areaMapping.areaMapEntries[areaIndex].mappedValue;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return clamp(total, areaMapping.lowerBound, areaMapping.upperBound);
|
|
328
|
+
}
|
|
329
|
+
function scoreResponse(declaration, response, normalize) {
|
|
330
|
+
if (declaration.areaMapping) {
|
|
331
|
+
const score = mapResponsePoint(declaration, response);
|
|
332
|
+
const positiveSum = declaration.areaMapping.areaMapEntries.reduce((sum, entry) => sum + Math.max(entry.mappedValue, 0), 0);
|
|
333
|
+
const maxScore = declaration.areaMapping.upperBound ?? positiveSum;
|
|
334
|
+
return {
|
|
335
|
+
identifier: declaration.identifier,
|
|
336
|
+
score,
|
|
337
|
+
maxScore,
|
|
338
|
+
correct: maxScore > 0 && score >= maxScore
|
|
339
|
+
};
|
|
340
|
+
}
|
|
124
341
|
if (declaration.mapping) {
|
|
125
|
-
const score = mapResponse(declaration, response);
|
|
342
|
+
const score = mapResponse(declaration, response, normalize);
|
|
126
343
|
const positiveSum = declaration.mapping.mapEntries.reduce((sum, entry) => sum + Math.max(entry.mappedValue, 0), 0);
|
|
127
344
|
const maxScore = declaration.mapping.upperBound ?? positiveSum;
|
|
128
345
|
return {
|
|
@@ -132,7 +349,7 @@ function scoreResponse(declaration, response) {
|
|
|
132
349
|
correct: maxScore > 0 && score >= maxScore
|
|
133
350
|
};
|
|
134
351
|
}
|
|
135
|
-
const correct = matchCorrect(declaration, response);
|
|
352
|
+
const correct = matchCorrect(declaration, response, normalize);
|
|
136
353
|
return {
|
|
137
354
|
identifier: declaration.identifier,
|
|
138
355
|
score: correct ? 1 : 0,
|
|
@@ -140,215 +357,3409 @@ function scoreResponse(declaration, response) {
|
|
|
140
357
|
correct
|
|
141
358
|
};
|
|
142
359
|
}
|
|
143
|
-
// src/
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
360
|
+
// src/normalized-item.ts
|
|
361
|
+
function isRecord(value) {
|
|
362
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
363
|
+
}
|
|
364
|
+
function asRecords(value) {
|
|
365
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
366
|
+
}
|
|
367
|
+
var kindRenames = {
|
|
368
|
+
hotTextInteraction: "hottextInteraction",
|
|
369
|
+
hotText: "hottext"
|
|
370
|
+
};
|
|
371
|
+
function numberValue(value) {
|
|
372
|
+
if (typeof value === "number") {
|
|
373
|
+
return value;
|
|
157
374
|
}
|
|
158
|
-
|
|
159
|
-
|
|
375
|
+
if (typeof value === "string" && value !== "") {
|
|
376
|
+
const parsed = Number(value);
|
|
377
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
160
378
|
}
|
|
161
|
-
return
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
responses: { ...snapshot.responses, [responseIdentifier]: value }
|
|
176
|
-
});
|
|
177
|
-
},
|
|
178
|
-
submit: () => {
|
|
179
|
-
const scores = computeScores(snapshot.responses);
|
|
180
|
-
emit({ ...snapshot, submitted: true, scores });
|
|
181
|
-
return scores;
|
|
182
|
-
},
|
|
183
|
-
reset: () => {
|
|
184
|
-
emit({ responses: { ...initialResponses }, submitted: false, scores: [] });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
function findImageSource(node) {
|
|
382
|
+
if (!isRecord(node)) {
|
|
383
|
+
return {};
|
|
384
|
+
}
|
|
385
|
+
const attributes = isRecord(node["attributes"]) ? node["attributes"] : {};
|
|
386
|
+
if (typeof attributes["data"] === "string" || typeof attributes["src"] === "string") {
|
|
387
|
+
return attributes;
|
|
388
|
+
}
|
|
389
|
+
for (const child of asRecords(node["children"])) {
|
|
390
|
+
const found = findImageSource(child);
|
|
391
|
+
if (typeof found["data"] === "string" || typeof found["src"] === "string") {
|
|
392
|
+
return found;
|
|
185
393
|
}
|
|
186
|
-
}
|
|
394
|
+
}
|
|
395
|
+
return attributes;
|
|
187
396
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
397
|
+
function toStageObject(node) {
|
|
398
|
+
const attributes = findImageSource(node);
|
|
399
|
+
const data = attributes["data"] ?? attributes["src"];
|
|
400
|
+
const width = numberValue(attributes["width"]);
|
|
401
|
+
const height = numberValue(attributes["height"]);
|
|
402
|
+
return {
|
|
403
|
+
data: typeof data === "string" ? data : "",
|
|
404
|
+
...width !== undefined ? { width } : {},
|
|
405
|
+
...height !== undefined ? { height } : {},
|
|
406
|
+
...typeof attributes["type"] === "string" ? { type: attributes["type"] } : {}
|
|
407
|
+
};
|
|
198
408
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (!context) {
|
|
203
|
-
throw new Error("QTI runtime components must be rendered inside an <ItemRenderer>.");
|
|
409
|
+
function flattenText(value) {
|
|
410
|
+
if (typeof value === "string") {
|
|
411
|
+
return value;
|
|
204
412
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (value
|
|
209
|
-
return
|
|
413
|
+
if (Array.isArray(value)) {
|
|
414
|
+
return value.map(flattenText).join("");
|
|
415
|
+
}
|
|
416
|
+
if (!isRecord(value)) {
|
|
417
|
+
return "";
|
|
210
418
|
}
|
|
211
|
-
|
|
419
|
+
if (value["kind"] === "text") {
|
|
420
|
+
return typeof value["value"] === "string" ? value["value"] : "";
|
|
421
|
+
}
|
|
422
|
+
return flattenText(value["children"]) + flattenText(value["content"]) + flattenText(value["value"]);
|
|
212
423
|
}
|
|
213
|
-
function
|
|
214
|
-
return
|
|
424
|
+
function withNumericCoords(node) {
|
|
425
|
+
return typeof node["coords"] === "string" ? { ...node, coords: parseCoords(node["coords"]) } : node;
|
|
215
426
|
}
|
|
216
|
-
function
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const isMath = node.name === model.mathRoot;
|
|
221
|
-
if (!isMath && !isAllowedFlowElement(model, node.name)) {
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
const attributes = sanitizeAttributes(model, node.attributes);
|
|
225
|
-
const children = node.children?.map((child, index) => renderNode(child, index));
|
|
226
|
-
return createElement(node.name, { key, ...attributes }, node.value ?? children);
|
|
427
|
+
function reshapeContentNode(node) {
|
|
428
|
+
const kind = node["kind"];
|
|
429
|
+
if (typeof kind !== "string") {
|
|
430
|
+
return node;
|
|
227
431
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
432
|
+
const renamed = kindRenames[kind];
|
|
433
|
+
if (renamed !== undefined) {
|
|
434
|
+
return { ...node, kind: renamed };
|
|
435
|
+
}
|
|
436
|
+
switch (kind) {
|
|
437
|
+
case "hotspotChoice":
|
|
438
|
+
case "associableHotspot":
|
|
439
|
+
return withNumericCoords(node);
|
|
440
|
+
case "hotspotInteraction":
|
|
441
|
+
case "graphicOrderInteraction":
|
|
442
|
+
case "graphicAssociateInteraction":
|
|
443
|
+
case "selectPointInteraction": {
|
|
444
|
+
const { image, ...rest } = node;
|
|
445
|
+
return { ...rest, object: toStageObject(image) };
|
|
231
446
|
}
|
|
232
|
-
|
|
233
|
-
|
|
447
|
+
case "drawingInteraction": {
|
|
448
|
+
const { content, ...rest } = node;
|
|
449
|
+
const media = asRecords(content).find((fragment) => fragment["kind"] === "xml" && typeof fragment["name"] === "string" && ["object", "picture", "img"].includes(fragment["name"]));
|
|
450
|
+
return { ...rest, object: toStageObject(media) };
|
|
234
451
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const { store, declarationsById } = useRuntimeContext();
|
|
240
|
-
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
241
|
-
const responseIdentifier = node.responseIdentifier;
|
|
242
|
-
const declaration = declarationsById.get(responseIdentifier);
|
|
243
|
-
const cardinality = declaration?.cardinality ?? "single";
|
|
244
|
-
const value = snapshot.responses[responseIdentifier] ?? null;
|
|
245
|
-
const disabled = snapshot.submitted;
|
|
246
|
-
const answered = value !== null && !(typeof value === "string" && value.trim() === "") && !(Array.isArray(value) && value.length === 0);
|
|
247
|
-
let status = answered ? "answered" : "unanswered";
|
|
248
|
-
if (disabled) {
|
|
249
|
-
const scored = snapshot.scores.find((score) => score.identifier === responseIdentifier);
|
|
250
|
-
status = scored?.correct ? "correct" : "incorrect";
|
|
452
|
+
case "graphicGapMatchInteraction": {
|
|
453
|
+
const { image, gapChoices, ...rest } = node;
|
|
454
|
+
const gapImgs = asRecords(gapChoices).map(({ media, content, ...choice }) => choice["kind"] === "gapImg" ? { ...choice, object: toStageObject(media) } : { ...choice, label: flattenText(content) });
|
|
455
|
+
return { ...rest, object: toStageObject(image), gapImgs };
|
|
251
456
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
457
|
+
case "gapMatchInteraction": {
|
|
458
|
+
const gapTexts = asRecords(node["gapChoices"]).filter((choice) => choice["kind"] === "gapText");
|
|
459
|
+
return { ...node, gapTexts };
|
|
460
|
+
}
|
|
461
|
+
case "mediaInteraction": {
|
|
462
|
+
const { media, ...rest } = node;
|
|
463
|
+
return { ...rest, content: media === undefined ? [] : [media] };
|
|
464
|
+
}
|
|
465
|
+
case "uploadInteraction": {
|
|
466
|
+
const acceptedTypes = node["acceptedTypes"];
|
|
467
|
+
return Array.isArray(acceptedTypes) ? { ...node, type: acceptedTypes.join(",") } : node;
|
|
468
|
+
}
|
|
469
|
+
case "positionObjectStage": {
|
|
470
|
+
const interactions = asRecords(node["positionObjectInteractions"]);
|
|
471
|
+
const [first] = interactions;
|
|
472
|
+
if (interactions.length === 1 && first) {
|
|
473
|
+
return {
|
|
474
|
+
kind: "positionObjectStage",
|
|
475
|
+
responseIdentifier: first["responseIdentifier"],
|
|
476
|
+
stageObject: toStageObject(node["image"]),
|
|
477
|
+
object: toStageObject(first["image"]),
|
|
478
|
+
...first["maxChoices"] !== undefined ? { maxChoices: first["maxChoices"] } : {},
|
|
479
|
+
...first["minChoices"] !== undefined ? { minChoices: first["minChoices"] } : {}
|
|
480
|
+
};
|
|
266
481
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
tabIndex: 0,
|
|
270
|
-
"aria-checked": selected,
|
|
271
|
-
"aria-disabled": disabled,
|
|
272
|
-
"data-status": status2,
|
|
273
|
-
onClick: () => {
|
|
274
|
-
if (disabled) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (cardinality === "single") {
|
|
278
|
-
setValue(optionIdentifier);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
const current = value === null ? [] : typeof value === "string" ? [value] : [...value];
|
|
282
|
-
const next = selected ? current.filter((entry) => entry !== optionIdentifier) : [...current, optionIdentifier];
|
|
283
|
-
setValue(next);
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
};
|
|
287
|
-
const renderContent = (nodes) => nodes ? nodes.map((child, index) => renderNode(child, index)) : null;
|
|
288
|
-
const Skin = config.skin[node.kind];
|
|
289
|
-
if (!Skin) {
|
|
290
|
-
return null;
|
|
482
|
+
const responseIdentifier = first?.["responseIdentifier"];
|
|
483
|
+
return { ...node, ...typeof responseIdentifier === "string" ? { responseIdentifier } : {} };
|
|
291
484
|
}
|
|
292
|
-
|
|
293
|
-
node
|
|
294
|
-
responseIdentifier,
|
|
295
|
-
value,
|
|
296
|
-
setValue,
|
|
297
|
-
disabled,
|
|
298
|
-
showFeedback: disabled,
|
|
299
|
-
status,
|
|
300
|
-
getOptionProps,
|
|
301
|
-
renderContent
|
|
302
|
-
});
|
|
485
|
+
default:
|
|
486
|
+
return node;
|
|
303
487
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return createAttemptStore(item.responseDeclarations, initial);
|
|
309
|
-
}, [item]);
|
|
310
|
-
const declarationsById = useMemo(() => new Map(item.responseDeclarations.map((declaration) => [declaration.identifier, declaration])), [item]);
|
|
311
|
-
const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
|
|
312
|
-
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, children);
|
|
488
|
+
}
|
|
489
|
+
function convertContentEntry(entry) {
|
|
490
|
+
if (typeof entry === "string") {
|
|
491
|
+
return { kind: "text", value: entry };
|
|
313
492
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
493
|
+
return convertContentValue(entry);
|
|
494
|
+
}
|
|
495
|
+
function convertContentValue(value) {
|
|
496
|
+
if (Array.isArray(value)) {
|
|
497
|
+
return value.map(convertContentValue);
|
|
498
|
+
}
|
|
499
|
+
if (!isRecord(value)) {
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
const converted = Object.fromEntries(Object.entries(value).map(([key, entry]) => {
|
|
503
|
+
if ((key === "children" || key === "content") && Array.isArray(entry)) {
|
|
504
|
+
return [key, entry.map(convertContentEntry)];
|
|
505
|
+
}
|
|
506
|
+
return [key, convertContentValue(entry)];
|
|
507
|
+
}));
|
|
508
|
+
return reshapeContentNode(converted);
|
|
509
|
+
}
|
|
510
|
+
function convertExpression(expression) {
|
|
511
|
+
const record = isRecord(expression) ? expression : {};
|
|
512
|
+
const { children, ...rest } = record;
|
|
513
|
+
return {
|
|
514
|
+
...rest,
|
|
515
|
+
...Array.isArray(children) ? { expressions: children.map(convertExpression) } : {}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function convertBranch(branch, convertRule) {
|
|
519
|
+
const record = isRecord(branch) ? branch : {};
|
|
520
|
+
return {
|
|
521
|
+
expression: convertExpression(record["expression"]),
|
|
522
|
+
rules: (Array.isArray(record["actions"]) ? record["actions"] : []).map(convertRule)
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function convertRpRule(rule) {
|
|
526
|
+
const record = isRecord(rule) ? rule : {};
|
|
527
|
+
const kind = typeof record["kind"] === "string" ? record["kind"] : "";
|
|
528
|
+
if (kind === "responseCondition") {
|
|
529
|
+
const elseIfs = Array.isArray(record["responseElseIf"]) ? record["responseElseIf"].map((branch) => convertBranch(branch, convertRpRule)) : [];
|
|
317
530
|
return {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
531
|
+
kind,
|
|
532
|
+
responseIf: convertBranch(record["responseIf"], convertRpRule),
|
|
533
|
+
...elseIfs.length ? { responseElseIfs: elseIfs } : {},
|
|
534
|
+
...isRecord(record["responseElse"]) ? {
|
|
535
|
+
responseElse: {
|
|
536
|
+
rules: (Array.isArray(record["responseElse"]["actions"]) ? record["responseElse"]["actions"] : []).map(convertRpRule)
|
|
537
|
+
}
|
|
538
|
+
} : {}
|
|
321
539
|
};
|
|
322
540
|
}
|
|
323
|
-
return {
|
|
541
|
+
return {
|
|
542
|
+
kind,
|
|
543
|
+
...typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {},
|
|
544
|
+
...record["expression"] !== undefined ? { expression: convertExpression(record["expression"]) } : {}
|
|
545
|
+
};
|
|
324
546
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
kind
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
547
|
+
function convertTemplateRule(rule) {
|
|
548
|
+
const record = isRecord(rule) ? rule : {};
|
|
549
|
+
const kind = typeof record["kind"] === "string" ? record["kind"] : "";
|
|
550
|
+
if (kind === "templateCondition") {
|
|
551
|
+
const elseIfs = Array.isArray(record["templateElseIf"]) ? record["templateElseIf"].map((branch) => convertBranch(branch, convertTemplateRule)) : [];
|
|
552
|
+
return {
|
|
553
|
+
kind,
|
|
554
|
+
templateIf: convertBranch(record["templateIf"], convertTemplateRule),
|
|
555
|
+
...elseIfs.length ? { templateElseIfs: elseIfs } : {},
|
|
556
|
+
...isRecord(record["templateElse"]) ? {
|
|
557
|
+
templateElse: {
|
|
558
|
+
rules: (Array.isArray(record["templateElse"]["actions"]) ? record["templateElse"]["actions"] : []).map(convertTemplateRule)
|
|
559
|
+
}
|
|
560
|
+
} : {}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
kind,
|
|
565
|
+
...typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {},
|
|
566
|
+
...record["expression"] !== undefined ? { expression: convertExpression(record["expression"]) } : {}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function convertResponseProcessing(value) {
|
|
570
|
+
return {
|
|
571
|
+
...typeof value["template"] === "string" ? { template: value["template"] } : {},
|
|
572
|
+
...Array.isArray(value["rules"]) ? { rules: value["rules"].map(convertRpRule) } : {}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function convertResponseDeclaration(declaration) {
|
|
576
|
+
const areaMapping = declaration["areaMapping"];
|
|
577
|
+
if (!isRecord(areaMapping)) {
|
|
578
|
+
return declaration;
|
|
579
|
+
}
|
|
580
|
+
const areaMapEntries = asRecords(areaMapping["areaMapEntries"]).map((entry) => withNumericCoords(entry));
|
|
581
|
+
return { ...declaration, areaMapping: { ...areaMapping, areaMapEntries } };
|
|
582
|
+
}
|
|
583
|
+
function assessmentItemViewFromNormalized(document) {
|
|
584
|
+
if (!isRecord(document) || !isRecord(document["assessmentItem"])) {
|
|
338
585
|
return null;
|
|
339
586
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
587
|
+
const item = document["assessmentItem"];
|
|
588
|
+
const itemBody = isRecord(item["itemBody"]) ? item["itemBody"] : {};
|
|
589
|
+
const content = Array.isArray(itemBody["content"]) ? itemBody["content"].map(convertContentEntry) : [];
|
|
590
|
+
const templateRules = isRecord(item["templateProcessing"]) ? item["templateProcessing"]["rules"] : undefined;
|
|
591
|
+
return {
|
|
592
|
+
responseDeclarations: asRecords(item["responseDeclarations"]).map(convertResponseDeclaration),
|
|
593
|
+
outcomeDeclarations: item["outcomeDeclarations"] ?? [],
|
|
594
|
+
...isRecord(item["responseProcessing"]) ? { responseProcessing: convertResponseProcessing(item["responseProcessing"]) } : {},
|
|
595
|
+
...Array.isArray(item["templateDeclarations"]) ? { templateDeclarations: item["templateDeclarations"] } : {},
|
|
596
|
+
...Array.isArray(templateRules) ? {
|
|
597
|
+
templateProcessing: {
|
|
598
|
+
rules: templateRules.map(convertTemplateRule)
|
|
599
|
+
}
|
|
600
|
+
} : {},
|
|
601
|
+
...typeof item["adaptive"] === "boolean" ? { adaptive: item["adaptive"] } : {},
|
|
602
|
+
...Array.isArray(item["modalFeedbacks"]) ? { modalFeedbacks: item["modalFeedbacks"].map(convertContentValue) } : {},
|
|
603
|
+
itemBody: { content }
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function convertPreConditions(value) {
|
|
607
|
+
return asRecords(value).map((wrapper) => convertExpression(wrapper["expression"]));
|
|
608
|
+
}
|
|
609
|
+
function convertBranchRules(value) {
|
|
610
|
+
return asRecords(value).map((rule) => ({
|
|
611
|
+
target: typeof rule["target"] === "string" ? rule["target"] : "",
|
|
612
|
+
expression: convertExpression(rule["expression"])
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
function convertOutcomeRule(rule) {
|
|
616
|
+
const record = isRecord(rule) ? rule : {};
|
|
617
|
+
const kind = typeof record["kind"] === "string" ? record["kind"] : "";
|
|
618
|
+
if (kind === "outcomeCondition") {
|
|
619
|
+
const elseIfs = Array.isArray(record["outcomeElseIf"]) ? record["outcomeElseIf"].map((branch) => convertBranch(branch, convertOutcomeRule)) : [];
|
|
620
|
+
return {
|
|
621
|
+
kind,
|
|
622
|
+
outcomeIf: convertBranch(record["outcomeIf"], convertOutcomeRule),
|
|
623
|
+
...elseIfs.length ? { outcomeElseIfs: elseIfs } : {},
|
|
624
|
+
...isRecord(record["outcomeElse"]) ? {
|
|
625
|
+
outcomeElse: {
|
|
626
|
+
rules: (Array.isArray(record["outcomeElse"]["actions"]) ? record["outcomeElse"]["actions"] : []).map(convertOutcomeRule)
|
|
627
|
+
}
|
|
628
|
+
} : {}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
kind,
|
|
633
|
+
...typeof record["identifier"] === "string" ? { identifier: record["identifier"] } : {},
|
|
634
|
+
...record["expression"] !== undefined ? { expression: convertExpression(record["expression"]) } : {}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
function sessionControlAndTimeLimits(record) {
|
|
638
|
+
return {
|
|
639
|
+
...isRecord(record["itemSessionControl"]) ? { itemSessionControl: record["itemSessionControl"] } : {},
|
|
640
|
+
...isRecord(record["timeLimits"]) ? { timeLimits: record["timeLimits"] } : {}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function convertItemRef(ref) {
|
|
644
|
+
return {
|
|
645
|
+
kind: "assessmentItemRef",
|
|
646
|
+
identifier: typeof ref["identifier"] === "string" ? ref["identifier"] : "",
|
|
647
|
+
...typeof ref["href"] === "string" ? { href: ref["href"] } : {},
|
|
648
|
+
...Array.isArray(ref["category"]) ? { categories: ref["category"] } : {},
|
|
649
|
+
...typeof ref["fixed"] === "boolean" ? { fixed: ref["fixed"] } : {},
|
|
650
|
+
...typeof ref["required"] === "boolean" ? { required: ref["required"] } : {},
|
|
651
|
+
...ref["preConditions"] !== undefined ? { preConditions: convertPreConditions(ref["preConditions"]) } : {},
|
|
652
|
+
...ref["branchRules"] !== undefined ? { branchRules: convertBranchRules(ref["branchRules"]) } : {},
|
|
653
|
+
...Array.isArray(ref["weights"]) ? { weights: ref["weights"] } : {},
|
|
654
|
+
...sessionControlAndTimeLimits(ref)
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function convertSection(section) {
|
|
658
|
+
const children = asRecords(section["children"]).map((child) => child["visible"] !== undefined || child["children"] !== undefined ? convertSection(child) : convertItemRef(child));
|
|
659
|
+
return {
|
|
660
|
+
kind: "assessmentSection",
|
|
661
|
+
identifier: typeof section["identifier"] === "string" ? section["identifier"] : "",
|
|
662
|
+
...typeof section["title"] === "string" ? { title: section["title"] } : {},
|
|
663
|
+
...typeof section["visible"] === "boolean" ? { visible: section["visible"] } : {},
|
|
664
|
+
...typeof section["fixed"] === "boolean" ? { fixed: section["fixed"] } : {},
|
|
665
|
+
...typeof section["required"] === "boolean" ? { required: section["required"] } : {},
|
|
666
|
+
...isRecord(section["selection"]) ? { selection: section["selection"] } : {},
|
|
667
|
+
...isRecord(section["ordering"]) ? { ordering: section["ordering"] } : {},
|
|
668
|
+
...section["preConditions"] !== undefined ? { preConditions: convertPreConditions(section["preConditions"]) } : {},
|
|
669
|
+
...section["branchRules"] !== undefined ? { branchRules: convertBranchRules(section["branchRules"]) } : {},
|
|
670
|
+
...sessionControlAndTimeLimits(section),
|
|
671
|
+
children
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
function convertTestFeedback(feedback) {
|
|
675
|
+
const converted = convertContentValue(feedback);
|
|
676
|
+
return {
|
|
677
|
+
outcomeIdentifier: typeof converted["outcomeIdentifier"] === "string" ? converted["outcomeIdentifier"] : "",
|
|
678
|
+
identifier: typeof converted["identifier"] === "string" ? converted["identifier"] : "",
|
|
679
|
+
...converted["access"] === "atEnd" || converted["access"] === "during" ? { access: converted["access"] } : {},
|
|
680
|
+
...converted["showHide"] === "show" || converted["showHide"] === "hide" ? { showHide: converted["showHide"] } : {},
|
|
681
|
+
...Array.isArray(converted["content"]) ? { content: converted["content"] } : {}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function assessmentTestViewFromNormalized(document) {
|
|
685
|
+
if (!isRecord(document) || !isRecord(document["assessmentTest"])) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
const testDocument = document["assessmentTest"];
|
|
689
|
+
const outcomeRules = isRecord(testDocument["outcomeProcessing"]) ? testDocument["outcomeProcessing"]["rules"] : undefined;
|
|
690
|
+
const testParts = asRecords(testDocument["testParts"]).map((part) => ({
|
|
691
|
+
identifier: typeof part["identifier"] === "string" ? part["identifier"] : "",
|
|
692
|
+
navigationMode: part["navigationMode"] === "nonlinear" ? "nonlinear" : "linear",
|
|
693
|
+
submissionMode: part["submissionMode"] === "simultaneous" ? "simultaneous" : "individual",
|
|
694
|
+
...part["preConditions"] !== undefined ? { preConditions: convertPreConditions(part["preConditions"]) } : {},
|
|
695
|
+
...part["branchRules"] !== undefined ? { branchRules: convertBranchRules(part["branchRules"]) } : {},
|
|
696
|
+
...sessionControlAndTimeLimits(part),
|
|
697
|
+
assessmentSections: asRecords(part["children"]).map(convertSection)
|
|
698
|
+
}));
|
|
699
|
+
return {
|
|
700
|
+
identifier: typeof testDocument["identifier"] === "string" ? testDocument["identifier"] : "",
|
|
701
|
+
...typeof testDocument["title"] === "string" ? { title: testDocument["title"] } : {},
|
|
702
|
+
outcomeDeclarations: testDocument["outcomeDeclarations"] ?? [],
|
|
703
|
+
...isRecord(testDocument["timeLimits"]) ? { timeLimits: testDocument["timeLimits"] } : {},
|
|
704
|
+
testParts,
|
|
705
|
+
...Array.isArray(outcomeRules) ? { outcomeProcessing: { rules: outcomeRules.map(convertOutcomeRule) } } : {},
|
|
706
|
+
...Array.isArray(testDocument["testFeedbacks"]) ? { testFeedbacks: asRecords(testDocument["testFeedbacks"]).map(convertTestFeedback) } : {}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
// src/types.ts
|
|
710
|
+
function isResponseRecord(value) {
|
|
711
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/rp/values.ts
|
|
715
|
+
var numericBaseTypes2 = new Set(["float", "integer", "duration"]);
|
|
716
|
+
function isNumericBaseType(baseType) {
|
|
717
|
+
return baseType !== undefined && numericBaseTypes2.has(baseType);
|
|
718
|
+
}
|
|
719
|
+
function coerceScalar(value, baseType) {
|
|
720
|
+
if (isNumericBaseType(baseType) && typeof value === "string" && value.trim() !== "") {
|
|
721
|
+
const parsed = Number(value);
|
|
722
|
+
if (!Number.isNaN(parsed)) {
|
|
723
|
+
return parsed;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (baseType === "boolean" && typeof value === "string") {
|
|
727
|
+
return value === "true";
|
|
728
|
+
}
|
|
729
|
+
return value;
|
|
730
|
+
}
|
|
731
|
+
function fieldBaseType(value) {
|
|
732
|
+
return typeof value === "boolean" ? "boolean" : typeof value === "number" ? "float" : "string";
|
|
733
|
+
}
|
|
734
|
+
function fromResponse(declaration, response) {
|
|
735
|
+
if (response === null) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
if (isResponseRecord(response)) {
|
|
739
|
+
const fields = Object.entries(response).flatMap(([name, member]) => member === null ? [] : [{ name, baseType: fieldBaseType(member), value: member }]);
|
|
740
|
+
return fields.length === 0 ? null : { cardinality: "record", fields, values: fields.map((field) => field.value) };
|
|
741
|
+
}
|
|
742
|
+
const raw = typeof response === "string" ? [response] : [...response];
|
|
743
|
+
if (raw.length === 0) {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
return rpValue(declaration.cardinality, raw.map((value) => coerceScalar(value, declaration.baseType)), declaration.baseType);
|
|
747
|
+
}
|
|
748
|
+
function singleNumber(value) {
|
|
749
|
+
if (value === null || value.values.length !== 1) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
const member = value.values[0];
|
|
753
|
+
return typeof member === "number" ? member : null;
|
|
754
|
+
}
|
|
755
|
+
function singleBoolean(value) {
|
|
756
|
+
if (value === null || value.values.length !== 1) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
const member = value.values[0];
|
|
760
|
+
return typeof member === "boolean" ? member : null;
|
|
761
|
+
}
|
|
762
|
+
function rpValue(cardinality, values, baseType) {
|
|
763
|
+
return {
|
|
764
|
+
cardinality,
|
|
765
|
+
values,
|
|
766
|
+
...baseType !== undefined ? { baseType } : {}
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function booleanValue(value) {
|
|
770
|
+
return { cardinality: "single", baseType: "boolean", values: [value] };
|
|
771
|
+
}
|
|
772
|
+
function floatValue(value) {
|
|
773
|
+
return { cardinality: "single", baseType: "float", values: [value] };
|
|
774
|
+
}
|
|
775
|
+
function pairsEqual2(a, b, directed) {
|
|
776
|
+
const [a1, a2] = a.trim().split(/\s+/u);
|
|
777
|
+
const [b1, b2] = b.trim().split(/\s+/u);
|
|
778
|
+
if (a1 === undefined || a2 === undefined || b1 === undefined || b2 === undefined) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
if (a1 === b1 && a2 === b2) {
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
return !directed && a1 === b2 && a2 === b1;
|
|
785
|
+
}
|
|
786
|
+
function scalarsEqual(a, b, baseType, normalize) {
|
|
787
|
+
if (baseType === "pair" || baseType === "directedPair") {
|
|
788
|
+
return typeof a === "string" && typeof b === "string" && pairsEqual2(a, b, baseType === "directedPair");
|
|
789
|
+
}
|
|
790
|
+
if (baseType === "point") {
|
|
791
|
+
if (typeof a !== "string" || typeof b !== "string") {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
const pointA = parsePoint(a);
|
|
795
|
+
const pointB = parsePoint(b);
|
|
796
|
+
return pointA !== null && pointB !== null && pointA.x === pointB.x && pointA.y === pointB.y;
|
|
797
|
+
}
|
|
798
|
+
if (typeof a === "number" || typeof b === "number") {
|
|
799
|
+
return Number(a) === Number(b);
|
|
800
|
+
}
|
|
801
|
+
if (normalize && baseType === "string" && typeof a === "string" && typeof b === "string") {
|
|
802
|
+
return normalize(a) === normalize(b);
|
|
803
|
+
}
|
|
804
|
+
return a === b;
|
|
805
|
+
}
|
|
806
|
+
function valuesMatch(a, b, normalize) {
|
|
807
|
+
const baseType = a.baseType ?? b.baseType;
|
|
808
|
+
if (a.values.length !== b.values.length) {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
if (a.cardinality === "ordered" || b.cardinality === "ordered") {
|
|
812
|
+
return a.values.every((value, index) => scalarsEqual(value, b.values[index], baseType, normalize));
|
|
813
|
+
}
|
|
814
|
+
const remaining = [...b.values];
|
|
815
|
+
for (const value of a.values) {
|
|
816
|
+
const matchIndex = remaining.findIndex((candidate) => scalarsEqual(candidate, value, baseType, normalize));
|
|
817
|
+
if (matchIndex === -1) {
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
remaining.splice(matchIndex, 1);
|
|
821
|
+
}
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
function fromFlatValue(value, cardinality, baseType) {
|
|
825
|
+
if (value === null) {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
const values = Array.isArray(value) ? [...value] : [value];
|
|
829
|
+
if (values.length === 0) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
return rpValue(cardinality, values.map((member) => coerceScalar(member, baseType)), baseType);
|
|
833
|
+
}
|
|
834
|
+
function toOutcomeValue(value) {
|
|
835
|
+
if (value === null || value.values.length === 0) {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
if (value.cardinality === "single") {
|
|
839
|
+
return value.values[0] ?? null;
|
|
840
|
+
}
|
|
841
|
+
return value.values;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/rp/evaluate.ts
|
|
845
|
+
var deterministicExpressionKinds = new Set([
|
|
846
|
+
"and",
|
|
847
|
+
"baseValue",
|
|
848
|
+
"correct",
|
|
849
|
+
"delete",
|
|
850
|
+
"divide",
|
|
851
|
+
"equal",
|
|
852
|
+
"equalRounded",
|
|
853
|
+
"fieldValue",
|
|
854
|
+
"gcd",
|
|
855
|
+
"gt",
|
|
856
|
+
"gte",
|
|
857
|
+
"index",
|
|
858
|
+
"inside",
|
|
859
|
+
"lcm",
|
|
860
|
+
"integerDivide",
|
|
861
|
+
"integerModulus",
|
|
862
|
+
"integerToFloat",
|
|
863
|
+
"isNull",
|
|
864
|
+
"lt",
|
|
865
|
+
"lte",
|
|
866
|
+
"mapResponse",
|
|
867
|
+
"mapResponsePoint",
|
|
868
|
+
"match",
|
|
869
|
+
"mathConstant",
|
|
870
|
+
"mathOperator",
|
|
871
|
+
"max",
|
|
872
|
+
"member",
|
|
873
|
+
"min",
|
|
874
|
+
"multiple",
|
|
875
|
+
"not",
|
|
876
|
+
"or",
|
|
877
|
+
"ordered",
|
|
878
|
+
"product",
|
|
879
|
+
"repeat",
|
|
880
|
+
"round",
|
|
881
|
+
"roundTo",
|
|
882
|
+
"statsOperator",
|
|
883
|
+
"stringMatch",
|
|
884
|
+
"substring",
|
|
885
|
+
"subtract",
|
|
886
|
+
"sum",
|
|
887
|
+
"truncate",
|
|
888
|
+
"variable"
|
|
889
|
+
]);
|
|
890
|
+
var randomExpressionKinds = new Set(["random", "randomFloat", "randomInteger"]);
|
|
891
|
+
|
|
892
|
+
class RpUnsupportedError extends Error {
|
|
893
|
+
kindName;
|
|
894
|
+
constructor(kindName) {
|
|
895
|
+
super(`Unsupported response-processing construct: ${kindName}`);
|
|
896
|
+
this.kindName = kindName;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
var mathConstants = { pi: Math.PI, e: Math.E };
|
|
900
|
+
function applyMathOperator(name, x, y) {
|
|
901
|
+
switch (name) {
|
|
902
|
+
case "sin":
|
|
903
|
+
return Math.sin(x);
|
|
904
|
+
case "cos":
|
|
905
|
+
return Math.cos(x);
|
|
906
|
+
case "tan":
|
|
907
|
+
return Math.tan(x);
|
|
908
|
+
case "sec":
|
|
909
|
+
return 1 / Math.cos(x);
|
|
910
|
+
case "csc":
|
|
911
|
+
return 1 / Math.sin(x);
|
|
912
|
+
case "cot":
|
|
913
|
+
return Math.cos(x) / Math.sin(x);
|
|
914
|
+
case "asin":
|
|
915
|
+
return Math.asin(x);
|
|
916
|
+
case "acos":
|
|
917
|
+
return Math.acos(x);
|
|
918
|
+
case "atan":
|
|
919
|
+
return Math.atan(x);
|
|
920
|
+
case "atan2":
|
|
921
|
+
return Math.atan2(x, y);
|
|
922
|
+
case "asec":
|
|
923
|
+
return Math.acos(1 / x);
|
|
924
|
+
case "acsc":
|
|
925
|
+
return Math.asin(1 / x);
|
|
926
|
+
case "acot":
|
|
927
|
+
return Math.atan(1 / x);
|
|
928
|
+
case "sinh":
|
|
929
|
+
return Math.sinh(x);
|
|
930
|
+
case "cosh":
|
|
931
|
+
return Math.cosh(x);
|
|
932
|
+
case "tanh":
|
|
933
|
+
return Math.tanh(x);
|
|
934
|
+
case "sech":
|
|
935
|
+
return 1 / Math.cosh(x);
|
|
936
|
+
case "csch":
|
|
937
|
+
return 1 / Math.sinh(x);
|
|
938
|
+
case "coth":
|
|
939
|
+
return Math.cosh(x) / Math.sinh(x);
|
|
940
|
+
case "log":
|
|
941
|
+
return Math.log10(x);
|
|
942
|
+
case "ln":
|
|
943
|
+
return Math.log(x);
|
|
944
|
+
case "exp":
|
|
945
|
+
return Math.exp(x);
|
|
946
|
+
case "abs":
|
|
947
|
+
return Math.abs(x);
|
|
948
|
+
case "signum":
|
|
949
|
+
return Math.sign(x);
|
|
950
|
+
case "floor":
|
|
951
|
+
return Math.floor(x);
|
|
952
|
+
case "ceil":
|
|
953
|
+
return Math.ceil(x);
|
|
954
|
+
case "toDegrees":
|
|
955
|
+
return x * 180 / Math.PI;
|
|
956
|
+
case "toRadians":
|
|
957
|
+
return x * Math.PI / 180;
|
|
958
|
+
default:
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function roundToFigures(value, mode, figures) {
|
|
963
|
+
if (mode === "decimalPlaces") {
|
|
964
|
+
const scale2 = 10 ** figures;
|
|
965
|
+
return Math.round(value * scale2) / scale2;
|
|
966
|
+
}
|
|
967
|
+
if (figures < 1) {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
if (value === 0) {
|
|
971
|
+
return 0;
|
|
972
|
+
}
|
|
973
|
+
const magnitude = Math.floor(Math.log10(Math.abs(value)));
|
|
974
|
+
const scale = 10 ** (figures - 1 - magnitude);
|
|
975
|
+
return Math.round(value * scale) / scale;
|
|
976
|
+
}
|
|
977
|
+
function evaluateExpression(expression, env) {
|
|
978
|
+
function evaluate(child) {
|
|
979
|
+
return evaluateExpression(child, env);
|
|
980
|
+
}
|
|
981
|
+
switch (expression.kind) {
|
|
982
|
+
case "baseValue": {
|
|
983
|
+
const baseType = expression.baseType;
|
|
984
|
+
const value = expression.value;
|
|
985
|
+
return value === undefined ? null : rpValue("single", [coerceScalar(value, baseType)], baseType);
|
|
986
|
+
}
|
|
987
|
+
case "variable":
|
|
988
|
+
return env.lookupVariable(expression.identifier ?? "");
|
|
989
|
+
case "correct": {
|
|
990
|
+
const declaration = env.responseDeclaration(expression.identifier ?? "");
|
|
991
|
+
if (!declaration?.correctResponse) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
return rpValue(declaration.cardinality, declaration.correctResponse.values.map((entry) => coerceScalar(entry.value, declaration.baseType)), declaration.baseType);
|
|
995
|
+
}
|
|
996
|
+
case "mapResponse": {
|
|
997
|
+
const identifier = expression.identifier ?? "";
|
|
998
|
+
const declaration = env.responseDeclaration(identifier);
|
|
999
|
+
if (!declaration) {
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
return floatValue(mapResponse(declaration, env.responseValue(identifier), env.normalization));
|
|
1003
|
+
}
|
|
1004
|
+
case "mapResponsePoint": {
|
|
1005
|
+
const identifier = expression.identifier ?? "";
|
|
1006
|
+
const declaration = env.responseDeclaration(identifier);
|
|
1007
|
+
if (!declaration) {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
return floatValue(mapResponsePoint(declaration, env.responseValue(identifier)));
|
|
1011
|
+
}
|
|
1012
|
+
case "match": {
|
|
1013
|
+
const [a, b] = (expression.expressions ?? []).map(evaluate);
|
|
1014
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
return booleanValue(valuesMatch(a, b, env.normalization));
|
|
1018
|
+
}
|
|
1019
|
+
case "isNull": {
|
|
1020
|
+
const operand = expression.expressions?.[0];
|
|
1021
|
+
return booleanValue(operand === undefined || evaluate(operand) === null);
|
|
1022
|
+
}
|
|
1023
|
+
case "not": {
|
|
1024
|
+
const operand = expression.expressions?.[0];
|
|
1025
|
+
const value = operand === undefined ? null : singleBoolean(evaluate(operand));
|
|
1026
|
+
return value === null ? null : booleanValue(!value);
|
|
1027
|
+
}
|
|
1028
|
+
case "fieldValue": {
|
|
1029
|
+
const operand = expression.expressions?.[0];
|
|
1030
|
+
const value = operand === undefined ? null : evaluate(operand);
|
|
1031
|
+
const field = value?.fields?.find((entry) => entry.name === expression.fieldIdentifier);
|
|
1032
|
+
return field === undefined ? null : rpValue("single", [field.value], field.baseType);
|
|
1033
|
+
}
|
|
1034
|
+
case "and":
|
|
1035
|
+
case "or": {
|
|
1036
|
+
const members = (expression.expressions ?? []).map((child) => singleBoolean(evaluate(child)));
|
|
1037
|
+
return booleanValue(expression.kind === "and" ? members.every((member) => member === true) : members.some((member) => member === true));
|
|
1038
|
+
}
|
|
1039
|
+
case "sum":
|
|
1040
|
+
case "product": {
|
|
1041
|
+
let result = expression.kind === "sum" ? 0 : 1;
|
|
1042
|
+
for (const child of expression.expressions ?? []) {
|
|
1043
|
+
const value = evaluate(child);
|
|
1044
|
+
if (value === null) {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
for (const member of value.values) {
|
|
1048
|
+
if (typeof member !== "number") {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
result = expression.kind === "sum" ? result + member : result * member;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return floatValue(result);
|
|
1055
|
+
}
|
|
1056
|
+
case "subtract":
|
|
1057
|
+
case "divide": {
|
|
1058
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
1059
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
if (expression.kind === "divide") {
|
|
1063
|
+
return b === 0 ? null : floatValue(a / b);
|
|
1064
|
+
}
|
|
1065
|
+
return floatValue(a - b);
|
|
1066
|
+
}
|
|
1067
|
+
case "gt":
|
|
1068
|
+
case "gte":
|
|
1069
|
+
case "lt":
|
|
1070
|
+
case "lte": {
|
|
1071
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
1072
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
const comparisons = { gt: a > b, gte: a >= b, lt: a < b, lte: a <= b };
|
|
1076
|
+
return booleanValue(comparisons[expression.kind]);
|
|
1077
|
+
}
|
|
1078
|
+
case "equal": {
|
|
1079
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
1080
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
const mode = expression.toleranceMode ?? "exact";
|
|
1084
|
+
if (mode === "exact") {
|
|
1085
|
+
return booleanValue(a === b);
|
|
1086
|
+
}
|
|
1087
|
+
const t0 = expression.tolerance?.[0];
|
|
1088
|
+
const t1 = expression.tolerance?.[1] ?? t0;
|
|
1089
|
+
if (typeof t0 !== "number" || typeof t1 !== "number") {
|
|
1090
|
+
throw new RpUnsupportedError("equal");
|
|
1091
|
+
}
|
|
1092
|
+
const lower = mode === "absolute" ? a - t0 : a * (1 - t0 / 100);
|
|
1093
|
+
const upper = mode === "absolute" ? a + t1 : a * (1 + t1 / 100);
|
|
1094
|
+
const aboveLower = expression.includeLowerBound ?? true ? b >= lower : b > lower;
|
|
1095
|
+
const belowUpper = expression.includeUpperBound ?? true ? b <= upper : b < upper;
|
|
1096
|
+
return booleanValue(aboveLower && belowUpper);
|
|
1097
|
+
}
|
|
1098
|
+
case "round":
|
|
1099
|
+
case "truncate": {
|
|
1100
|
+
const operand = expression.expressions?.[0];
|
|
1101
|
+
const value = operand === undefined ? null : singleNumber(evaluate(operand));
|
|
1102
|
+
if (value === null) {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
const rounded = expression.kind === "round" ? Math.round(value) : Math.trunc(value);
|
|
1106
|
+
return { cardinality: "single", baseType: "integer", values: [rounded] };
|
|
1107
|
+
}
|
|
1108
|
+
case "index": {
|
|
1109
|
+
if (typeof expression.n !== "number") {
|
|
1110
|
+
throw new RpUnsupportedError("index");
|
|
1111
|
+
}
|
|
1112
|
+
const operand = expression.expressions?.[0];
|
|
1113
|
+
const container = operand === undefined ? null : evaluate(operand);
|
|
1114
|
+
if (container === null) {
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
const member = container.values[expression.n - 1];
|
|
1118
|
+
if (member === undefined) {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
return rpValue("single", [member], container.baseType);
|
|
1122
|
+
}
|
|
1123
|
+
case "mathConstant": {
|
|
1124
|
+
const constant = expression.name === undefined ? undefined : mathConstants[expression.name];
|
|
1125
|
+
if (constant === undefined) {
|
|
1126
|
+
throw new RpUnsupportedError("mathConstant");
|
|
1127
|
+
}
|
|
1128
|
+
return floatValue(constant);
|
|
1129
|
+
}
|
|
1130
|
+
case "mathOperator": {
|
|
1131
|
+
const [x, y] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
1132
|
+
if (x === undefined || x === null || expression.name === "atan2" && (y === undefined || y === null)) {
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
const result = expression.name === undefined ? undefined : applyMathOperator(expression.name, x, y ?? NaN);
|
|
1136
|
+
if (result === undefined) {
|
|
1137
|
+
throw new RpUnsupportedError("mathOperator");
|
|
1138
|
+
}
|
|
1139
|
+
return Number.isFinite(result) ? floatValue(result) : null;
|
|
1140
|
+
}
|
|
1141
|
+
case "integerDivide":
|
|
1142
|
+
case "integerModulus": {
|
|
1143
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
1144
|
+
if (a === undefined || b === undefined || a === null || b === null || b === 0) {
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
const result = expression.kind === "integerDivide" ? Math.trunc(a / b) : a % b;
|
|
1148
|
+
return { cardinality: "single", baseType: "integer", values: [result] };
|
|
1149
|
+
}
|
|
1150
|
+
case "integerToFloat": {
|
|
1151
|
+
const operand = expression.expressions?.[0];
|
|
1152
|
+
const value = operand === undefined ? null : singleNumber(evaluate(operand));
|
|
1153
|
+
return value === null ? null : floatValue(value);
|
|
1154
|
+
}
|
|
1155
|
+
case "min":
|
|
1156
|
+
case "max": {
|
|
1157
|
+
const members = [];
|
|
1158
|
+
for (const child of expression.expressions ?? []) {
|
|
1159
|
+
const value = evaluate(child);
|
|
1160
|
+
if (value === null) {
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
for (const member of value.values) {
|
|
1164
|
+
if (typeof member !== "number") {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
members.push(member);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (members.length === 0) {
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
return floatValue(expression.kind === "min" ? Math.min(...members) : Math.max(...members));
|
|
1174
|
+
}
|
|
1175
|
+
case "gcd":
|
|
1176
|
+
case "lcm": {
|
|
1177
|
+
const members = [];
|
|
1178
|
+
for (const child of expression.expressions ?? []) {
|
|
1179
|
+
const value = evaluate(child);
|
|
1180
|
+
if (value === null) {
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
for (const member of value.values) {
|
|
1184
|
+
if (typeof member !== "number" || !Number.isInteger(member)) {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
members.push(Math.abs(member));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (members.length === 0) {
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
const gcdOf = (a, b) => b === 0 ? a : gcdOf(b, a % b);
|
|
1194
|
+
const result = expression.kind === "gcd" ? members.reduce(gcdOf) : members.reduce((a, b) => a === 0 || b === 0 ? 0 : a / gcdOf(a, b) * b);
|
|
1195
|
+
return { cardinality: "single", baseType: "integer", values: [result] };
|
|
1196
|
+
}
|
|
1197
|
+
case "roundTo": {
|
|
1198
|
+
if (typeof expression.figures !== "number") {
|
|
1199
|
+
throw new RpUnsupportedError("roundTo");
|
|
1200
|
+
}
|
|
1201
|
+
const operand = expression.expressions?.[0];
|
|
1202
|
+
const value = operand === undefined ? null : singleNumber(evaluate(operand));
|
|
1203
|
+
if (value === null) {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
const rounded = roundToFigures(value, expression.roundingMode ?? "significantFigures", expression.figures);
|
|
1207
|
+
return rounded === null ? null : floatValue(rounded);
|
|
1208
|
+
}
|
|
1209
|
+
case "equalRounded": {
|
|
1210
|
+
if (typeof expression.figures !== "number") {
|
|
1211
|
+
throw new RpUnsupportedError("equalRounded");
|
|
1212
|
+
}
|
|
1213
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
1214
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
const mode = expression.roundingMode ?? "significantFigures";
|
|
1218
|
+
const roundedA = roundToFigures(a, mode, expression.figures);
|
|
1219
|
+
const roundedB = roundToFigures(b, mode, expression.figures);
|
|
1220
|
+
return roundedA === null || roundedB === null ? null : booleanValue(roundedA === roundedB);
|
|
1221
|
+
}
|
|
1222
|
+
case "statsOperator": {
|
|
1223
|
+
const operand = expression.expressions?.[0];
|
|
1224
|
+
const container = operand === undefined ? null : evaluate(operand);
|
|
1225
|
+
if (container === null) {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
const members = [];
|
|
1229
|
+
for (const member of container.values) {
|
|
1230
|
+
if (typeof member !== "number") {
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
members.push(member);
|
|
1234
|
+
}
|
|
1235
|
+
const count = members.length;
|
|
1236
|
+
if (count === 0) {
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
const mean = members.reduce((sum, member) => sum + member, 0) / count;
|
|
1240
|
+
const sumSquares = members.reduce((sum, member) => sum + (member - mean) ** 2, 0);
|
|
1241
|
+
switch (expression.name) {
|
|
1242
|
+
case "mean":
|
|
1243
|
+
return floatValue(mean);
|
|
1244
|
+
case "popVariance":
|
|
1245
|
+
return floatValue(sumSquares / count);
|
|
1246
|
+
case "popSD":
|
|
1247
|
+
return floatValue(Math.sqrt(sumSquares / count));
|
|
1248
|
+
case "sampleVariance":
|
|
1249
|
+
return count < 2 ? null : floatValue(sumSquares / (count - 1));
|
|
1250
|
+
case "sampleSD":
|
|
1251
|
+
return count < 2 ? null : floatValue(Math.sqrt(sumSquares / (count - 1)));
|
|
1252
|
+
default:
|
|
1253
|
+
throw new RpUnsupportedError("statsOperator");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
case "delete": {
|
|
1257
|
+
const [valueExpression, containerExpression] = expression.expressions ?? [];
|
|
1258
|
+
if (valueExpression === undefined || containerExpression === undefined) {
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
const value = evaluate(valueExpression);
|
|
1262
|
+
const container = evaluate(containerExpression);
|
|
1263
|
+
if (value === null || container === null) {
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
const scalar = value.values[0];
|
|
1267
|
+
if (scalar === undefined) {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
const baseType = container.baseType ?? value.baseType;
|
|
1271
|
+
const remaining = container.values.filter((member) => !scalarsEqual(member, scalar, baseType, env.normalization));
|
|
1272
|
+
return remaining.length === 0 ? null : rpValue(container.cardinality, remaining, container.baseType);
|
|
1273
|
+
}
|
|
1274
|
+
case "repeat": {
|
|
1275
|
+
if (typeof expression.numberRepeats !== "number") {
|
|
1276
|
+
throw new RpUnsupportedError("repeat");
|
|
1277
|
+
}
|
|
1278
|
+
if (expression.numberRepeats < 1) {
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
const members = [];
|
|
1282
|
+
let baseType;
|
|
1283
|
+
for (let pass = 0;pass < expression.numberRepeats; pass += 1) {
|
|
1284
|
+
for (const child of expression.expressions ?? []) {
|
|
1285
|
+
const value = evaluate(child);
|
|
1286
|
+
if (value === null) {
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
baseType ??= value.baseType;
|
|
1290
|
+
members.push(...value.values);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return members.length === 0 ? null : rpValue("ordered", members, baseType);
|
|
1294
|
+
}
|
|
1295
|
+
case "stringMatch":
|
|
1296
|
+
case "substring": {
|
|
1297
|
+
const [a, b] = (expression.expressions ?? []).map((child) => {
|
|
1298
|
+
const value = evaluate(child);
|
|
1299
|
+
const member = value?.values[0];
|
|
1300
|
+
return typeof member === "string" ? member : null;
|
|
1301
|
+
});
|
|
1302
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
const normalize = (input) => {
|
|
1306
|
+
const normalized = env.normalization?.(input) ?? input;
|
|
1307
|
+
return expression.caseSensitive === false ? normalized.toLowerCase() : normalized;
|
|
1308
|
+
};
|
|
1309
|
+
const [left, right] = [normalize(a), normalize(b)];
|
|
1310
|
+
const contains = expression.kind === "substring" || expression.substring === true;
|
|
1311
|
+
return booleanValue(contains ? right.includes(left) : left === right);
|
|
1312
|
+
}
|
|
1313
|
+
case "inside": {
|
|
1314
|
+
if (typeof expression.shape !== "string" || typeof expression.coords !== "string") {
|
|
1315
|
+
throw new RpUnsupportedError("inside");
|
|
1316
|
+
}
|
|
1317
|
+
const operand = expression.expressions?.[0];
|
|
1318
|
+
const value = operand === undefined ? null : evaluate(operand);
|
|
1319
|
+
const member = value?.values[0];
|
|
1320
|
+
if (value === null || typeof member !== "string") {
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
const point = parsePoint(member);
|
|
1324
|
+
if (point === null) {
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
return booleanValue(pointInShape(expression.shape, parseCoords(expression.coords), point));
|
|
1328
|
+
}
|
|
1329
|
+
case "member": {
|
|
1330
|
+
const [needleExpression, containerExpression] = expression.expressions ?? [];
|
|
1331
|
+
if (needleExpression === undefined || containerExpression === undefined) {
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
const needle = evaluate(needleExpression);
|
|
1335
|
+
const container = evaluate(containerExpression);
|
|
1336
|
+
if (needle === null || container === null) {
|
|
1337
|
+
return null;
|
|
1338
|
+
}
|
|
1339
|
+
const scalar = needle.values[0];
|
|
1340
|
+
if (scalar === undefined) {
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
const baseType = container.baseType ?? needle.baseType;
|
|
1344
|
+
return booleanValue(container.values.some((member) => scalarsEqual(member, scalar, baseType, env.normalization)));
|
|
1345
|
+
}
|
|
1346
|
+
case "multiple":
|
|
1347
|
+
case "ordered": {
|
|
1348
|
+
const members = [];
|
|
1349
|
+
let baseType;
|
|
1350
|
+
for (const child of expression.expressions ?? []) {
|
|
1351
|
+
const value = evaluate(child);
|
|
1352
|
+
if (value === null) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
baseType ??= value.baseType;
|
|
1356
|
+
members.push(...value.values);
|
|
1357
|
+
}
|
|
1358
|
+
if (members.length === 0) {
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
return rpValue(expression.kind, members, baseType);
|
|
1362
|
+
}
|
|
1363
|
+
case "randomInteger": {
|
|
1364
|
+
if (!env.random) {
|
|
1365
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1366
|
+
}
|
|
1367
|
+
const min = expression.min ?? 0;
|
|
1368
|
+
const max = expression.max ?? min;
|
|
1369
|
+
const step = expression.step ?? 1;
|
|
1370
|
+
const count = Math.max(1, Math.floor((max - min) / step) + 1);
|
|
1371
|
+
return { cardinality: "single", baseType: "integer", values: [min + Math.floor(env.random() * count) * step] };
|
|
1372
|
+
}
|
|
1373
|
+
case "randomFloat": {
|
|
1374
|
+
if (!env.random) {
|
|
1375
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1376
|
+
}
|
|
1377
|
+
const min = expression.min ?? 0;
|
|
1378
|
+
const max = expression.max ?? min;
|
|
1379
|
+
return floatValue(min + env.random() * (max - min));
|
|
1380
|
+
}
|
|
1381
|
+
case "testVariables": {
|
|
1382
|
+
if (!env.testVariables) {
|
|
1383
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1384
|
+
}
|
|
1385
|
+
return env.testVariables(expression);
|
|
1386
|
+
}
|
|
1387
|
+
case "customOperator": {
|
|
1388
|
+
const implementation = env.customOperators?.[expression.class ?? ""];
|
|
1389
|
+
if (!implementation) {
|
|
1390
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1391
|
+
}
|
|
1392
|
+
return implementation((expression.expressions ?? []).map(evaluate), expression);
|
|
1393
|
+
}
|
|
1394
|
+
case "numberCorrect":
|
|
1395
|
+
case "numberIncorrect":
|
|
1396
|
+
case "numberPresented":
|
|
1397
|
+
case "numberResponded":
|
|
1398
|
+
case "numberSelected": {
|
|
1399
|
+
if (!env.testAggregate) {
|
|
1400
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1401
|
+
}
|
|
1402
|
+
return env.testAggregate(expression);
|
|
1403
|
+
}
|
|
1404
|
+
case "random": {
|
|
1405
|
+
if (!env.random) {
|
|
1406
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1407
|
+
}
|
|
1408
|
+
const container = expression.expressions?.[0] === undefined ? null : evaluate(expression.expressions[0]);
|
|
1409
|
+
if (container === null || container.values.length === 0) {
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
const pick = container.values[Math.floor(env.random() * container.values.length)];
|
|
1413
|
+
return rpValue("single", [pick], container.baseType);
|
|
1414
|
+
}
|
|
1415
|
+
default:
|
|
1416
|
+
throw new RpUnsupportedError(expression.kind);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
function collectExpressionIssues(expression, allowedKinds, report, customOperatorClasses) {
|
|
1420
|
+
if (expression.kind === "customOperator") {
|
|
1421
|
+
if (!customOperatorClasses?.has(expression.class ?? "")) {
|
|
1422
|
+
report(expression.kind);
|
|
1423
|
+
}
|
|
1424
|
+
} else if (!allowedKinds.has(expression.kind)) {
|
|
1425
|
+
report(expression.kind);
|
|
1426
|
+
}
|
|
1427
|
+
for (const child of expression.expressions ?? []) {
|
|
1428
|
+
collectExpressionIssues(child, allowedKinds, report, customOperatorClasses);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// src/rp/templates.ts
|
|
1433
|
+
var matchCorrectRules = [
|
|
1434
|
+
{
|
|
1435
|
+
kind: "responseCondition",
|
|
1436
|
+
responseIf: {
|
|
1437
|
+
expression: {
|
|
1438
|
+
kind: "match",
|
|
1439
|
+
expressions: [
|
|
1440
|
+
{ kind: "variable", identifier: "RESPONSE" },
|
|
1441
|
+
{ kind: "correct", identifier: "RESPONSE" }
|
|
1442
|
+
]
|
|
1443
|
+
},
|
|
1444
|
+
rules: [
|
|
1445
|
+
{
|
|
1446
|
+
kind: "setOutcomeValue",
|
|
1447
|
+
identifier: "SCORE",
|
|
1448
|
+
expression: { kind: "baseValue", baseType: "float", value: 1 }
|
|
1449
|
+
}
|
|
1450
|
+
]
|
|
1451
|
+
},
|
|
1452
|
+
responseElse: {
|
|
1453
|
+
rules: [
|
|
1454
|
+
{
|
|
1455
|
+
kind: "setOutcomeValue",
|
|
1456
|
+
identifier: "SCORE",
|
|
1457
|
+
expression: { kind: "baseValue", baseType: "float", value: 0 }
|
|
1458
|
+
}
|
|
1459
|
+
]
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
];
|
|
1463
|
+
var mapResponseRules = [
|
|
1464
|
+
{
|
|
1465
|
+
kind: "responseCondition",
|
|
1466
|
+
responseIf: {
|
|
1467
|
+
expression: { kind: "isNull", expressions: [{ kind: "variable", identifier: "RESPONSE" }] },
|
|
1468
|
+
rules: [
|
|
1469
|
+
{
|
|
1470
|
+
kind: "setOutcomeValue",
|
|
1471
|
+
identifier: "SCORE",
|
|
1472
|
+
expression: { kind: "baseValue", baseType: "float", value: 0 }
|
|
1473
|
+
}
|
|
1474
|
+
]
|
|
1475
|
+
},
|
|
1476
|
+
responseElse: {
|
|
1477
|
+
rules: [
|
|
1478
|
+
{ kind: "setOutcomeValue", identifier: "SCORE", expression: { kind: "mapResponse", identifier: "RESPONSE" } }
|
|
1479
|
+
]
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
];
|
|
1483
|
+
var mapResponsePointRules = [
|
|
1484
|
+
{
|
|
1485
|
+
kind: "responseCondition",
|
|
1486
|
+
responseIf: {
|
|
1487
|
+
expression: { kind: "isNull", expressions: [{ kind: "variable", identifier: "RESPONSE" }] },
|
|
1488
|
+
rules: [
|
|
1489
|
+
{
|
|
1490
|
+
kind: "setOutcomeValue",
|
|
1491
|
+
identifier: "SCORE",
|
|
1492
|
+
expression: { kind: "baseValue", baseType: "float", value: 0 }
|
|
1493
|
+
}
|
|
1494
|
+
]
|
|
1495
|
+
},
|
|
1496
|
+
responseElse: {
|
|
1497
|
+
rules: [
|
|
1498
|
+
{
|
|
1499
|
+
kind: "setOutcomeValue",
|
|
1500
|
+
identifier: "SCORE",
|
|
1501
|
+
expression: { kind: "mapResponsePoint", identifier: "RESPONSE" }
|
|
1502
|
+
}
|
|
1503
|
+
]
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
];
|
|
1507
|
+
var cc2MatchBasicRules = [
|
|
1508
|
+
{
|
|
1509
|
+
kind: "responseCondition",
|
|
1510
|
+
responseIf: {
|
|
1511
|
+
expression: {
|
|
1512
|
+
kind: "match",
|
|
1513
|
+
expressions: [
|
|
1514
|
+
{ kind: "variable", identifier: "RESPONSE" },
|
|
1515
|
+
{ kind: "correct", identifier: "RESPONSE" }
|
|
1516
|
+
]
|
|
1517
|
+
},
|
|
1518
|
+
rules: [
|
|
1519
|
+
{ kind: "setOutcomeValue", identifier: "SCORE", expression: { kind: "variable", identifier: "MAXSCORE" } },
|
|
1520
|
+
{
|
|
1521
|
+
kind: "setOutcomeValue",
|
|
1522
|
+
identifier: "FEEDBACKBASIC",
|
|
1523
|
+
expression: { kind: "baseValue", baseType: "identifier", value: "correct" }
|
|
1524
|
+
}
|
|
1525
|
+
]
|
|
1526
|
+
},
|
|
1527
|
+
responseElse: {
|
|
1528
|
+
rules: [
|
|
1529
|
+
{
|
|
1530
|
+
kind: "setOutcomeValue",
|
|
1531
|
+
identifier: "FEEDBACKBASIC",
|
|
1532
|
+
expression: { kind: "baseValue", baseType: "identifier", value: "incorrect" }
|
|
1533
|
+
}
|
|
1534
|
+
]
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
];
|
|
1538
|
+
var cc2MapResponseRules = [
|
|
1539
|
+
{
|
|
1540
|
+
kind: "responseCondition",
|
|
1541
|
+
responseIf: {
|
|
1542
|
+
expression: { kind: "isNull", expressions: [{ kind: "variable", identifier: "RESPONSE" }] },
|
|
1543
|
+
rules: [
|
|
1544
|
+
{
|
|
1545
|
+
kind: "setOutcomeValue",
|
|
1546
|
+
identifier: "SCORE",
|
|
1547
|
+
expression: { kind: "baseValue", baseType: "float", value: 0 }
|
|
1548
|
+
}
|
|
1549
|
+
]
|
|
1550
|
+
},
|
|
1551
|
+
responseElse: {
|
|
1552
|
+
rules: [
|
|
1553
|
+
{ kind: "setOutcomeValue", identifier: "SCORE", expression: { kind: "mapResponse", identifier: "RESPONSE" } }
|
|
1554
|
+
]
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
kind: "responseCondition",
|
|
1559
|
+
responseIf: {
|
|
1560
|
+
expression: {
|
|
1561
|
+
kind: "equal",
|
|
1562
|
+
toleranceMode: "exact",
|
|
1563
|
+
expressions: [
|
|
1564
|
+
{ kind: "variable", identifier: "MAXSCORE" },
|
|
1565
|
+
{ kind: "variable", identifier: "SCORE" }
|
|
1566
|
+
]
|
|
1567
|
+
},
|
|
1568
|
+
rules: [
|
|
1569
|
+
{
|
|
1570
|
+
kind: "setOutcomeValue",
|
|
1571
|
+
identifier: "FEEDBACK",
|
|
1572
|
+
expression: { kind: "baseValue", baseType: "identifier", value: "correct" }
|
|
1573
|
+
}
|
|
1574
|
+
]
|
|
1575
|
+
},
|
|
1576
|
+
responseElse: {
|
|
1577
|
+
rules: [
|
|
1578
|
+
{
|
|
1579
|
+
kind: "setOutcomeValue",
|
|
1580
|
+
identifier: "FEEDBACK",
|
|
1581
|
+
expression: { kind: "baseValue", baseType: "identifier", value: "incorrect" }
|
|
1582
|
+
}
|
|
1583
|
+
]
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
];
|
|
1587
|
+
var templatesBySegment = new Map([
|
|
1588
|
+
["match_correct", matchCorrectRules],
|
|
1589
|
+
["map_response", mapResponseRules],
|
|
1590
|
+
["map_response_point", mapResponsePointRules],
|
|
1591
|
+
["CC2_match", matchCorrectRules],
|
|
1592
|
+
["CC2_match_basic", cc2MatchBasicRules],
|
|
1593
|
+
["CC2_map_response", cc2MapResponseRules]
|
|
1594
|
+
]);
|
|
1595
|
+
function resolveTemplate(uri) {
|
|
1596
|
+
const segment = uri.split("/").at(-1)?.replace(/\.xml$/u, "") ?? "";
|
|
1597
|
+
return templatesBySegment.get(segment) ?? null;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/rp/interpreter.ts
|
|
1601
|
+
var supportedRuleKinds = new Set(["responseCondition", "setOutcomeValue", "exitResponse"]);
|
|
1602
|
+
var rpExpressionKinds = new Set([...deterministicExpressionKinds, "random", "randomInteger", "randomFloat"]);
|
|
1603
|
+
|
|
1604
|
+
class ExitResponseSignal extends Error {
|
|
1605
|
+
}
|
|
1606
|
+
function defaultOutcomes(declarations) {
|
|
1607
|
+
const outcomes = new Map;
|
|
1608
|
+
for (const declaration of declarations) {
|
|
1609
|
+
if (declaration.defaultValue) {
|
|
1610
|
+
outcomes.set(declaration.identifier, rpValue(declaration.cardinality, declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)), declaration.baseType));
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
outcomes.set(declaration.identifier, isNumericBaseType(declaration.baseType) ? floatValue(0) : null);
|
|
1614
|
+
}
|
|
1615
|
+
return outcomes;
|
|
1616
|
+
}
|
|
1617
|
+
function executeResponseProcessing(view, context) {
|
|
1618
|
+
const issues = [];
|
|
1619
|
+
const declarationsById = new Map(context.responseDeclarations.map((declaration) => [declaration.identifier, declaration]));
|
|
1620
|
+
const templateDeclarationsById = new Map((context.templateDeclarations ?? []).map((declaration) => [declaration.identifier, declaration]));
|
|
1621
|
+
function initialOutcomes() {
|
|
1622
|
+
const outcomes2 = defaultOutcomes(context.outcomeDeclarations);
|
|
1623
|
+
for (const [identifier, prior] of Object.entries(context.priorOutcomes ?? {})) {
|
|
1624
|
+
const declaration = context.outcomeDeclarations.find((entry) => entry.identifier === identifier);
|
|
1625
|
+
outcomes2.set(identifier, fromFlatValue(prior, declaration?.cardinality ?? "single", declaration?.baseType));
|
|
1626
|
+
}
|
|
1627
|
+
return outcomes2;
|
|
1628
|
+
}
|
|
1629
|
+
let outcomes = initialOutcomes();
|
|
1630
|
+
let rules = view?.rules ?? [];
|
|
1631
|
+
if (view && !view.rules && view.template) {
|
|
1632
|
+
const resolved = resolveTemplate(view.template);
|
|
1633
|
+
if (resolved) {
|
|
1634
|
+
rules = resolved;
|
|
1635
|
+
} else {
|
|
1636
|
+
issues.push({ type: "unsupported-rp", name: view.template, detail: "Unknown response-processing template URI." });
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const env = {
|
|
1640
|
+
lookupVariable: (identifier) => {
|
|
1641
|
+
const declaration = declarationsById.get(identifier);
|
|
1642
|
+
if (declaration) {
|
|
1643
|
+
return fromResponse(declaration, context.responses[identifier] ?? null);
|
|
1644
|
+
}
|
|
1645
|
+
const templateDeclaration = templateDeclarationsById.get(identifier);
|
|
1646
|
+
if (templateDeclaration) {
|
|
1647
|
+
return fromFlatValue(context.templateValues?.[identifier] ?? null, templateDeclaration.cardinality, templateDeclaration.baseType);
|
|
1648
|
+
}
|
|
1649
|
+
return outcomes.get(identifier) ?? null;
|
|
1650
|
+
},
|
|
1651
|
+
responseDeclaration: (identifier) => declarationsById.get(identifier),
|
|
1652
|
+
responseValue: (identifier) => context.responses[identifier] ?? null,
|
|
1653
|
+
normalization: context.normalization,
|
|
1654
|
+
random: context.random,
|
|
1655
|
+
customOperators: context.customOperators
|
|
1656
|
+
};
|
|
1657
|
+
function branchTaken(branch) {
|
|
1658
|
+
if (singleBoolean(evaluateExpression(branch.expression, env)) !== true) {
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
executeRules(branch.rules);
|
|
1662
|
+
return true;
|
|
1663
|
+
}
|
|
1664
|
+
function executeRules(rules_) {
|
|
1665
|
+
for (const rule of rules_) {
|
|
1666
|
+
if (!supportedRuleKinds.has(rule.kind)) {
|
|
1667
|
+
throw new RpUnsupportedError(rule.kind);
|
|
1668
|
+
}
|
|
1669
|
+
if (rule.kind === "exitResponse") {
|
|
1670
|
+
throw new ExitResponseSignal;
|
|
1671
|
+
}
|
|
1672
|
+
if (rule.kind === "setOutcomeValue") {
|
|
1673
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
1674
|
+
outcomes.set(rule.identifier, evaluateExpression(rule.expression, env));
|
|
1675
|
+
}
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
if (rule.responseIf && branchTaken(rule.responseIf)) {
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
const elseIfTaken = (rule.responseElseIfs ?? []).some((branch) => branchTaken(branch));
|
|
1682
|
+
if (!elseIfTaken && rule.responseElse) {
|
|
1683
|
+
executeRules(rule.responseElse.rules);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
try {
|
|
1688
|
+
executeRules(rules);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
if (error instanceof RpUnsupportedError) {
|
|
1691
|
+
issues.push({ type: "unsupported-rp", name: error.kindName });
|
|
1692
|
+
outcomes = initialOutcomes();
|
|
1693
|
+
} else if (!(error instanceof ExitResponseSignal)) {
|
|
1694
|
+
throw error;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return {
|
|
1698
|
+
outcomes: Object.fromEntries([...outcomes].map(([identifier, value]) => [identifier, toOutcomeValue(value)])),
|
|
1699
|
+
issues
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
function collectRpIssues(view, options) {
|
|
1703
|
+
if (!view) {
|
|
1704
|
+
return [];
|
|
1705
|
+
}
|
|
1706
|
+
const issues = [];
|
|
1707
|
+
const seen = new Set;
|
|
1708
|
+
function report(name, detail) {
|
|
1709
|
+
if (!seen.has(name)) {
|
|
1710
|
+
seen.add(name);
|
|
1711
|
+
issues.push({ type: "unsupported-rp", name, ...detail === undefined ? {} : { detail } });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
function walkRules(rules) {
|
|
1715
|
+
for (const rule of rules) {
|
|
1716
|
+
if (!supportedRuleKinds.has(rule.kind)) {
|
|
1717
|
+
report(rule.kind);
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
if (rule.expression) {
|
|
1721
|
+
collectExpressionIssues(rule.expression, rpExpressionKinds, report, options?.customOperatorClasses);
|
|
1722
|
+
}
|
|
1723
|
+
for (const branch of [rule.responseIf, ...rule.responseElseIfs ?? []]) {
|
|
1724
|
+
if (branch) {
|
|
1725
|
+
collectExpressionIssues(branch.expression, rpExpressionKinds, report, options?.customOperatorClasses);
|
|
1726
|
+
walkRules(branch.rules);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
if (rule.responseElse) {
|
|
1730
|
+
walkRules(rule.responseElse.rules);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (view.rules) {
|
|
1735
|
+
walkRules(view.rules);
|
|
1736
|
+
} else if (view.template) {
|
|
1737
|
+
const resolved = resolveTemplate(view.template);
|
|
1738
|
+
if (resolved === null) {
|
|
1739
|
+
report(view.template, "Unknown response-processing template URI.");
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
return issues;
|
|
1743
|
+
}
|
|
1744
|
+
// src/rp/template-processing.ts
|
|
1745
|
+
var supportedTemplateRuleKinds = new Set([
|
|
1746
|
+
"setTemplateValue",
|
|
1747
|
+
"templateCondition",
|
|
1748
|
+
"templateConstraint",
|
|
1749
|
+
"setCorrectResponse",
|
|
1750
|
+
"exitTemplate"
|
|
1751
|
+
]);
|
|
1752
|
+
var templateExpressionKinds = new Set([...deterministicExpressionKinds, ...randomExpressionKinds]);
|
|
1753
|
+
|
|
1754
|
+
class ExitTemplateSignal extends Error {
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
class TemplateConstraintSignal extends Error {
|
|
1758
|
+
}
|
|
1759
|
+
var maxConstraintAttempts = 100;
|
|
1760
|
+
function mulberry32(seed) {
|
|
1761
|
+
let state = seed >>> 0;
|
|
1762
|
+
return () => {
|
|
1763
|
+
state = state + 1831565813 >>> 0;
|
|
1764
|
+
let t = state;
|
|
1765
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
1766
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
1767
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
function executeTemplateProcessing(view, context) {
|
|
1771
|
+
const issues = [];
|
|
1772
|
+
const declarationsById = new Map(context.templateDeclarations.map((entry) => [entry.identifier, entry]));
|
|
1773
|
+
const responseDeclarationsById = new Map(context.responseDeclarations.map((entry) => [entry.identifier, entry]));
|
|
1774
|
+
const correctResponseOverrides = {};
|
|
1775
|
+
function initialValues() {
|
|
1776
|
+
const values = new Map;
|
|
1777
|
+
for (const declaration of context.templateDeclarations) {
|
|
1778
|
+
values.set(declaration.identifier, declaration.defaultValue ? rpValue(declaration.cardinality, declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)), declaration.baseType) : null);
|
|
1779
|
+
}
|
|
1780
|
+
return values;
|
|
1781
|
+
}
|
|
1782
|
+
let templateValues = initialValues();
|
|
1783
|
+
const env = {
|
|
1784
|
+
lookupVariable: (identifier) => templateValues.get(identifier) ?? null,
|
|
1785
|
+
responseDeclaration: (identifier) => responseDeclarationsById.get(identifier),
|
|
1786
|
+
responseValue: () => null,
|
|
1787
|
+
random: mulberry32(context.seed),
|
|
1788
|
+
customOperators: context.customOperators
|
|
1789
|
+
};
|
|
1790
|
+
function branchTaken(branch) {
|
|
1791
|
+
if (singleBoolean(evaluateExpression(branch.expression, env)) !== true) {
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
executeRules(branch.rules);
|
|
1795
|
+
return true;
|
|
1796
|
+
}
|
|
1797
|
+
function executeRules(rules) {
|
|
1798
|
+
for (const rule of rules) {
|
|
1799
|
+
if (!supportedTemplateRuleKinds.has(rule.kind)) {
|
|
1800
|
+
throw new RpUnsupportedError(rule.kind);
|
|
1801
|
+
}
|
|
1802
|
+
if (rule.kind === "exitTemplate") {
|
|
1803
|
+
throw new ExitTemplateSignal;
|
|
1804
|
+
}
|
|
1805
|
+
if (rule.kind === "setTemplateValue") {
|
|
1806
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
1807
|
+
const declaration = declarationsById.get(rule.identifier);
|
|
1808
|
+
const value = evaluateExpression(rule.expression, env);
|
|
1809
|
+
templateValues.set(rule.identifier, value === null || !declaration ? value : rpValue(declaration.cardinality, value.values, declaration.baseType ?? value.baseType));
|
|
1810
|
+
}
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
if (rule.kind === "setCorrectResponse") {
|
|
1814
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
1815
|
+
const value = evaluateExpression(rule.expression, env);
|
|
1816
|
+
if (value !== null) {
|
|
1817
|
+
correctResponseOverrides[rule.identifier] = {
|
|
1818
|
+
values: value.values.map((member) => ({ value: String(member) }))
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
if (rule.kind === "templateConstraint") {
|
|
1825
|
+
if (rule.expression !== undefined && singleBoolean(evaluateExpression(rule.expression, env)) !== true) {
|
|
1826
|
+
throw new TemplateConstraintSignal;
|
|
1827
|
+
}
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
if (rule.templateIf && branchTaken(rule.templateIf)) {
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
const elseIfTaken = (rule.templateElseIfs ?? []).some((branch) => branchTaken(branch));
|
|
1834
|
+
if (!elseIfTaken && rule.templateElse) {
|
|
1835
|
+
executeRules(rule.templateElse.rules);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
for (let attempt = 0;attempt < maxConstraintAttempts; attempt += 1) {
|
|
1840
|
+
try {
|
|
1841
|
+
executeRules(view?.rules ?? []);
|
|
1842
|
+
break;
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
if (error instanceof TemplateConstraintSignal) {
|
|
1845
|
+
templateValues = initialValues();
|
|
1846
|
+
for (const key of Object.keys(correctResponseOverrides)) {
|
|
1847
|
+
delete correctResponseOverrides[key];
|
|
1848
|
+
}
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
if (error instanceof RpUnsupportedError) {
|
|
1852
|
+
issues.push({ type: "unsupported-rp", name: error.kindName });
|
|
1853
|
+
templateValues = initialValues();
|
|
1854
|
+
} else if (!(error instanceof ExitTemplateSignal)) {
|
|
1855
|
+
throw error;
|
|
1856
|
+
}
|
|
1857
|
+
break;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
templateValues: Object.fromEntries([...templateValues].map(([identifier, value]) => [identifier, toOutcomeValue(value)])),
|
|
1862
|
+
correctResponseOverrides,
|
|
1863
|
+
issues
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
function applyCorrectResponseOverrides(declarations, overrides) {
|
|
1867
|
+
return declarations.map((declaration) => {
|
|
1868
|
+
const override = overrides[declaration.identifier];
|
|
1869
|
+
return override ? { ...declaration, correctResponse: override } : declaration;
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
function collectTemplateIssues(view, options) {
|
|
1873
|
+
if (!view) {
|
|
1874
|
+
return [];
|
|
1875
|
+
}
|
|
1876
|
+
const issues = [];
|
|
1877
|
+
const seen = new Set;
|
|
1878
|
+
function report(name) {
|
|
1879
|
+
if (!seen.has(name)) {
|
|
1880
|
+
seen.add(name);
|
|
1881
|
+
issues.push({ type: "unsupported-rp", name });
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
function walkRules(rules) {
|
|
1885
|
+
for (const rule of rules) {
|
|
1886
|
+
if (!supportedTemplateRuleKinds.has(rule.kind)) {
|
|
1887
|
+
report(rule.kind);
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
if (rule.expression) {
|
|
1891
|
+
collectExpressionIssues(rule.expression, templateExpressionKinds, report, options?.customOperatorClasses);
|
|
1892
|
+
}
|
|
1893
|
+
for (const branch of [rule.templateIf, ...rule.templateElseIfs ?? []]) {
|
|
1894
|
+
if (branch) {
|
|
1895
|
+
collectExpressionIssues(branch.expression, templateExpressionKinds, report, options?.customOperatorClasses);
|
|
1896
|
+
walkRules(branch.rules);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
if (rule.templateElse) {
|
|
1900
|
+
walkRules(rule.templateElse.rules);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
walkRules(view.rules);
|
|
1905
|
+
return issues;
|
|
1906
|
+
}
|
|
1907
|
+
// src/store.ts
|
|
1908
|
+
function createAttemptStore(declarations, initialResponses, options) {
|
|
1909
|
+
const seed = options?.seed ?? Math.floor(Math.random() * 2 ** 31);
|
|
1910
|
+
const templateResult = options?.templateProcessing ? executeTemplateProcessing(options.templateProcessing, {
|
|
1911
|
+
templateDeclarations: options.templateDeclarations ?? [],
|
|
1912
|
+
responseDeclarations: declarations,
|
|
1913
|
+
seed,
|
|
1914
|
+
customOperators: options.customOperators
|
|
1915
|
+
}) : null;
|
|
1916
|
+
const effectiveDeclarations = templateResult ? applyCorrectResponseOverrides(declarations, templateResult.correctResponseOverrides) : declarations;
|
|
1917
|
+
const declarationsById = new Map(effectiveDeclarations.map((declaration) => [declaration.identifier, declaration]));
|
|
1918
|
+
const listeners = new Set;
|
|
1919
|
+
const responseCollectors = new Map;
|
|
1920
|
+
const rpRandom = mulberry32((seed ^ 2654435769) >>> 0);
|
|
1921
|
+
let snapshot = {
|
|
1922
|
+
responses: { ...initialResponses },
|
|
1923
|
+
submitted: false,
|
|
1924
|
+
scores: [],
|
|
1925
|
+
outcomes: {},
|
|
1926
|
+
templateValues: templateResult?.templateValues ?? {},
|
|
1927
|
+
attemptCount: 0
|
|
1928
|
+
};
|
|
1929
|
+
function emit(next) {
|
|
1930
|
+
snapshot = next;
|
|
1931
|
+
for (const listener of listeners) {
|
|
1932
|
+
listener();
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
function computeScores(responses) {
|
|
1936
|
+
return [...declarationsById.values()].map((declaration) => scoreResponse(declaration, responses[declaration.identifier] ?? null, options?.normalization));
|
|
1937
|
+
}
|
|
1938
|
+
function computeOutcomes(responses, priorOutcomes) {
|
|
1939
|
+
if (!options?.responseProcessing) {
|
|
1940
|
+
return {};
|
|
1941
|
+
}
|
|
1942
|
+
return executeResponseProcessing(options.responseProcessing, {
|
|
1943
|
+
responseDeclarations: effectiveDeclarations,
|
|
1944
|
+
outcomeDeclarations: options.outcomeDeclarations ?? [],
|
|
1945
|
+
responses,
|
|
1946
|
+
normalization: options.normalization,
|
|
1947
|
+
templateDeclarations: options.templateDeclarations,
|
|
1948
|
+
templateValues: snapshot.templateValues,
|
|
1949
|
+
priorOutcomes,
|
|
1950
|
+
random: rpRandom,
|
|
1951
|
+
customOperators: options.customOperators
|
|
1952
|
+
}).outcomes;
|
|
1953
|
+
}
|
|
1954
|
+
return {
|
|
1955
|
+
getSnapshot: () => snapshot,
|
|
1956
|
+
subscribe: (listener) => {
|
|
1957
|
+
listeners.add(listener);
|
|
1958
|
+
return () => {
|
|
1959
|
+
listeners.delete(listener);
|
|
1960
|
+
};
|
|
1961
|
+
},
|
|
1962
|
+
setResponse: (responseIdentifier, value) => {
|
|
1963
|
+
if (snapshot.submitted) {
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
emit({
|
|
1967
|
+
...snapshot,
|
|
1968
|
+
responses: { ...snapshot.responses, [responseIdentifier]: value }
|
|
1969
|
+
});
|
|
1970
|
+
},
|
|
1971
|
+
registerResponseCollector: (responseIdentifier, collector) => {
|
|
1972
|
+
responseCollectors.set(responseIdentifier, collector);
|
|
1973
|
+
return () => {
|
|
1974
|
+
if (responseCollectors.get(responseIdentifier) === collector) {
|
|
1975
|
+
responseCollectors.delete(responseIdentifier);
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
},
|
|
1979
|
+
submit: () => {
|
|
1980
|
+
if (snapshot.submitted) {
|
|
1981
|
+
return snapshot.scores;
|
|
1982
|
+
}
|
|
1983
|
+
let collected = snapshot.responses;
|
|
1984
|
+
for (const [responseIdentifier, collector] of responseCollectors) {
|
|
1985
|
+
const value = collector();
|
|
1986
|
+
if (value !== undefined) {
|
|
1987
|
+
collected = { ...collected, [responseIdentifier]: value };
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (collected !== snapshot.responses) {
|
|
1991
|
+
snapshot = { ...snapshot, responses: collected };
|
|
1992
|
+
}
|
|
1993
|
+
const scores = computeScores(snapshot.responses);
|
|
1994
|
+
const priorOutcomes = options?.adaptive && snapshot.attemptCount > 0 ? snapshot.outcomes : undefined;
|
|
1995
|
+
const outcomes = computeOutcomes(snapshot.responses, priorOutcomes);
|
|
1996
|
+
const completionStatus = outcomes["completionStatus"] ?? outcomes["completion_status"];
|
|
1997
|
+
const completed = !options?.adaptive || completionStatus === "completed";
|
|
1998
|
+
let responses = snapshot.responses;
|
|
1999
|
+
if (options?.adaptive && !completed) {
|
|
2000
|
+
responses = { ...responses };
|
|
2001
|
+
for (const declaration of effectiveDeclarations) {
|
|
2002
|
+
if (declaration.baseType === "boolean") {
|
|
2003
|
+
responses = { ...responses, [declaration.identifier]: null };
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
emit({
|
|
2008
|
+
...snapshot,
|
|
2009
|
+
responses,
|
|
2010
|
+
submitted: completed,
|
|
2011
|
+
scores,
|
|
2012
|
+
outcomes,
|
|
2013
|
+
attemptCount: snapshot.attemptCount + 1
|
|
2014
|
+
});
|
|
2015
|
+
return scores;
|
|
2016
|
+
},
|
|
2017
|
+
reset: () => {
|
|
2018
|
+
emit({
|
|
2019
|
+
responses: { ...initialResponses },
|
|
2020
|
+
submitted: false,
|
|
2021
|
+
scores: [],
|
|
2022
|
+
outcomes: {},
|
|
2023
|
+
templateValues: snapshot.templateValues,
|
|
2024
|
+
attemptCount: 0
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
// src/test/controller.ts
|
|
2030
|
+
var supportedOutcomeRuleKinds = new Set(["outcomeCondition", "setOutcomeValue", "exitTest"]);
|
|
2031
|
+
var testExpressionKinds = new Set([
|
|
2032
|
+
...deterministicExpressionKinds,
|
|
2033
|
+
"testVariables",
|
|
2034
|
+
"numberCorrect",
|
|
2035
|
+
"numberIncorrect",
|
|
2036
|
+
"numberPresented",
|
|
2037
|
+
"numberResponded",
|
|
2038
|
+
"numberSelected"
|
|
2039
|
+
]);
|
|
2040
|
+
|
|
2041
|
+
class ExitTestSignal extends Error {
|
|
2042
|
+
}
|
|
2043
|
+
function inferBaseType(value) {
|
|
2044
|
+
if (typeof value === "number") {
|
|
2045
|
+
return "float";
|
|
2046
|
+
}
|
|
2047
|
+
if (typeof value === "boolean") {
|
|
2048
|
+
return "boolean";
|
|
2049
|
+
}
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
function liftFlat(value) {
|
|
2053
|
+
if (value === null || value === undefined) {
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
if (Array.isArray(value)) {
|
|
2057
|
+
return fromFlatValue(value, "multiple", inferBaseType(value[0]));
|
|
2058
|
+
}
|
|
2059
|
+
return fromFlatValue(value, "single", inferBaseType(value));
|
|
2060
|
+
}
|
|
2061
|
+
var specSessionControlDefaults = {
|
|
2062
|
+
maxAttempts: 1,
|
|
2063
|
+
showFeedback: false,
|
|
2064
|
+
allowReview: true,
|
|
2065
|
+
showSolution: false,
|
|
2066
|
+
allowComment: false,
|
|
2067
|
+
allowSkipping: true,
|
|
2068
|
+
validateResponses: false
|
|
2069
|
+
};
|
|
2070
|
+
function definedControl(control) {
|
|
2071
|
+
return control ? Object.fromEntries(Object.entries(control).filter(([, value]) => value !== undefined)) : {};
|
|
2072
|
+
}
|
|
2073
|
+
function seededPick(pool, count, random) {
|
|
2074
|
+
const indices = pool.map((_, index) => index);
|
|
2075
|
+
for (let i = indices.length - 1;i > 0; i -= 1) {
|
|
2076
|
+
const j = Math.floor(random() * (i + 1));
|
|
2077
|
+
[indices[i], indices[j]] = [indices[j], indices[i]];
|
|
2078
|
+
}
|
|
2079
|
+
return indices.slice(0, Math.min(count, indices.length)).sort((a, b) => a - b).map((index) => pool[index]);
|
|
2080
|
+
}
|
|
2081
|
+
function applySelection(children, select, random) {
|
|
2082
|
+
const required = children.filter((child) => child.required === true);
|
|
2083
|
+
const optional = children.filter((child) => child.required !== true);
|
|
2084
|
+
const needed = Math.max(0, select - required.length);
|
|
2085
|
+
const picked = new Set([...required, ...seededPick(optional, needed, random)]);
|
|
2086
|
+
return children.filter((child) => picked.has(child));
|
|
2087
|
+
}
|
|
2088
|
+
function applyOrdering(children, random) {
|
|
2089
|
+
const result = children.map((child) => child.fixed === true ? child : null);
|
|
2090
|
+
const movable = children.filter((child) => child.fixed !== true);
|
|
2091
|
+
const shuffled = seededPick(movable, movable.length, random);
|
|
2092
|
+
for (let i = shuffled.length - 1;i > 0; i -= 1) {
|
|
2093
|
+
const j = Math.floor(random() * (i + 1));
|
|
2094
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
2095
|
+
}
|
|
2096
|
+
let cursor = 0;
|
|
2097
|
+
return result.map((slot) => slot ?? shuffled[cursor++]);
|
|
2098
|
+
}
|
|
2099
|
+
function resolveSection(section, partIdentifier, sectionPath, inheritedPreConditions, inheritedControl, random) {
|
|
2100
|
+
const path = [...sectionPath, section.identifier];
|
|
2101
|
+
const preConditions = [...inheritedPreConditions, ...section.preConditions ?? []];
|
|
2102
|
+
const control = { ...inheritedControl, ...definedControl(section.itemSessionControl) };
|
|
2103
|
+
let children = section.children;
|
|
2104
|
+
if (section.selection) {
|
|
2105
|
+
children = applySelection(children, section.selection.select, random);
|
|
2106
|
+
}
|
|
2107
|
+
if (section.ordering?.shuffle) {
|
|
2108
|
+
children = applyOrdering(children, random);
|
|
2109
|
+
}
|
|
2110
|
+
const items = [];
|
|
2111
|
+
for (const child of children) {
|
|
2112
|
+
if (child.kind === "assessmentSection") {
|
|
2113
|
+
items.push(...resolveSection(child, partIdentifier, path, preConditions, control, random));
|
|
2114
|
+
} else {
|
|
2115
|
+
items.push({
|
|
2116
|
+
key: child.identifier,
|
|
2117
|
+
ref: child,
|
|
2118
|
+
partIdentifier,
|
|
2119
|
+
sectionPath: path,
|
|
2120
|
+
preConditions: [...preConditions, ...child.preConditions ?? []],
|
|
2121
|
+
sessionControl: { ...specSessionControlDefaults, ...control, ...definedControl(child.itemSessionControl) },
|
|
2122
|
+
...child.timeLimits ? { timeLimits: child.timeLimits } : {}
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return items;
|
|
2127
|
+
}
|
|
2128
|
+
function resolvePlan(view, seed) {
|
|
2129
|
+
const random = mulberry32(seed);
|
|
2130
|
+
return {
|
|
2131
|
+
...view.timeLimits ? { timeLimits: view.timeLimits } : {},
|
|
2132
|
+
parts: view.testParts.map((part) => ({
|
|
2133
|
+
identifier: part.identifier,
|
|
2134
|
+
navigationMode: part.navigationMode,
|
|
2135
|
+
submissionMode: part.submissionMode,
|
|
2136
|
+
...part.timeLimits ? { timeLimits: part.timeLimits } : {},
|
|
2137
|
+
items: part.assessmentSections.flatMap((section) => resolveSection(section, part.identifier, [], [], definedControl(part.itemSessionControl), random))
|
|
2138
|
+
}))
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
function createTestController(view, options) {
|
|
2142
|
+
const plan = resolvePlan(view, options.seed);
|
|
2143
|
+
const allItems = plan.parts.flatMap((part) => [...part.items]);
|
|
2144
|
+
const partIndexByItemKey = new Map;
|
|
2145
|
+
const itemsByKey = new Map;
|
|
2146
|
+
plan.parts.forEach((part, partIndex) => {
|
|
2147
|
+
for (const item of part.items) {
|
|
2148
|
+
partIndexByItemKey.set(item.key, partIndex);
|
|
2149
|
+
itemsByKey.set(item.key, item);
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
function attemptsOf(state, itemKey) {
|
|
2153
|
+
return (state.attemptCounts ?? {})[itemKey] ?? 0;
|
|
2154
|
+
}
|
|
2155
|
+
function subsetItems(expression) {
|
|
2156
|
+
const asList2 = (value) => typeof value === "string" ? [value] : value;
|
|
2157
|
+
const includeCategory = asList2(expression.includeCategory);
|
|
2158
|
+
const excludeCategory = asList2(expression.excludeCategory);
|
|
2159
|
+
return allItems.filter((item) => {
|
|
2160
|
+
if (expression.sectionIdentifier !== undefined && !item.sectionPath.includes(expression.sectionIdentifier)) {
|
|
2161
|
+
return false;
|
|
2162
|
+
}
|
|
2163
|
+
const categories = item.ref.categories ?? [];
|
|
2164
|
+
if (includeCategory !== undefined && !includeCategory.some((category) => categories.includes(category))) {
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
return !(excludeCategory !== undefined && excludeCategory.some((category) => categories.includes(category)));
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
function remainingAttempts(state, itemKey) {
|
|
2171
|
+
const item = itemsByKey.get(itemKey);
|
|
2172
|
+
if (!item) {
|
|
2173
|
+
return 0;
|
|
2174
|
+
}
|
|
2175
|
+
const max = item.sessionControl.maxAttempts;
|
|
2176
|
+
return max === 0 ? Number.POSITIVE_INFINITY : Math.max(0, max - attemptsOf(state, itemKey));
|
|
2177
|
+
}
|
|
2178
|
+
function defaultTestOutcomes() {
|
|
2179
|
+
const outcomes = new Map;
|
|
2180
|
+
for (const declaration of view.outcomeDeclarations ?? []) {
|
|
2181
|
+
if (declaration.defaultValue) {
|
|
2182
|
+
outcomes.set(declaration.identifier, rpValue(declaration.cardinality, declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)), declaration.baseType));
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
2185
|
+
outcomes.set(declaration.identifier, isNumericBaseType(declaration.baseType) ? floatValue(0) : null);
|
|
2186
|
+
}
|
|
2187
|
+
return outcomes;
|
|
2188
|
+
}
|
|
2189
|
+
function makeEnv(state, outcomes) {
|
|
2190
|
+
return {
|
|
2191
|
+
lookupVariable: (identifier) => {
|
|
2192
|
+
const dot = identifier.indexOf(".");
|
|
2193
|
+
if (dot !== -1) {
|
|
2194
|
+
const itemKey = identifier.slice(0, dot);
|
|
2195
|
+
const variableName = identifier.slice(dot + 1);
|
|
2196
|
+
return liftFlat(state.itemOutcomes[itemKey]?.[variableName] ?? null);
|
|
2197
|
+
}
|
|
2198
|
+
if (outcomes?.has(identifier)) {
|
|
2199
|
+
return outcomes.get(identifier) ?? null;
|
|
2200
|
+
}
|
|
2201
|
+
return liftFlat(state.testOutcomes[identifier] ?? null);
|
|
2202
|
+
},
|
|
2203
|
+
responseDeclaration: () => {
|
|
2204
|
+
return;
|
|
2205
|
+
},
|
|
2206
|
+
responseValue: () => null,
|
|
2207
|
+
testVariables: (expression) => {
|
|
2208
|
+
const variableName = expression.variableIdentifier ?? expression.identifier ?? "";
|
|
2209
|
+
const weightIdentifier = expression.weightIdentifier;
|
|
2210
|
+
const members = [];
|
|
2211
|
+
let baseType = expression.baseType;
|
|
2212
|
+
for (const item of subsetItems(expression)) {
|
|
2213
|
+
const value = state.itemOutcomes[item.key]?.[variableName];
|
|
2214
|
+
if (value === undefined || value === null) {
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
const lifted = liftFlat(value);
|
|
2218
|
+
if (lifted === null) {
|
|
2219
|
+
continue;
|
|
2220
|
+
}
|
|
2221
|
+
if (weightIdentifier !== undefined && isNumericBaseType(lifted.baseType)) {
|
|
2222
|
+
const weight = item.ref.weights?.find((entry) => entry.identifier === weightIdentifier)?.value ?? 1;
|
|
2223
|
+
baseType = "float";
|
|
2224
|
+
members.push(...lifted.values.map((entry) => Number(entry) * weight));
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
baseType ??= lifted.baseType;
|
|
2228
|
+
members.push(...lifted.values);
|
|
2229
|
+
}
|
|
2230
|
+
return members.length === 0 ? null : rpValue("multiple", members, baseType);
|
|
2231
|
+
},
|
|
2232
|
+
testAggregate: (expression) => {
|
|
2233
|
+
const subset = subsetItems(expression);
|
|
2234
|
+
const integer = (value) => ({
|
|
2235
|
+
cardinality: "single",
|
|
2236
|
+
baseType: "integer",
|
|
2237
|
+
values: [value]
|
|
2238
|
+
});
|
|
2239
|
+
const countIn = (list) => {
|
|
2240
|
+
const flagged = new Set(list ?? []);
|
|
2241
|
+
return subset.filter((item) => flagged.has(item.key)).length;
|
|
2242
|
+
};
|
|
2243
|
+
switch (expression.kind) {
|
|
2244
|
+
case "numberSelected":
|
|
2245
|
+
return integer(subset.length);
|
|
2246
|
+
case "numberPresented":
|
|
2247
|
+
return integer(countIn(state.presentedItems));
|
|
2248
|
+
case "numberResponded":
|
|
2249
|
+
return integer(countIn(state.respondedItems));
|
|
2250
|
+
case "numberCorrect":
|
|
2251
|
+
return integer(countIn(state.correctItems));
|
|
2252
|
+
case "numberIncorrect":
|
|
2253
|
+
return integer(countIn(state.incorrectItems));
|
|
2254
|
+
default:
|
|
2255
|
+
throw new RpUnsupportedError(expression.kind);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
function conditionPasses(expression, state) {
|
|
2261
|
+
try {
|
|
2262
|
+
return singleBoolean(evaluateExpression(expression, makeEnv(state))) === true;
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
if (error instanceof RpUnsupportedError) {
|
|
2265
|
+
return true;
|
|
2266
|
+
}
|
|
2267
|
+
throw error;
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
function preConditionsPass(item, state) {
|
|
2271
|
+
return item.preConditions.every((expression) => conditionPasses(expression, state));
|
|
2272
|
+
}
|
|
2273
|
+
function runOutcomeProcessing(state) {
|
|
2274
|
+
let outcomes = defaultTestOutcomes();
|
|
2275
|
+
const env = makeEnv(state, outcomes);
|
|
2276
|
+
function branchTaken(branch) {
|
|
2277
|
+
if (singleBoolean(evaluateExpression(branch.expression, env)) !== true) {
|
|
2278
|
+
return false;
|
|
2279
|
+
}
|
|
2280
|
+
executeRules(branch.rules);
|
|
2281
|
+
return true;
|
|
2282
|
+
}
|
|
2283
|
+
function executeRules(rules) {
|
|
2284
|
+
for (const rule of rules) {
|
|
2285
|
+
if (!supportedOutcomeRuleKinds.has(rule.kind)) {
|
|
2286
|
+
throw new RpUnsupportedError(rule.kind);
|
|
2287
|
+
}
|
|
2288
|
+
if (rule.kind === "exitTest") {
|
|
2289
|
+
throw new ExitTestSignal;
|
|
2290
|
+
}
|
|
2291
|
+
if (rule.kind === "setOutcomeValue") {
|
|
2292
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
2293
|
+
outcomes.set(rule.identifier, evaluateExpression(rule.expression, env));
|
|
2294
|
+
}
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
if (rule.outcomeIf && branchTaken(rule.outcomeIf)) {
|
|
2298
|
+
continue;
|
|
2299
|
+
}
|
|
2300
|
+
const elseIfTaken = (rule.outcomeElseIfs ?? []).some((branch) => branchTaken(branch));
|
|
2301
|
+
if (!elseIfTaken && rule.outcomeElse) {
|
|
2302
|
+
executeRules(rule.outcomeElse.rules);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
try {
|
|
2307
|
+
executeRules(view.outcomeProcessing?.rules ?? []);
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
if (error instanceof RpUnsupportedError) {
|
|
2310
|
+
outcomes = defaultTestOutcomes();
|
|
2311
|
+
} else if (!(error instanceof ExitTestSignal)) {
|
|
2312
|
+
throw error;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
return Object.fromEntries([...outcomes].map(([identifier, value]) => [identifier, toOutcomeValue(value)]));
|
|
2316
|
+
}
|
|
2317
|
+
function firstNavigable(state, partIndex, itemIndex) {
|
|
2318
|
+
for (let p = partIndex;p < plan.parts.length; p += 1) {
|
|
2319
|
+
const items = plan.parts[p].items;
|
|
2320
|
+
for (let i = p === partIndex ? itemIndex : 0;i < items.length; i += 1) {
|
|
2321
|
+
const item = items[i];
|
|
2322
|
+
if (preConditionsPass(item, state)) {
|
|
2323
|
+
return item;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
return null;
|
|
2328
|
+
}
|
|
2329
|
+
function positionOf(itemKey) {
|
|
2330
|
+
const partIndex = partIndexByItemKey.get(itemKey);
|
|
2331
|
+
if (partIndex === undefined) {
|
|
2332
|
+
return null;
|
|
2333
|
+
}
|
|
2334
|
+
const itemIndex = plan.parts[partIndex].items.findIndex((item) => item.key === itemKey);
|
|
2335
|
+
return itemIndex === -1 ? null : { partIndex, itemIndex };
|
|
2336
|
+
}
|
|
2337
|
+
function withFlag(list, itemKey, present) {
|
|
2338
|
+
const existing = list ?? [];
|
|
2339
|
+
if (existing.includes(itemKey) === present) {
|
|
2340
|
+
return existing;
|
|
2341
|
+
}
|
|
2342
|
+
return present ? [...existing, itemKey] : existing.filter((entry) => entry !== itemKey);
|
|
2343
|
+
}
|
|
2344
|
+
function applyResultFlags(state, itemKey, result) {
|
|
2345
|
+
return {
|
|
2346
|
+
...state,
|
|
2347
|
+
respondedItems: withFlag(state.respondedItems, itemKey, result.responded === true),
|
|
2348
|
+
correctItems: withFlag(state.correctItems, itemKey, result.correct === true),
|
|
2349
|
+
incorrectItems: withFlag(state.incorrectItems, itemKey, result.correct === false)
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
function markPresented(state, itemKey) {
|
|
2353
|
+
return (state.presentedItems ?? []).includes(itemKey) ? state : { ...state, presentedItems: [...state.presentedItems ?? [], itemKey] };
|
|
2354
|
+
}
|
|
2355
|
+
function flushPending(state, partIndex) {
|
|
2356
|
+
const pending = state.pendingItemResults ?? {};
|
|
2357
|
+
const keys = Object.keys(pending).filter((key) => partIndex === null || partIndexByItemKey.get(key) === partIndex);
|
|
2358
|
+
if (keys.length === 0) {
|
|
2359
|
+
return state;
|
|
2360
|
+
}
|
|
2361
|
+
const itemOutcomes = { ...state.itemOutcomes };
|
|
2362
|
+
const attemptCounts = { ...state.attemptCounts ?? {} };
|
|
2363
|
+
const remaining = { ...pending };
|
|
2364
|
+
let flagged = state;
|
|
2365
|
+
for (const key of keys) {
|
|
2366
|
+
const result = pending[key];
|
|
2367
|
+
itemOutcomes[key] = result.outcomes;
|
|
2368
|
+
attemptCounts[key] = (attemptCounts[key] ?? 0) + 1;
|
|
2369
|
+
delete remaining[key];
|
|
2370
|
+
flagged = applyResultFlags(flagged, key, result);
|
|
2371
|
+
}
|
|
2372
|
+
return { ...flagged, itemOutcomes, attemptCounts, pendingItemResults: remaining };
|
|
2373
|
+
}
|
|
2374
|
+
function ended(state) {
|
|
2375
|
+
const flushed = flushPending(state, null);
|
|
2376
|
+
return { ...flushed, status: "ended", currentItemKey: null, testOutcomes: runOutcomeProcessing(flushed) };
|
|
2377
|
+
}
|
|
2378
|
+
function moveToItem(state, item) {
|
|
2379
|
+
if (item === null) {
|
|
2380
|
+
return ended(state);
|
|
2381
|
+
}
|
|
2382
|
+
const fromPart = state.currentItemKey === null ? undefined : partIndexByItemKey.get(state.currentItemKey);
|
|
2383
|
+
const toPart = partIndexByItemKey.get(item.key);
|
|
2384
|
+
let next = state;
|
|
2385
|
+
if (fromPart !== undefined && toPart !== fromPart) {
|
|
2386
|
+
const flushed = flushPending(state, fromPart);
|
|
2387
|
+
if (flushed !== state) {
|
|
2388
|
+
next = { ...flushed, testOutcomes: runOutcomeProcessing(flushed) };
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
return markPresented({ ...next, currentItemKey: item.key }, item.key);
|
|
2392
|
+
}
|
|
2393
|
+
function nextState(state) {
|
|
2394
|
+
if (state.status === "ended" || state.currentItemKey === null) {
|
|
2395
|
+
return state;
|
|
2396
|
+
}
|
|
2397
|
+
const current = positionOf(state.currentItemKey);
|
|
2398
|
+
if (!current) {
|
|
2399
|
+
return ended(state);
|
|
2400
|
+
}
|
|
2401
|
+
const part = plan.parts[current.partIndex];
|
|
2402
|
+
const currentItem = part.items[current.itemIndex];
|
|
2403
|
+
if (part.navigationMode === "linear" && !currentItem.sessionControl.allowSkipping && !state.attemptedItems.includes(currentItem.key)) {
|
|
2404
|
+
return state;
|
|
2405
|
+
}
|
|
2406
|
+
for (const branchRule of currentItem.ref.branchRules ?? []) {
|
|
2407
|
+
if (!conditionPasses(branchRule.expression, state)) {
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
if (branchRule.target === "EXIT_TEST") {
|
|
2411
|
+
return ended(state);
|
|
2412
|
+
}
|
|
2413
|
+
if (branchRule.target === "EXIT_TESTPART") {
|
|
2414
|
+
return moveToItem(state, firstNavigable(state, current.partIndex + 1, 0));
|
|
2415
|
+
}
|
|
2416
|
+
if (branchRule.target === "EXIT_SECTION") {
|
|
2417
|
+
const items = part.items;
|
|
2418
|
+
const sectionKey = currentItem.sectionPath.join("/");
|
|
2419
|
+
let index = current.itemIndex + 1;
|
|
2420
|
+
while (index < items.length && items[index].sectionPath.join("/") === sectionKey) {
|
|
2421
|
+
index += 1;
|
|
2422
|
+
}
|
|
2423
|
+
return moveToItem(state, firstNavigable(state, current.partIndex, index));
|
|
2424
|
+
}
|
|
2425
|
+
const target = positionOf(branchRule.target);
|
|
2426
|
+
if (target && target.partIndex === current.partIndex) {
|
|
2427
|
+
return moveToItem(state, firstNavigable(state, target.partIndex, target.itemIndex));
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
const destination = firstNavigable(state, current.partIndex, current.itemIndex + 1);
|
|
2431
|
+
if (part.navigationMode === "nonlinear" && (destination === null || partIndexByItemKey.get(destination.key) !== current.partIndex)) {
|
|
2432
|
+
const blocked = part.items.some((item) => !item.sessionControl.allowSkipping && !state.attemptedItems.includes(item.key) && preConditionsPass(item, state));
|
|
2433
|
+
if (blocked) {
|
|
2434
|
+
return state;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
return moveToItem(state, destination);
|
|
2438
|
+
}
|
|
2439
|
+
const issues = [];
|
|
2440
|
+
const seenIssues = new Set;
|
|
2441
|
+
function report(name) {
|
|
2442
|
+
if (!seenIssues.has(name)) {
|
|
2443
|
+
seenIssues.add(name);
|
|
2444
|
+
issues.push({ type: "unsupported-rp", name });
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
function walkOutcomeRules(rules) {
|
|
2448
|
+
for (const rule of rules) {
|
|
2449
|
+
if (!supportedOutcomeRuleKinds.has(rule.kind)) {
|
|
2450
|
+
report(rule.kind);
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2453
|
+
if (rule.expression) {
|
|
2454
|
+
collectExpressionIssues(rule.expression, testExpressionKinds, report);
|
|
2455
|
+
}
|
|
2456
|
+
for (const branch of [rule.outcomeIf, ...rule.outcomeElseIfs ?? []]) {
|
|
2457
|
+
if (branch) {
|
|
2458
|
+
collectExpressionIssues(branch.expression, testExpressionKinds, report);
|
|
2459
|
+
walkOutcomeRules(branch.rules);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (rule.outcomeElse) {
|
|
2463
|
+
walkOutcomeRules(rule.outcomeElse.rules);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
walkOutcomeRules(view.outcomeProcessing?.rules ?? []);
|
|
2468
|
+
for (const item of allItems) {
|
|
2469
|
+
for (const expression of item.preConditions) {
|
|
2470
|
+
collectExpressionIssues(expression, testExpressionKinds, report);
|
|
2471
|
+
}
|
|
2472
|
+
for (const branchRule of item.ref.branchRules ?? []) {
|
|
2473
|
+
collectExpressionIssues(branchRule.expression, testExpressionKinds, report);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
return {
|
|
2477
|
+
plan,
|
|
2478
|
+
issues,
|
|
2479
|
+
start: () => {
|
|
2480
|
+
const initial = {
|
|
2481
|
+
status: "in-progress",
|
|
2482
|
+
currentItemKey: null,
|
|
2483
|
+
itemOutcomes: {},
|
|
2484
|
+
attemptedItems: [],
|
|
2485
|
+
attemptCounts: {},
|
|
2486
|
+
presentedItems: [],
|
|
2487
|
+
respondedItems: [],
|
|
2488
|
+
correctItems: [],
|
|
2489
|
+
incorrectItems: [],
|
|
2490
|
+
pendingItemResults: {},
|
|
2491
|
+
testOutcomes: {}
|
|
2492
|
+
};
|
|
2493
|
+
return moveToItem({ ...initial, testOutcomes: runOutcomeProcessing(initial) }, firstNavigable(initial, 0, 0));
|
|
2494
|
+
},
|
|
2495
|
+
currentItem: (state) => state.currentItemKey === null ? null : allItems.find((item) => item.key === state.currentItemKey) ?? null,
|
|
2496
|
+
canMoveTo: (state, itemKey) => {
|
|
2497
|
+
if (state.status === "ended" || state.currentItemKey === null) {
|
|
2498
|
+
return false;
|
|
2499
|
+
}
|
|
2500
|
+
const current = positionOf(state.currentItemKey);
|
|
2501
|
+
const target = positionOf(itemKey);
|
|
2502
|
+
if (!current || !target || target.partIndex !== current.partIndex) {
|
|
2503
|
+
return false;
|
|
2504
|
+
}
|
|
2505
|
+
if (plan.parts[current.partIndex].navigationMode !== "nonlinear") {
|
|
2506
|
+
return false;
|
|
2507
|
+
}
|
|
2508
|
+
return preConditionsPass(plan.parts[target.partIndex].items[target.itemIndex], state);
|
|
2509
|
+
},
|
|
2510
|
+
moveTo: (state, itemKey) => {
|
|
2511
|
+
const current = positionOf(state.currentItemKey ?? "");
|
|
2512
|
+
const target = positionOf(itemKey);
|
|
2513
|
+
if (state.status === "ended" || !current || !target || target.partIndex !== current.partIndex || plan.parts[current.partIndex].navigationMode !== "nonlinear") {
|
|
2514
|
+
return state;
|
|
2515
|
+
}
|
|
2516
|
+
return markPresented({ ...state, currentItemKey: itemKey }, itemKey);
|
|
2517
|
+
},
|
|
2518
|
+
canNext: (state) => nextState(state) !== state,
|
|
2519
|
+
next: nextState,
|
|
2520
|
+
remainingAttempts,
|
|
2521
|
+
canSubmitItem: (state, itemKey) => state.status !== "ended" && remainingAttempts(state, itemKey) > 0,
|
|
2522
|
+
submitItem: (state, itemKey, result) => {
|
|
2523
|
+
if (state.status === "ended") {
|
|
2524
|
+
return state;
|
|
2525
|
+
}
|
|
2526
|
+
const partIndex = partIndexByItemKey.get(itemKey);
|
|
2527
|
+
if (partIndex !== undefined && plan.parts[partIndex].submissionMode === "simultaneous") {
|
|
2528
|
+
if (attemptsOf(state, itemKey) > 0) {
|
|
2529
|
+
return state;
|
|
2530
|
+
}
|
|
2531
|
+
return {
|
|
2532
|
+
...state,
|
|
2533
|
+
pendingItemResults: { ...state.pendingItemResults ?? {}, [itemKey]: result },
|
|
2534
|
+
attemptedItems: state.attemptedItems.includes(itemKey) ? state.attemptedItems : [...state.attemptedItems, itemKey]
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
if (result.adaptive !== true && remainingAttempts(state, itemKey) <= 0) {
|
|
2538
|
+
return state;
|
|
2539
|
+
}
|
|
2540
|
+
const next = {
|
|
2541
|
+
...applyResultFlags(state, itemKey, result),
|
|
2542
|
+
itemOutcomes: { ...state.itemOutcomes, [itemKey]: result.outcomes },
|
|
2543
|
+
attemptedItems: state.attemptedItems.includes(itemKey) ? state.attemptedItems : [...state.attemptedItems, itemKey],
|
|
2544
|
+
attemptCounts: { ...state.attemptCounts ?? {}, [itemKey]: attemptsOf(state, itemKey) + 1 }
|
|
2545
|
+
};
|
|
2546
|
+
return { ...next, testOutcomes: runOutcomeProcessing(next) };
|
|
2547
|
+
},
|
|
2548
|
+
end: (state) => state.status === "ended" ? state : ended(state),
|
|
2549
|
+
visibleTestFeedbacks: (state) => (view.testFeedbacks ?? []).filter((feedback) => {
|
|
2550
|
+
const accessOk = (feedback.access ?? "atEnd") === (state.status === "ended" ? "atEnd" : "during");
|
|
2551
|
+
if (!accessOk) {
|
|
2552
|
+
return false;
|
|
2553
|
+
}
|
|
2554
|
+
const outcome = state.testOutcomes[feedback.outcomeIdentifier] ?? null;
|
|
2555
|
+
const matched = Array.isArray(outcome) ? outcome.map(String).includes(feedback.identifier) : outcome !== null && String(outcome) === feedback.identifier;
|
|
2556
|
+
return matched !== (feedback.showHide === "hide");
|
|
2557
|
+
})
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
// src/test/session-store.ts
|
|
2561
|
+
function collectCorrectResponseTargets(rules, into) {
|
|
2562
|
+
for (const rule of rules ?? []) {
|
|
2563
|
+
if (rule.kind === "setCorrectResponse" && rule.identifier !== undefined) {
|
|
2564
|
+
into.add(rule.identifier);
|
|
2565
|
+
}
|
|
2566
|
+
for (const branch of [rule.templateIf, ...rule.templateElseIfs ?? []]) {
|
|
2567
|
+
if (branch) {
|
|
2568
|
+
collectCorrectResponseTargets(branch.rules, into);
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
if (rule.templateElse) {
|
|
2572
|
+
collectCorrectResponseTargets(rule.templateElse.rules, into);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
function scorableIdentifiers(view) {
|
|
2577
|
+
const templated = new Set;
|
|
2578
|
+
collectCorrectResponseTargets(view.templateProcessing?.rules, templated);
|
|
2579
|
+
return new Set(view.responseDeclarations.filter((declaration) => declaration.correctResponse !== undefined || declaration.mapping !== undefined || declaration.areaMapping !== undefined || templated.has(declaration.identifier)).map((declaration) => declaration.identifier));
|
|
2580
|
+
}
|
|
2581
|
+
function hasResponse(value) {
|
|
2582
|
+
if (value === null || value === undefined || value === "") {
|
|
2583
|
+
return false;
|
|
2584
|
+
}
|
|
2585
|
+
if (isResponseRecord(value)) {
|
|
2586
|
+
return Object.values(value).some((member) => member !== null && member !== "");
|
|
2587
|
+
}
|
|
2588
|
+
return !Array.isArray(value) || value.length > 0;
|
|
2589
|
+
}
|
|
2590
|
+
function resultFlags(attempt, scorable) {
|
|
2591
|
+
const relevant = attempt.scores.filter((score) => scorable.has(score.identifier));
|
|
2592
|
+
return {
|
|
2593
|
+
...relevant.length > 0 ? { correct: relevant.every((score) => score.correct) } : {},
|
|
2594
|
+
responded: Object.values(attempt.responses).some(hasResponse)
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
function deriveItemSeed(seed, itemKey) {
|
|
2598
|
+
let hash = (2166136261 ^ seed) >>> 0;
|
|
2599
|
+
for (let index = 0;index < itemKey.length; index += 1) {
|
|
2600
|
+
hash = Math.imul(hash ^ itemKey.charCodeAt(index), 16777619) >>> 0;
|
|
2601
|
+
}
|
|
2602
|
+
return hash;
|
|
2603
|
+
}
|
|
2604
|
+
function createTestSessionStore(controller, options) {
|
|
2605
|
+
const listeners = new Set;
|
|
2606
|
+
const planItemsByKey = new Map;
|
|
2607
|
+
for (const part of controller.plan.parts) {
|
|
2608
|
+
for (const item of part.items) {
|
|
2609
|
+
planItemsByKey.set(item.key, item);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
const itemViews = new Map;
|
|
2613
|
+
const itemStores = new Map;
|
|
2614
|
+
const forwardedAttempts = new Map;
|
|
2615
|
+
let state = options.initialState ?? controller.start();
|
|
2616
|
+
let snapshot = buildSnapshot();
|
|
2617
|
+
function buildSnapshot() {
|
|
2618
|
+
const currentItem = controller.currentItem(state);
|
|
2619
|
+
return {
|
|
2620
|
+
state,
|
|
2621
|
+
currentItem,
|
|
2622
|
+
currentItemView: currentItem === null ? null : itemView(currentItem.key),
|
|
2623
|
+
visibleFeedbacks: controller.visibleTestFeedbacks(state)
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
function emit(next) {
|
|
2627
|
+
state = next;
|
|
2628
|
+
snapshot = buildSnapshot();
|
|
2629
|
+
for (const listener of listeners) {
|
|
2630
|
+
listener();
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
function itemView(itemKey) {
|
|
2634
|
+
if (!itemViews.has(itemKey)) {
|
|
2635
|
+
const planItem = planItemsByKey.get(itemKey);
|
|
2636
|
+
itemViews.set(itemKey, planItem ? options.resolveItem(planItem.ref) : null);
|
|
2637
|
+
}
|
|
2638
|
+
return itemViews.get(itemKey) ?? null;
|
|
2639
|
+
}
|
|
2640
|
+
function itemStore(itemKey) {
|
|
2641
|
+
if (itemStores.has(itemKey)) {
|
|
2642
|
+
return itemStores.get(itemKey) ?? null;
|
|
2643
|
+
}
|
|
2644
|
+
const view = itemView(itemKey);
|
|
2645
|
+
if (!view) {
|
|
2646
|
+
itemStores.set(itemKey, null);
|
|
2647
|
+
return null;
|
|
2648
|
+
}
|
|
2649
|
+
const store = createAttemptStore(view.responseDeclarations, {}, {
|
|
2650
|
+
outcomeDeclarations: view.outcomeDeclarations,
|
|
2651
|
+
responseProcessing: view.responseProcessing,
|
|
2652
|
+
templateDeclarations: view.templateDeclarations,
|
|
2653
|
+
templateProcessing: view.templateProcessing,
|
|
2654
|
+
adaptive: view.adaptive,
|
|
2655
|
+
seed: deriveItemSeed(options.seed, itemKey),
|
|
2656
|
+
normalization: options.normalization,
|
|
2657
|
+
customOperators: options.customOperators
|
|
2658
|
+
});
|
|
2659
|
+
const scorable = scorableIdentifiers(view);
|
|
2660
|
+
store.subscribe(() => {
|
|
2661
|
+
const attempt = store.getSnapshot();
|
|
2662
|
+
if (attempt.submitted && forwardedAttempts.get(itemKey) !== attempt) {
|
|
2663
|
+
forwardedAttempts.set(itemKey, attempt);
|
|
2664
|
+
const next = controller.submitItem(state, itemKey, {
|
|
2665
|
+
outcomes: attempt.outcomes,
|
|
2666
|
+
...resultFlags(attempt, scorable),
|
|
2667
|
+
...view.adaptive === true ? { adaptive: true } : {}
|
|
2668
|
+
});
|
|
2669
|
+
if (next !== state) {
|
|
2670
|
+
emit(next);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
itemStores.set(itemKey, store);
|
|
2675
|
+
return store;
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
controller,
|
|
2679
|
+
subscribe: (listener) => {
|
|
2680
|
+
listeners.add(listener);
|
|
2681
|
+
return () => listeners.delete(listener);
|
|
2682
|
+
},
|
|
2683
|
+
getSnapshot: () => snapshot,
|
|
2684
|
+
itemStore,
|
|
2685
|
+
itemView,
|
|
2686
|
+
next: () => emit(controller.next(state)),
|
|
2687
|
+
canMoveTo: (itemKey) => controller.canMoveTo(state, itemKey),
|
|
2688
|
+
moveTo: (itemKey) => emit(controller.moveTo(state, itemKey)),
|
|
2689
|
+
end: () => emit(controller.end(state))
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
// src/runtime.ts
|
|
2693
|
+
import {
|
|
2694
|
+
Fragment,
|
|
2695
|
+
createContext,
|
|
2696
|
+
createElement,
|
|
2697
|
+
useContext,
|
|
2698
|
+
useMemo,
|
|
2699
|
+
useSyncExternalStore
|
|
2700
|
+
} from "react";
|
|
2701
|
+
function defineInteraction(descriptor) {
|
|
2702
|
+
return descriptor;
|
|
2703
|
+
}
|
|
2704
|
+
var RuntimeContext = createContext(null);
|
|
2705
|
+
function useRuntimeContext() {
|
|
2706
|
+
const context = useContext(RuntimeContext);
|
|
2707
|
+
if (!context) {
|
|
2708
|
+
throw new Error("QTI runtime components must be rendered inside an <ItemRenderer>.");
|
|
2709
|
+
}
|
|
2710
|
+
return context;
|
|
2711
|
+
}
|
|
2712
|
+
function responseIncludes(value, optionIdentifier) {
|
|
2713
|
+
if (value === null) {
|
|
2714
|
+
return false;
|
|
2715
|
+
}
|
|
2716
|
+
return typeof value === "string" ? value === optionIdentifier : Array.isArray(value) && value.includes(optionIdentifier);
|
|
2717
|
+
}
|
|
2718
|
+
function isCorrectOption(declaration, optionIdentifier) {
|
|
2719
|
+
return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
|
|
2720
|
+
}
|
|
2721
|
+
function isInteractionNode(node) {
|
|
2722
|
+
return node.kind !== "xml" && typeof node.responseIdentifier === "string";
|
|
2723
|
+
}
|
|
2724
|
+
var feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
|
|
2725
|
+
function isFeedbackNode(node) {
|
|
2726
|
+
return feedbackKinds.has(node.kind);
|
|
2727
|
+
}
|
|
2728
|
+
var templateContentKinds = new Set(["templateInline", "templateBlock"]);
|
|
2729
|
+
function isTemplateContentNode(node) {
|
|
2730
|
+
return templateContentKinds.has(node.kind);
|
|
2731
|
+
}
|
|
2732
|
+
function templateVisible(value, view) {
|
|
2733
|
+
const matched = Array.isArray(value) ? value.includes(view.identifier) : value === view.identifier;
|
|
2734
|
+
return matched !== (view.showHide === "hide");
|
|
2735
|
+
}
|
|
2736
|
+
var intrinsicLeafKinds = new Set(["text", "printedVariable"]);
|
|
2737
|
+
function createStaticStore(outcomes) {
|
|
2738
|
+
const snapshot = {
|
|
2739
|
+
responses: {},
|
|
2740
|
+
submitted: true,
|
|
2741
|
+
scores: [],
|
|
2742
|
+
outcomes,
|
|
2743
|
+
templateValues: {},
|
|
2744
|
+
attemptCount: 1
|
|
2745
|
+
};
|
|
2746
|
+
return {
|
|
2747
|
+
getSnapshot: () => snapshot,
|
|
2748
|
+
subscribe: () => () => {},
|
|
2749
|
+
setResponse: () => {},
|
|
2750
|
+
registerResponseCollector: () => () => {},
|
|
2751
|
+
submit: () => [],
|
|
2752
|
+
reset: () => {}
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
function feedbackVisible(outcome, feedback, submitted) {
|
|
2756
|
+
if (!submitted) {
|
|
2757
|
+
return false;
|
|
2758
|
+
}
|
|
2759
|
+
const matched = Array.isArray(outcome) ? outcome.includes(feedback.identifier) : outcome === feedback.identifier;
|
|
2760
|
+
return matched !== (feedback.showHide === "hide");
|
|
2761
|
+
}
|
|
2762
|
+
function createQtiRuntime(config) {
|
|
2763
|
+
const model = config.contentModel ?? v0ContentModel;
|
|
2764
|
+
const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
|
|
2765
|
+
const resolveAsset = config.assetResolver ?? ((href) => href);
|
|
2766
|
+
function renderFlow(node, key, overrides, inMath = false) {
|
|
2767
|
+
const isMath = inMath || node.name === model.mathRoot;
|
|
2768
|
+
if (!isMath && !isAllowedFlowElement(model, node.name)) {
|
|
2769
|
+
return null;
|
|
2770
|
+
}
|
|
2771
|
+
const attributes = isMath ? sanitizeMathAttributes(node.attributes) : sanitizeAttributes(model, node.name, node.attributes);
|
|
2772
|
+
for (const name of model.urlAttributes) {
|
|
2773
|
+
const value = attributes[name];
|
|
2774
|
+
if (value !== undefined) {
|
|
2775
|
+
attributes[name] = resolveAsset(value);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
const children = node.children?.map((child, index) => renderNode(child, index, overrides, isMath));
|
|
2779
|
+
return createElement(node.name, { key, ...attributes }, node.value ?? children);
|
|
2780
|
+
}
|
|
2781
|
+
function renderUnsupported(node, key) {
|
|
2782
|
+
if (config.renderUnsupported) {
|
|
2783
|
+
return createElement("span", { key }, config.renderUnsupported(node));
|
|
2784
|
+
}
|
|
2785
|
+
return createElement("div", { key, role: "note", "data-qti-unsupported": node.kind }, `This content requires an interaction type (${node.kind}) this runtime does not support.`);
|
|
2786
|
+
}
|
|
2787
|
+
function renderNode(node, key, overrides, inMath = false) {
|
|
2788
|
+
const override = overrides?.[node.kind];
|
|
2789
|
+
if (override) {
|
|
2790
|
+
return createElement(Fragment, { key }, override(node, key));
|
|
2791
|
+
}
|
|
2792
|
+
if (isInteractionNode(node)) {
|
|
2793
|
+
if (descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
|
|
2794
|
+
return createElement(InteractionHost, { key, node });
|
|
2795
|
+
}
|
|
2796
|
+
return renderUnsupported(node, key);
|
|
2797
|
+
}
|
|
2798
|
+
if (isFeedbackNode(node)) {
|
|
2799
|
+
return createElement(FeedbackHost, {
|
|
2800
|
+
key,
|
|
2801
|
+
feedback: node,
|
|
2802
|
+
element: node.kind === "feedbackInline" ? "span" : "div",
|
|
2803
|
+
overrides
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
if (isTemplateContentNode(node)) {
|
|
2807
|
+
return createElement(TemplateContentHost, {
|
|
2808
|
+
key,
|
|
2809
|
+
view: node,
|
|
2810
|
+
element: node.kind === "templateInline" ? "span" : "div",
|
|
2811
|
+
overrides
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
if (node.kind === "rubricBlock") {
|
|
2815
|
+
const rubric = node;
|
|
2816
|
+
if (!rubric.view?.includes("candidate")) {
|
|
2817
|
+
return null;
|
|
2818
|
+
}
|
|
2819
|
+
return createElement("div", { key, "data-qti-rubric-block": true }, rubric.content?.map((child, index) => renderNode(child, index, overrides)));
|
|
2820
|
+
}
|
|
2821
|
+
if (node.kind === "printedVariable") {
|
|
2822
|
+
const identifier = node.identifier;
|
|
2823
|
+
return createElement(PrintedVariableHost, {
|
|
2824
|
+
key,
|
|
2825
|
+
identifier: typeof identifier === "string" ? identifier : ""
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
if (node.kind === "xml") {
|
|
2829
|
+
return renderFlow(node, key, overrides, inMath);
|
|
2830
|
+
}
|
|
2831
|
+
const value = node.value;
|
|
2832
|
+
return typeof value === "string" ? value : null;
|
|
2833
|
+
}
|
|
2834
|
+
function FeedbackHost({
|
|
2835
|
+
feedback,
|
|
2836
|
+
element,
|
|
2837
|
+
overrides
|
|
2838
|
+
}) {
|
|
2839
|
+
const { store } = useRuntimeContext();
|
|
2840
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
2841
|
+
const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
|
|
2842
|
+
if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
|
|
2843
|
+
return null;
|
|
2844
|
+
}
|
|
2845
|
+
return createElement(element, { "data-qti-feedback": feedback.identifier }, feedback.content?.map((child, index) => renderNode(child, index, overrides)));
|
|
2846
|
+
}
|
|
2847
|
+
function TemplateContentHost({
|
|
2848
|
+
view,
|
|
2849
|
+
element,
|
|
2850
|
+
overrides
|
|
2851
|
+
}) {
|
|
2852
|
+
const { store } = useRuntimeContext();
|
|
2853
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
2854
|
+
const value = snapshot.templateValues[view.templateIdentifier] ?? null;
|
|
2855
|
+
if (!templateVisible(value, view)) {
|
|
2856
|
+
return null;
|
|
2857
|
+
}
|
|
2858
|
+
return createElement(element, { "data-qti-template": view.identifier }, view.content?.map((child, index) => renderNode(child, index, overrides)));
|
|
2859
|
+
}
|
|
2860
|
+
function PrintedVariableHost({ identifier }) {
|
|
2861
|
+
const { store } = useRuntimeContext();
|
|
2862
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
2863
|
+
const value = snapshot.templateValues[identifier] ?? snapshot.outcomes[identifier] ?? null;
|
|
2864
|
+
const text = value === null ? "" : Array.isArray(value) ? value.join(" ") : String(value);
|
|
2865
|
+
return createElement("span", { "data-qti-printed-variable": identifier }, text);
|
|
2866
|
+
}
|
|
2867
|
+
function ModalFeedbackHost({ feedback }) {
|
|
2868
|
+
const { store } = useRuntimeContext();
|
|
2869
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
2870
|
+
const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
|
|
2871
|
+
if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
|
|
2872
|
+
return null;
|
|
2873
|
+
}
|
|
2874
|
+
return createElement("div", { role: "status", "data-qti-modal-feedback": feedback.identifier }, feedback.content?.map((child, index) => renderNode(child, index)));
|
|
2875
|
+
}
|
|
2876
|
+
function InteractionHost({ node }) {
|
|
2877
|
+
const { store, declarationsById } = useRuntimeContext();
|
|
2878
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
2879
|
+
const responseIdentifier = node.responseIdentifier;
|
|
2880
|
+
const declaration = declarationsById.get(responseIdentifier);
|
|
2881
|
+
const cardinality = declaration?.cardinality ?? "single";
|
|
2882
|
+
const value = snapshot.responses[responseIdentifier] ?? null;
|
|
2883
|
+
const disabled = snapshot.submitted;
|
|
2884
|
+
const answered = value !== null && !(typeof value === "string" && value.trim() === "") && !(Array.isArray(value) && value.length === 0);
|
|
2885
|
+
let status = answered ? "answered" : "unanswered";
|
|
2886
|
+
if (disabled) {
|
|
2887
|
+
const scored = snapshot.scores.find((score) => score.identifier === responseIdentifier);
|
|
2888
|
+
status = scored?.correct ? "correct" : "incorrect";
|
|
2889
|
+
}
|
|
2890
|
+
const setValue = (next) => {
|
|
2891
|
+
store.setResponse(responseIdentifier, next);
|
|
2892
|
+
};
|
|
2893
|
+
const getOptionProps = (optionIdentifier) => {
|
|
2894
|
+
const selected = responseIncludes(value, optionIdentifier);
|
|
2895
|
+
let status2 = selected ? "selected" : "idle";
|
|
2896
|
+
if (disabled) {
|
|
2897
|
+
if (isCorrectOption(declaration, optionIdentifier)) {
|
|
2898
|
+
status2 = "correct";
|
|
2899
|
+
} else if (selected) {
|
|
2900
|
+
status2 = "incorrect";
|
|
2901
|
+
} else {
|
|
2902
|
+
status2 = "idle";
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
return {
|
|
2906
|
+
role: cardinality === "single" ? "radio" : "checkbox",
|
|
2907
|
+
tabIndex: 0,
|
|
2908
|
+
"aria-checked": selected,
|
|
2909
|
+
"aria-disabled": disabled,
|
|
2910
|
+
"data-status": status2,
|
|
2911
|
+
onClick: () => {
|
|
2912
|
+
if (disabled) {
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
if (cardinality === "single") {
|
|
2916
|
+
setValue(optionIdentifier);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const current = value === null ? [] : typeof value === "string" ? [value] : Array.isArray(value) ? [...value] : [];
|
|
2920
|
+
const next = selected ? current.filter((entry) => entry !== optionIdentifier) : [...current, optionIdentifier];
|
|
2921
|
+
setValue(next);
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
};
|
|
2925
|
+
const renderContent = (nodes, overrides) => nodes ? nodes.map((child, index) => renderNode(child, index, overrides)) : null;
|
|
2926
|
+
const Skin = config.skin[node.kind];
|
|
2927
|
+
if (!Skin) {
|
|
2928
|
+
return null;
|
|
2929
|
+
}
|
|
2930
|
+
return createElement(Skin, {
|
|
2931
|
+
node,
|
|
2932
|
+
responseIdentifier,
|
|
2933
|
+
value,
|
|
2934
|
+
setValue,
|
|
2935
|
+
disabled,
|
|
2936
|
+
showFeedback: disabled,
|
|
2937
|
+
status,
|
|
2938
|
+
getOptionProps,
|
|
2939
|
+
renderContent,
|
|
2940
|
+
resolveAsset,
|
|
2941
|
+
endAttempt: () => {
|
|
2942
|
+
store.setResponse(responseIdentifier, "true");
|
|
2943
|
+
store.submit();
|
|
2944
|
+
},
|
|
2945
|
+
registerResponseCollector: (collector) => store.registerResponseCollector(responseIdentifier, collector)
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
function ContentRenderer({ nodes, outcomes }) {
|
|
2949
|
+
const store = useMemo(() => createStaticStore(outcomes ?? {}), [outcomes]);
|
|
2950
|
+
const declarationsById = useMemo(() => new Map, []);
|
|
2951
|
+
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, nodes?.map((node, index) => renderNode(node, index)));
|
|
2952
|
+
}
|
|
2953
|
+
function ItemRenderer({ item, store: externalStore, seed, children }) {
|
|
2954
|
+
const store = useMemo(() => externalStore ?? createAttemptStore(item.responseDeclarations, {}, {
|
|
2955
|
+
outcomeDeclarations: item.outcomeDeclarations,
|
|
2956
|
+
responseProcessing: item.responseProcessing,
|
|
2957
|
+
templateDeclarations: item.templateDeclarations,
|
|
2958
|
+
templateProcessing: item.templateProcessing,
|
|
2959
|
+
adaptive: item.adaptive,
|
|
2960
|
+
seed,
|
|
2961
|
+
normalization: config.normalization,
|
|
2962
|
+
customOperators: config.customOperators
|
|
2963
|
+
}), [item, externalStore, seed]);
|
|
2964
|
+
const declarationsById = useMemo(() => new Map(item.responseDeclarations.map((declaration) => [declaration.identifier, declaration])), [item]);
|
|
2965
|
+
const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
|
|
2966
|
+
const modals = (item.modalFeedbacks ?? []).map((feedback, index) => createElement(ModalFeedbackHost, { key: index, feedback }));
|
|
2967
|
+
return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, modals, children);
|
|
2968
|
+
}
|
|
2969
|
+
function useAttempt() {
|
|
2970
|
+
const { store } = useRuntimeContext();
|
|
2971
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
2972
|
+
return {
|
|
2973
|
+
...snapshot,
|
|
2974
|
+
submit: store.submit,
|
|
2975
|
+
reset: store.reset
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
function canDeliver(item) {
|
|
2979
|
+
const issues = [];
|
|
2980
|
+
const seen = new Set;
|
|
2981
|
+
function report(issue) {
|
|
2982
|
+
const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
|
|
2983
|
+
if (!seen.has(dedupeKey)) {
|
|
2984
|
+
seen.add(dedupeKey);
|
|
2985
|
+
issues.push(issue);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
function walk(node) {
|
|
2989
|
+
if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
|
|
2990
|
+
for (const child of node.content ?? []) {
|
|
2991
|
+
walk(child);
|
|
2992
|
+
}
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
if (isInteractionNode(node)) {
|
|
2996
|
+
const descriptor = descriptorsByKind.get(node.kind);
|
|
2997
|
+
if (!descriptor || !config.skin[node.kind]) {
|
|
2998
|
+
report({
|
|
2999
|
+
type: "unsupported-interaction",
|
|
3000
|
+
name: node.kind,
|
|
3001
|
+
responseIdentifier: node.responseIdentifier
|
|
3002
|
+
});
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
const parsed = descriptor.schema.safeParse(node);
|
|
3006
|
+
if (!parsed.success) {
|
|
3007
|
+
const detail = parsed.error.issues[0]?.message;
|
|
3008
|
+
report({
|
|
3009
|
+
type: "invalid-interaction",
|
|
3010
|
+
name: node.kind,
|
|
3011
|
+
responseIdentifier: node.responseIdentifier,
|
|
3012
|
+
...detail !== undefined ? { detail } : {}
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
if (node.kind === "xml") {
|
|
3018
|
+
const xmlNode = node;
|
|
3019
|
+
if (xmlNode.name === model.mathRoot) {
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
if (!isAllowedFlowElement(model, xmlNode.name)) {
|
|
3023
|
+
report({ type: "unsupported-element", name: xmlNode.name });
|
|
3024
|
+
}
|
|
3025
|
+
for (const child of xmlNode.children ?? []) {
|
|
3026
|
+
walk(child);
|
|
3027
|
+
}
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
if (intrinsicLeafKinds.has(node.kind)) {
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
report({ type: "unsupported-element", name: node.kind });
|
|
3034
|
+
}
|
|
3035
|
+
for (const node of item.itemBody.content ?? []) {
|
|
3036
|
+
walk(node);
|
|
3037
|
+
}
|
|
3038
|
+
for (const feedback of item.modalFeedbacks ?? []) {
|
|
3039
|
+
for (const child of feedback.content ?? []) {
|
|
3040
|
+
walk(child);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
|
|
3044
|
+
for (const issue of collectRpIssues(item.responseProcessing, { customOperatorClasses })) {
|
|
3045
|
+
report(issue);
|
|
3046
|
+
}
|
|
3047
|
+
for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
|
|
3048
|
+
report(issue);
|
|
3049
|
+
}
|
|
3050
|
+
return { deliverable: issues.length === 0, issues };
|
|
3051
|
+
}
|
|
3052
|
+
return { ItemRenderer, ContentRenderer, useAttempt, canDeliver };
|
|
3053
|
+
}
|
|
3054
|
+
// src/pci/response.ts
|
|
3055
|
+
function scalarToString(value) {
|
|
3056
|
+
if (Array.isArray(value)) {
|
|
3057
|
+
return value.map(String).join(" ");
|
|
3058
|
+
}
|
|
3059
|
+
return String(value);
|
|
3060
|
+
}
|
|
3061
|
+
function payloadEntry(payload) {
|
|
3062
|
+
if (typeof payload !== "object" || payload === null) {
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
const values = Object.values(payload);
|
|
3066
|
+
return values.length > 0 ? values[0] : undefined;
|
|
3067
|
+
}
|
|
3068
|
+
function recordField(entry) {
|
|
3069
|
+
if (typeof entry !== "object" || entry === null) {
|
|
3070
|
+
return null;
|
|
3071
|
+
}
|
|
3072
|
+
const { name, base } = entry;
|
|
3073
|
+
if (typeof name !== "string") {
|
|
3074
|
+
return null;
|
|
3075
|
+
}
|
|
3076
|
+
const raw = payloadEntry(base);
|
|
3077
|
+
if (raw === undefined || raw === null) {
|
|
3078
|
+
return [name, null];
|
|
3079
|
+
}
|
|
3080
|
+
return [name, typeof raw === "boolean" || typeof raw === "number" ? raw : scalarToString(raw)];
|
|
3081
|
+
}
|
|
3082
|
+
function pciResponseToValue(json) {
|
|
3083
|
+
if (typeof json !== "object" || json === null) {
|
|
3084
|
+
return null;
|
|
3085
|
+
}
|
|
3086
|
+
const shaped = json;
|
|
3087
|
+
if (shaped.base !== undefined) {
|
|
3088
|
+
const entry = payloadEntry(shaped.base);
|
|
3089
|
+
return entry === undefined || entry === null ? null : scalarToString(entry);
|
|
3090
|
+
}
|
|
3091
|
+
if (shaped.list !== undefined) {
|
|
3092
|
+
const entry = payloadEntry(shaped.list);
|
|
3093
|
+
return Array.isArray(entry) ? entry.map(scalarToString) : null;
|
|
3094
|
+
}
|
|
3095
|
+
if (Array.isArray(shaped.record)) {
|
|
3096
|
+
return Object.fromEntries(shaped.record.flatMap((entry) => {
|
|
3097
|
+
const field = recordField(entry);
|
|
3098
|
+
return field === null ? [] : [field];
|
|
3099
|
+
}));
|
|
3100
|
+
}
|
|
3101
|
+
return null;
|
|
3102
|
+
}
|
|
3103
|
+
function fieldPciType(value) {
|
|
3104
|
+
return typeof value === "boolean" ? "boolean" : typeof value === "number" ? "float" : "string";
|
|
3105
|
+
}
|
|
3106
|
+
var numericBaseTypes3 = new Set(["integer", "float"]);
|
|
3107
|
+
function bindScalar(value, baseType) {
|
|
3108
|
+
if (numericBaseTypes3.has(baseType)) {
|
|
3109
|
+
return Number(value);
|
|
3110
|
+
}
|
|
3111
|
+
if (baseType === "boolean") {
|
|
3112
|
+
return value === "true";
|
|
3113
|
+
}
|
|
3114
|
+
if (baseType === "point") {
|
|
3115
|
+
return value.split(/\s+/u).map(Number);
|
|
3116
|
+
}
|
|
3117
|
+
if (baseType === "pair" || baseType === "directedPair") {
|
|
3118
|
+
return value.split(/\s+/u);
|
|
3119
|
+
}
|
|
3120
|
+
return value;
|
|
3121
|
+
}
|
|
3122
|
+
function valueToPciResponse(value, declaration) {
|
|
3123
|
+
const baseType = declaration.baseType ?? "string";
|
|
3124
|
+
if (value === null || value === undefined) {
|
|
3125
|
+
return { base: null };
|
|
3126
|
+
}
|
|
3127
|
+
if (isResponseRecord(value)) {
|
|
3128
|
+
return {
|
|
3129
|
+
record: Object.entries(value).map(([name, member]) => member === null ? { name, base: null } : { name, base: { [fieldPciType(member)]: member } })
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
if (declaration.cardinality === "single") {
|
|
3133
|
+
const single = Array.isArray(value) ? value[0] : value;
|
|
3134
|
+
return single === undefined ? { base: null } : { base: { [baseType]: bindScalar(single, baseType) } };
|
|
3135
|
+
}
|
|
3136
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
3137
|
+
return { list: { [baseType]: entries.map((entry) => bindScalar(entry, baseType)) } };
|
|
3138
|
+
}
|
|
3139
|
+
// src/pci/registry.ts
|
|
3140
|
+
function defaultFetchText(url) {
|
|
3141
|
+
return fetch(url).then((response) => {
|
|
3142
|
+
if (!response.ok) {
|
|
3143
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
3144
|
+
}
|
|
3145
|
+
return response.text();
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
function createPciModuleRegistry(options = {}) {
|
|
3149
|
+
const fetchText = options.fetchText ?? defaultFetchText;
|
|
3150
|
+
const definitions = new Map;
|
|
3151
|
+
const resolved = new Map;
|
|
3152
|
+
const byTypeIdentifier = new Map;
|
|
3153
|
+
let contextRegistrations = [];
|
|
3154
|
+
const qtiCustomInteractionContext = {
|
|
3155
|
+
register(module) {
|
|
3156
|
+
contextRegistrations.push(module);
|
|
3157
|
+
if (module.typeIdentifier !== undefined) {
|
|
3158
|
+
byTypeIdentifier.set(module.typeIdentifier, module);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
};
|
|
3162
|
+
function resolveDependency(id, dependedOnBy, resolving) {
|
|
3163
|
+
if (id === "qtiCustomInteractionContext") {
|
|
3164
|
+
return qtiCustomInteractionContext;
|
|
3165
|
+
}
|
|
3166
|
+
if (resolved.has(id)) {
|
|
3167
|
+
return resolved.get(id);
|
|
3168
|
+
}
|
|
3169
|
+
const definition = definitions.get(id);
|
|
3170
|
+
if (!definition) {
|
|
3171
|
+
throw new Error(`PCI module "${dependedOnBy}" depends on "${id}", which is not registered.`);
|
|
3172
|
+
}
|
|
3173
|
+
return instantiate(id, definition, resolving);
|
|
3174
|
+
}
|
|
3175
|
+
function instantiate(id, definition, resolving) {
|
|
3176
|
+
if (resolving.has(id)) {
|
|
3177
|
+
throw new Error(`Circular PCI module dependency involving "${id}".`);
|
|
3178
|
+
}
|
|
3179
|
+
resolving.add(id);
|
|
3180
|
+
const dependencies = definition.dependencies.map((dependency) => resolveDependency(dependency, id, resolving));
|
|
3181
|
+
const beforeCount = contextRegistrations.length;
|
|
3182
|
+
const value = definition.factory(...dependencies);
|
|
3183
|
+
const registered = contextRegistrations.length > beforeCount ? contextRegistrations.at(-1) : undefined;
|
|
3184
|
+
const moduleValue = value ?? registered;
|
|
3185
|
+
resolving.delete(id);
|
|
3186
|
+
resolved.set(id, moduleValue);
|
|
3187
|
+
const candidate = moduleValue;
|
|
3188
|
+
if (candidate?.typeIdentifier !== undefined && typeof candidate.getInstance === "function") {
|
|
3189
|
+
byTypeIdentifier.set(candidate.typeIdentifier, candidate);
|
|
3190
|
+
}
|
|
3191
|
+
return moduleValue;
|
|
3192
|
+
}
|
|
3193
|
+
function evaluate(source, context) {
|
|
3194
|
+
const define = (...args) => {
|
|
3195
|
+
const id = typeof args[0] === "string" ? args.shift() : context.id;
|
|
3196
|
+
const dependencies = Array.isArray(args[0]) ? args.shift() : [];
|
|
3197
|
+
const factoryArg = args[0];
|
|
3198
|
+
const factory = typeof factoryArg === "function" ? factoryArg : () => factoryArg;
|
|
3199
|
+
definitions.set(id, { dependencies, factory });
|
|
3200
|
+
};
|
|
3201
|
+
define.amd = {};
|
|
3202
|
+
new Function("define", `"use strict";
|
|
3203
|
+
${source}`)(define);
|
|
3204
|
+
}
|
|
3205
|
+
function resolve(id) {
|
|
3206
|
+
const fromType = byTypeIdentifier.get(id);
|
|
3207
|
+
if (fromType) {
|
|
3208
|
+
return fromType;
|
|
3209
|
+
}
|
|
3210
|
+
if (!resolved.has(id)) {
|
|
3211
|
+
const definition = definitions.get(id);
|
|
3212
|
+
if (!definition) {
|
|
3213
|
+
for (const [pendingId, pending] of definitions) {
|
|
3214
|
+
if (!resolved.has(pendingId)) {
|
|
3215
|
+
try {
|
|
3216
|
+
instantiate(pendingId, pending, new Set);
|
|
3217
|
+
} catch {}
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
return byTypeIdentifier.get(id);
|
|
3221
|
+
}
|
|
3222
|
+
instantiate(id, definition, new Set);
|
|
3223
|
+
}
|
|
3224
|
+
const value = resolved.get(id);
|
|
3225
|
+
return value && typeof value.getInstance === "function" ? value : undefined;
|
|
3226
|
+
}
|
|
3227
|
+
function toUrl(path) {
|
|
3228
|
+
const withExtension = /\.[a-z]+$/iu.test(path) ? path : `${path}.js`;
|
|
3229
|
+
return options.baseUrl ? new URL(withExtension, options.baseUrl).toString() : withExtension;
|
|
3230
|
+
}
|
|
3231
|
+
async function load(id, candidates) {
|
|
3232
|
+
const existing = resolve(id);
|
|
3233
|
+
if (existing) {
|
|
3234
|
+
return existing;
|
|
3235
|
+
}
|
|
3236
|
+
const pathsEntry = options.paths?.[id];
|
|
3237
|
+
const allCandidates = candidates.length > 0 ? candidates : pathsEntry !== undefined ? [pathsEntry] : [];
|
|
3238
|
+
const failures = [];
|
|
3239
|
+
for (const candidate of allCandidates) {
|
|
3240
|
+
try {
|
|
3241
|
+
evaluate(await fetchText(toUrl(candidate)), { id });
|
|
3242
|
+
const module = resolve(id);
|
|
3243
|
+
if (module) {
|
|
3244
|
+
return module;
|
|
3245
|
+
}
|
|
3246
|
+
failures.push(`${candidate}: evaluated but did not register a PCI module`);
|
|
3247
|
+
} catch (error) {
|
|
3248
|
+
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
throw new Error(`PCI module "${id}" could not be loaded. ${failures.join("; ") || "No candidate paths."}`);
|
|
3252
|
+
}
|
|
3253
|
+
return {
|
|
3254
|
+
evaluate,
|
|
3255
|
+
registerModule: (id, module) => {
|
|
3256
|
+
resolved.set(id, module);
|
|
3257
|
+
if (module.typeIdentifier !== undefined) {
|
|
3258
|
+
byTypeIdentifier.set(module.typeIdentifier, module);
|
|
3259
|
+
}
|
|
3260
|
+
},
|
|
3261
|
+
resolve,
|
|
3262
|
+
load
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
// src/pci/markup.ts
|
|
3266
|
+
var voidElements = new Set([
|
|
3267
|
+
"area",
|
|
3268
|
+
"base",
|
|
3269
|
+
"br",
|
|
3270
|
+
"col",
|
|
3271
|
+
"embed",
|
|
3272
|
+
"hr",
|
|
3273
|
+
"img",
|
|
3274
|
+
"input",
|
|
3275
|
+
"link",
|
|
3276
|
+
"meta",
|
|
3277
|
+
"source",
|
|
3278
|
+
"track",
|
|
3279
|
+
"wbr"
|
|
3280
|
+
]);
|
|
3281
|
+
var blockedElements = new Set(["script", "iframe", "object", "embed"]);
|
|
3282
|
+
var urlAttributes = new Set(["src", "href", "xlink:href", "poster", "data"]);
|
|
3283
|
+
function escapeText(value) {
|
|
3284
|
+
return value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
3285
|
+
}
|
|
3286
|
+
function escapeAttribute(value) {
|
|
3287
|
+
return escapeText(value).replace(/"/gu, """);
|
|
3288
|
+
}
|
|
3289
|
+
function isUnsafeMarkupAttribute(name, value) {
|
|
3290
|
+
if (/^on/iu.test(name)) {
|
|
3291
|
+
return true;
|
|
3292
|
+
}
|
|
3293
|
+
return urlAttributes.has(name.toLowerCase()) && /^\s*(?:javascript|vbscript|data:text\/html)/iu.test(value);
|
|
3294
|
+
}
|
|
3295
|
+
function serializeAttributes(attributes) {
|
|
3296
|
+
if (!attributes) {
|
|
3297
|
+
return "";
|
|
3298
|
+
}
|
|
3299
|
+
let result = "";
|
|
3300
|
+
for (const [name, raw] of Object.entries(attributes)) {
|
|
3301
|
+
const value = typeof raw === "string" ? raw : typeof raw === "number" || typeof raw === "boolean" ? String(raw) : undefined;
|
|
3302
|
+
if (value !== undefined && !isUnsafeMarkupAttribute(name, value)) {
|
|
3303
|
+
result += ` ${name}="${escapeAttribute(value)}"`;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return result;
|
|
3307
|
+
}
|
|
3308
|
+
function serializeNode(node) {
|
|
3309
|
+
if (typeof node === "string") {
|
|
3310
|
+
return escapeText(node);
|
|
3311
|
+
}
|
|
3312
|
+
if (node.kind === "text") {
|
|
3313
|
+
return escapeText(node.value ?? "");
|
|
3314
|
+
}
|
|
3315
|
+
if (node.kind !== "xml") {
|
|
3316
|
+
return "";
|
|
3317
|
+
}
|
|
3318
|
+
const xmlNode = node;
|
|
3319
|
+
const name = xmlNode.name.toLowerCase();
|
|
3320
|
+
if (blockedElements.has(name)) {
|
|
3321
|
+
return "";
|
|
3322
|
+
}
|
|
3323
|
+
const attributes = serializeAttributes(xmlNode.attributes);
|
|
3324
|
+
if (voidElements.has(name)) {
|
|
3325
|
+
return `<${name}${attributes}>`;
|
|
3326
|
+
}
|
|
3327
|
+
const children = (xmlNode.children ?? []).map(serializeNode).join("");
|
|
3328
|
+
const text = xmlNode.value !== undefined ? escapeText(xmlNode.value) : "";
|
|
3329
|
+
return `<${name}${attributes}>${text}${children}</${name}>`;
|
|
3330
|
+
}
|
|
3331
|
+
function serializePciMarkup(nodes) {
|
|
3332
|
+
return (nodes ?? []).map(serializeNode).join("");
|
|
3333
|
+
}
|
|
3334
|
+
// src/pci/mount.ts
|
|
3335
|
+
async function resolveModule(node, registry) {
|
|
3336
|
+
const preRegistered = registry.resolve(node.customInteractionTypeIdentifier) ?? (node.module !== undefined ? registry.resolve(node.module) : undefined);
|
|
3337
|
+
if (preRegistered) {
|
|
3338
|
+
return preRegistered;
|
|
3339
|
+
}
|
|
3340
|
+
const declared = node.interactionModules?.modules ?? [];
|
|
3341
|
+
for (const entry of declared) {
|
|
3342
|
+
const candidates = [entry.primaryPath, entry.fallbackPath].filter((candidate) => candidate !== undefined);
|
|
3343
|
+
await registry.load(entry.id, candidates);
|
|
3344
|
+
}
|
|
3345
|
+
if (declared.length === 0 && node.module !== undefined) {
|
|
3346
|
+
await registry.load(node.module, []);
|
|
3347
|
+
}
|
|
3348
|
+
const loaded = registry.resolve(node.customInteractionTypeIdentifier) ?? (node.module !== undefined ? registry.resolve(node.module) : undefined) ?? (declared.length === 1 ? registry.resolve(declared[0].id) : undefined);
|
|
3349
|
+
if (!loaded) {
|
|
3350
|
+
throw new Error(`No PCI module registered for "${node.customInteractionTypeIdentifier}".`);
|
|
3351
|
+
}
|
|
3352
|
+
return loaded;
|
|
3353
|
+
}
|
|
3354
|
+
async function mountPci(options) {
|
|
3355
|
+
const { container, node, registry } = options;
|
|
3356
|
+
const module = await resolveModule(node, registry);
|
|
3357
|
+
const markupHost = container.ownerDocument.createElement("div");
|
|
3358
|
+
markupHost.className = "qti-interaction-markup";
|
|
3359
|
+
markupHost.innerHTML = serializePciMarkup(node.interactionMarkup?.content);
|
|
3360
|
+
container.appendChild(markupHost);
|
|
3361
|
+
let resolveReady;
|
|
3362
|
+
const ready = new Promise((resolve) => {
|
|
3363
|
+
resolveReady = resolve;
|
|
3364
|
+
});
|
|
3365
|
+
const configuration = {
|
|
3366
|
+
properties: node.properties ?? {},
|
|
3367
|
+
responseIdentifier: node.responseIdentifier,
|
|
3368
|
+
...options.declaration ? { boundTo: { [node.responseIdentifier]: valueToPciResponse(options.boundValue ?? null, options.declaration) } } : {},
|
|
3369
|
+
status: "interacting",
|
|
3370
|
+
onready: (instance2) => resolveReady(instance2),
|
|
3371
|
+
ondone: (_instance, response, state) => options.ondone?.(pciResponseToValue(response), state)
|
|
3372
|
+
};
|
|
3373
|
+
const returned = module.getInstance(container, configuration, options.state);
|
|
3374
|
+
if (returned) {
|
|
3375
|
+
resolveReady(returned);
|
|
3376
|
+
}
|
|
3377
|
+
const instance = await ready;
|
|
3378
|
+
return {
|
|
3379
|
+
instance,
|
|
3380
|
+
collectResponse: () => pciResponseToValue(instance.getResponse()),
|
|
3381
|
+
getState: () => instance.getState?.(),
|
|
3382
|
+
unmount: () => {
|
|
3383
|
+
instance.oncompleted?.();
|
|
3384
|
+
container.replaceChildren();
|
|
3385
|
+
}
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
// src/pci/interaction.ts
|
|
3389
|
+
import { z } from "zod";
|
|
3390
|
+
var interactionModuleSchema = z.object({
|
|
3391
|
+
id: z.string().min(1),
|
|
3392
|
+
primaryPath: z.string().optional(),
|
|
3393
|
+
fallbackPath: z.string().optional()
|
|
3394
|
+
});
|
|
3395
|
+
var pciNodeSchema = z.object({
|
|
3396
|
+
kind: z.literal("portableCustomInteraction"),
|
|
3397
|
+
responseIdentifier: z.string().min(1),
|
|
3398
|
+
customInteractionTypeIdentifier: z.string().min(1),
|
|
3399
|
+
module: z.string().optional(),
|
|
3400
|
+
properties: z.record(z.string(), z.string()).optional(),
|
|
3401
|
+
interactionMarkup: z.object({ content: z.array(z.unknown()).optional() }).optional(),
|
|
3402
|
+
interactionModules: z.object({
|
|
3403
|
+
primaryConfiguration: z.string().optional(),
|
|
3404
|
+
secondaryConfiguration: z.string().optional(),
|
|
3405
|
+
modules: z.array(interactionModuleSchema).optional()
|
|
3406
|
+
}).optional()
|
|
3407
|
+
});
|
|
3408
|
+
var portableCustomInteraction = defineInteraction({
|
|
3409
|
+
kind: "portableCustomInteraction",
|
|
3410
|
+
schema: pciNodeSchema,
|
|
3411
|
+
scoring: "qti-standard",
|
|
3412
|
+
initialResponse() {
|
|
3413
|
+
return null;
|
|
3414
|
+
}
|
|
3415
|
+
});
|
|
3416
|
+
// src/pci/skin.ts
|
|
3417
|
+
import { createElement as createElement2, useEffect, useRef, useState } from "react";
|
|
3418
|
+
function createPciSkin(options) {
|
|
3419
|
+
return function PciHost(props) {
|
|
3420
|
+
const node = props.node;
|
|
3421
|
+
const containerRef = useRef(null);
|
|
3422
|
+
const handleRef = useRef(null);
|
|
3423
|
+
const propsRef = useRef(props);
|
|
3424
|
+
propsRef.current = props;
|
|
3425
|
+
const [mountError, setMountError] = useState(null);
|
|
3426
|
+
useEffect(() => {
|
|
3427
|
+
const container = containerRef.current;
|
|
3428
|
+
if (!container) {
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
let cancelled = false;
|
|
3432
|
+
let mounted = null;
|
|
3433
|
+
mountPci({
|
|
3434
|
+
container,
|
|
3435
|
+
node: propsRef.current.node,
|
|
3436
|
+
registry: options.registry,
|
|
3437
|
+
ondone: (value) => propsRef.current.setValue(value)
|
|
3438
|
+
}).then((handle) => {
|
|
3439
|
+
if (cancelled) {
|
|
3440
|
+
handle.unmount();
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
mounted = handle;
|
|
3444
|
+
handleRef.current = handle;
|
|
3445
|
+
}).catch((error) => {
|
|
3446
|
+
if (!cancelled) {
|
|
3447
|
+
setMountError(error instanceof Error ? error.message : String(error));
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3450
|
+
return () => {
|
|
3451
|
+
cancelled = true;
|
|
3452
|
+
mounted?.unmount();
|
|
3453
|
+
handleRef.current = null;
|
|
3454
|
+
};
|
|
3455
|
+
}, []);
|
|
3456
|
+
useEffect(() => propsRef.current.registerResponseCollector(() => handleRef.current?.collectResponse()), []);
|
|
3457
|
+
return createElement2("div", {
|
|
3458
|
+
"data-qti-interaction": "portableCustomInteraction",
|
|
3459
|
+
"data-qti-pci-type": node.customInteractionTypeIdentifier,
|
|
3460
|
+
className: node.class?.join(" ")
|
|
3461
|
+
}, createElement2("div", { ref: containerRef, "data-qti-pci-container": "" }), mountError !== null ? createElement2("p", { role: "note", "data-qti-pci-error": "" }, `Custom interaction failed to load: ${mountError}`) : null);
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
// src/interactions/associate.ts
|
|
3465
|
+
import { z as z2 } from "zod";
|
|
3466
|
+
var associateInteractionNodeSchema = z2.object({
|
|
3467
|
+
kind: z2.literal("associateInteraction"),
|
|
3468
|
+
responseIdentifier: z2.string().min(1),
|
|
3469
|
+
simpleAssociableChoices: z2.array(z2.looseObject({ identifier: z2.string().min(1), matchMax: z2.number().int().optional() })).min(2),
|
|
3470
|
+
maxAssociations: z2.number().int().optional()
|
|
3471
|
+
});
|
|
3472
|
+
var associateInteraction = defineInteraction({
|
|
3473
|
+
kind: "associateInteraction",
|
|
3474
|
+
schema: associateInteractionNodeSchema,
|
|
3475
|
+
scoring: "qti-standard",
|
|
3476
|
+
initialResponse() {
|
|
3477
|
+
return null;
|
|
3478
|
+
}
|
|
3479
|
+
});
|
|
3480
|
+
|
|
3481
|
+
// src/interactions/choice.ts
|
|
3482
|
+
import { z as z3 } from "zod";
|
|
3483
|
+
var choiceInteractionNodeSchema = z3.object({
|
|
3484
|
+
kind: z3.literal("choiceInteraction"),
|
|
3485
|
+
responseIdentifier: z3.string().min(1),
|
|
3486
|
+
simpleChoices: z3.array(z3.object({ identifier: z3.string().min(1) })).min(1),
|
|
3487
|
+
maxChoices: z3.number().int().optional()
|
|
3488
|
+
});
|
|
3489
|
+
var choiceInteraction = defineInteraction({
|
|
3490
|
+
kind: "choiceInteraction",
|
|
3491
|
+
schema: choiceInteractionNodeSchema,
|
|
3492
|
+
scoring: "qti-standard",
|
|
3493
|
+
initialResponse() {
|
|
3494
|
+
return null;
|
|
3495
|
+
}
|
|
3496
|
+
});
|
|
3497
|
+
|
|
3498
|
+
// src/interactions/drawing.ts
|
|
3499
|
+
import { z as z4 } from "zod";
|
|
3500
|
+
var drawingInteractionNodeSchema = z4.object({
|
|
3501
|
+
kind: z4.literal("drawingInteraction"),
|
|
3502
|
+
responseIdentifier: z4.string().min(1),
|
|
3503
|
+
object: z4.object({
|
|
3504
|
+
data: z4.string().min(1),
|
|
3505
|
+
width: z4.number().optional(),
|
|
3506
|
+
height: z4.number().optional(),
|
|
3507
|
+
type: z4.string().optional()
|
|
3508
|
+
})
|
|
3509
|
+
});
|
|
3510
|
+
var drawingInteraction = defineInteraction({
|
|
3511
|
+
kind: "drawingInteraction",
|
|
3512
|
+
schema: drawingInteractionNodeSchema,
|
|
3513
|
+
scoring: "qti-standard",
|
|
3514
|
+
initialResponse() {
|
|
3515
|
+
return null;
|
|
3516
|
+
}
|
|
3517
|
+
});
|
|
3518
|
+
|
|
3519
|
+
// src/interactions/end-attempt.ts
|
|
3520
|
+
import { z as z5 } from "zod";
|
|
3521
|
+
var endAttemptInteractionNodeSchema = z5.object({
|
|
3522
|
+
kind: z5.literal("endAttemptInteraction"),
|
|
3523
|
+
responseIdentifier: z5.string().min(1),
|
|
3524
|
+
title: z5.string().min(1)
|
|
3525
|
+
});
|
|
3526
|
+
var endAttemptInteraction = defineInteraction({
|
|
3527
|
+
kind: "endAttemptInteraction",
|
|
3528
|
+
schema: endAttemptInteractionNodeSchema,
|
|
3529
|
+
scoring: "qti-standard",
|
|
3530
|
+
initialResponse() {
|
|
3531
|
+
return null;
|
|
3532
|
+
}
|
|
3533
|
+
});
|
|
3534
|
+
|
|
3535
|
+
// src/interactions/extended-text.ts
|
|
3536
|
+
import { z as z6 } from "zod";
|
|
3537
|
+
var extendedTextInteractionNodeSchema = z6.object({
|
|
3538
|
+
kind: z6.literal("extendedTextInteraction"),
|
|
3539
|
+
responseIdentifier: z6.string().min(1),
|
|
3540
|
+
expectedLength: z6.number().int().optional(),
|
|
3541
|
+
expectedLines: z6.number().int().optional(),
|
|
3542
|
+
placeholderText: z6.string().optional()
|
|
3543
|
+
});
|
|
3544
|
+
var extendedTextInteraction = defineInteraction({
|
|
3545
|
+
kind: "extendedTextInteraction",
|
|
3546
|
+
schema: extendedTextInteractionNodeSchema,
|
|
3547
|
+
scoring: "qti-standard",
|
|
3548
|
+
initialResponse() {
|
|
3549
|
+
return null;
|
|
3550
|
+
}
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
// src/interactions/gap-match.ts
|
|
3554
|
+
import { z as z7 } from "zod";
|
|
3555
|
+
var gapMatchInteractionNodeSchema = z7.object({
|
|
3556
|
+
kind: z7.literal("gapMatchInteraction"),
|
|
3557
|
+
responseIdentifier: z7.string().min(1),
|
|
3558
|
+
gapTexts: z7.array(z7.looseObject({ identifier: z7.string().min(1), matchMax: z7.number().int().optional() })).min(1),
|
|
3559
|
+
content: z7.array(z7.looseObject({ kind: z7.string().min(1) })).min(1)
|
|
3560
|
+
});
|
|
3561
|
+
var gapMatchInteraction = defineInteraction({
|
|
3562
|
+
kind: "gapMatchInteraction",
|
|
3563
|
+
schema: gapMatchInteractionNodeSchema,
|
|
3564
|
+
scoring: "qti-standard",
|
|
3565
|
+
initialResponse() {
|
|
3566
|
+
return null;
|
|
3567
|
+
}
|
|
3568
|
+
});
|
|
3569
|
+
|
|
3570
|
+
// src/interactions/graphic.ts
|
|
3571
|
+
import { z as z8 } from "zod";
|
|
3572
|
+
var objectSchema = z8.object({
|
|
3573
|
+
data: z8.string().min(1),
|
|
3574
|
+
width: z8.number().optional(),
|
|
3575
|
+
height: z8.number().optional(),
|
|
3576
|
+
type: z8.string().optional()
|
|
3577
|
+
});
|
|
3578
|
+
var hotspotSchema = z8.looseObject({
|
|
3579
|
+
identifier: z8.string().min(1),
|
|
3580
|
+
shape: z8.string().min(1),
|
|
3581
|
+
coords: z8.array(z8.number())
|
|
3582
|
+
});
|
|
3583
|
+
function nullInitial() {
|
|
3584
|
+
return null;
|
|
3585
|
+
}
|
|
3586
|
+
var hotspotInteraction = defineInteraction({
|
|
3587
|
+
kind: "hotspotInteraction",
|
|
3588
|
+
schema: z8.object({
|
|
3589
|
+
kind: z8.literal("hotspotInteraction"),
|
|
3590
|
+
responseIdentifier: z8.string().min(1),
|
|
3591
|
+
object: objectSchema,
|
|
3592
|
+
hotspotChoices: z8.array(hotspotSchema).min(1),
|
|
3593
|
+
maxChoices: z8.number().int().optional()
|
|
3594
|
+
}),
|
|
3595
|
+
scoring: "qti-standard",
|
|
3596
|
+
initialResponse: nullInitial
|
|
3597
|
+
});
|
|
3598
|
+
var graphicOrderInteraction = defineInteraction({
|
|
3599
|
+
kind: "graphicOrderInteraction",
|
|
3600
|
+
schema: z8.object({
|
|
3601
|
+
kind: z8.literal("graphicOrderInteraction"),
|
|
3602
|
+
responseIdentifier: z8.string().min(1),
|
|
3603
|
+
object: objectSchema,
|
|
3604
|
+
hotspotChoices: z8.array(hotspotSchema).min(1)
|
|
3605
|
+
}),
|
|
3606
|
+
scoring: "qti-standard",
|
|
3607
|
+
initialResponse: nullInitial
|
|
3608
|
+
});
|
|
3609
|
+
var graphicAssociateInteraction = defineInteraction({
|
|
3610
|
+
kind: "graphicAssociateInteraction",
|
|
3611
|
+
schema: z8.object({
|
|
3612
|
+
kind: z8.literal("graphicAssociateInteraction"),
|
|
3613
|
+
responseIdentifier: z8.string().min(1),
|
|
3614
|
+
object: objectSchema,
|
|
3615
|
+
associableHotspots: z8.array(hotspotSchema).min(2),
|
|
3616
|
+
maxAssociations: z8.number().int().optional()
|
|
3617
|
+
}),
|
|
3618
|
+
scoring: "qti-standard",
|
|
3619
|
+
initialResponse: nullInitial
|
|
3620
|
+
});
|
|
3621
|
+
var graphicGapMatchInteraction = defineInteraction({
|
|
3622
|
+
kind: "graphicGapMatchInteraction",
|
|
3623
|
+
schema: z8.object({
|
|
3624
|
+
kind: z8.literal("graphicGapMatchInteraction"),
|
|
3625
|
+
responseIdentifier: z8.string().min(1),
|
|
3626
|
+
object: objectSchema,
|
|
3627
|
+
gapImgs: z8.array(z8.looseObject({ identifier: z8.string().min(1), object: objectSchema.optional() })).min(1),
|
|
3628
|
+
associableHotspots: z8.array(hotspotSchema).min(1)
|
|
3629
|
+
}),
|
|
3630
|
+
scoring: "qti-standard",
|
|
3631
|
+
initialResponse: nullInitial
|
|
3632
|
+
});
|
|
3633
|
+
var selectPointInteraction = defineInteraction({
|
|
3634
|
+
kind: "selectPointInteraction",
|
|
3635
|
+
schema: z8.object({
|
|
3636
|
+
kind: z8.literal("selectPointInteraction"),
|
|
3637
|
+
responseIdentifier: z8.string().min(1),
|
|
3638
|
+
object: objectSchema,
|
|
3639
|
+
maxChoices: z8.number().int().optional()
|
|
3640
|
+
}),
|
|
3641
|
+
scoring: "qti-standard",
|
|
3642
|
+
initialResponse: nullInitial
|
|
3643
|
+
});
|
|
3644
|
+
var positionObjectStage = defineInteraction({
|
|
3645
|
+
kind: "positionObjectStage",
|
|
3646
|
+
schema: z8.object({
|
|
3647
|
+
kind: z8.literal("positionObjectStage"),
|
|
3648
|
+
responseIdentifier: z8.string().min(1),
|
|
3649
|
+
stageObject: objectSchema,
|
|
3650
|
+
object: objectSchema,
|
|
3651
|
+
maxChoices: z8.number().int().optional()
|
|
3652
|
+
}),
|
|
3653
|
+
scoring: "qti-standard",
|
|
3654
|
+
initialResponse: nullInitial
|
|
3655
|
+
});
|
|
3656
|
+
|
|
3657
|
+
// src/interactions/hottext.ts
|
|
3658
|
+
import { z as z9 } from "zod";
|
|
3659
|
+
var hottextInteractionNodeSchema = z9.object({
|
|
3660
|
+
kind: z9.literal("hottextInteraction"),
|
|
3661
|
+
responseIdentifier: z9.string().min(1),
|
|
3662
|
+
maxChoices: z9.number().int().optional(),
|
|
3663
|
+
content: z9.array(z9.looseObject({ kind: z9.string().min(1) })).min(1)
|
|
3664
|
+
});
|
|
3665
|
+
var hottextInteraction = defineInteraction({
|
|
3666
|
+
kind: "hottextInteraction",
|
|
3667
|
+
schema: hottextInteractionNodeSchema,
|
|
3668
|
+
scoring: "qti-standard",
|
|
3669
|
+
initialResponse() {
|
|
3670
|
+
return null;
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
|
|
3674
|
+
// src/interactions/inline-choice.ts
|
|
3675
|
+
import { z as z10 } from "zod";
|
|
3676
|
+
var inlineChoiceInteractionNodeSchema = z10.object({
|
|
3677
|
+
kind: z10.literal("inlineChoiceInteraction"),
|
|
3678
|
+
responseIdentifier: z10.string().min(1),
|
|
3679
|
+
inlineChoices: z10.array(z10.object({ identifier: z10.string().min(1) })).min(1)
|
|
3680
|
+
});
|
|
3681
|
+
var inlineChoiceInteraction = defineInteraction({
|
|
3682
|
+
kind: "inlineChoiceInteraction",
|
|
3683
|
+
schema: inlineChoiceInteractionNodeSchema,
|
|
3684
|
+
scoring: "qti-standard",
|
|
3685
|
+
initialResponse() {
|
|
3686
|
+
return null;
|
|
3687
|
+
}
|
|
3688
|
+
});
|
|
3689
|
+
|
|
3690
|
+
// src/interactions/match.ts
|
|
3691
|
+
import { z as z11 } from "zod";
|
|
3692
|
+
var simpleMatchSetSchema = z11.object({
|
|
3693
|
+
simpleAssociableChoices: z11.array(z11.looseObject({ identifier: z11.string().min(1), matchMax: z11.number().int().optional() })).min(1)
|
|
3694
|
+
});
|
|
3695
|
+
var matchInteractionNodeSchema = z11.object({
|
|
3696
|
+
kind: z11.literal("matchInteraction"),
|
|
3697
|
+
responseIdentifier: z11.string().min(1),
|
|
3698
|
+
simpleMatchSets: z11.array(simpleMatchSetSchema).length(2),
|
|
3699
|
+
maxAssociations: z11.number().int().optional()
|
|
3700
|
+
});
|
|
3701
|
+
var matchInteraction = defineInteraction({
|
|
3702
|
+
kind: "matchInteraction",
|
|
3703
|
+
schema: matchInteractionNodeSchema,
|
|
3704
|
+
scoring: "qti-standard",
|
|
3705
|
+
initialResponse() {
|
|
3706
|
+
return null;
|
|
3707
|
+
}
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
// src/interactions/media.ts
|
|
3711
|
+
import { z as z12 } from "zod";
|
|
3712
|
+
var mediaInteractionNodeSchema = z12.object({
|
|
3713
|
+
kind: z12.literal("mediaInteraction"),
|
|
3714
|
+
responseIdentifier: z12.string().min(1),
|
|
3715
|
+
autostart: z12.boolean().optional(),
|
|
3716
|
+
minPlays: z12.number().int().optional(),
|
|
3717
|
+
maxPlays: z12.number().int().optional(),
|
|
3718
|
+
loop: z12.boolean().optional(),
|
|
3719
|
+
content: z12.array(z12.looseObject({ kind: z12.string().min(1) })).min(1)
|
|
3720
|
+
});
|
|
3721
|
+
var mediaInteraction = defineInteraction({
|
|
3722
|
+
kind: "mediaInteraction",
|
|
3723
|
+
schema: mediaInteractionNodeSchema,
|
|
3724
|
+
scoring: "qti-standard",
|
|
3725
|
+
initialResponse() {
|
|
3726
|
+
return null;
|
|
3727
|
+
}
|
|
3728
|
+
});
|
|
3729
|
+
|
|
3730
|
+
// src/interactions/order.ts
|
|
3731
|
+
import { z as z13 } from "zod";
|
|
3732
|
+
var orderInteractionNodeSchema = z13.object({
|
|
3733
|
+
kind: z13.literal("orderInteraction"),
|
|
3734
|
+
responseIdentifier: z13.string().min(1),
|
|
3735
|
+
simpleChoices: z13.array(z13.looseObject({ identifier: z13.string().min(1) })).min(1),
|
|
3736
|
+
shuffle: z13.boolean().optional(),
|
|
3737
|
+
orientation: z13.enum(["horizontal", "vertical"]).optional()
|
|
3738
|
+
});
|
|
3739
|
+
var orderInteraction = defineInteraction({
|
|
3740
|
+
kind: "orderInteraction",
|
|
3741
|
+
schema: orderInteractionNodeSchema,
|
|
3742
|
+
scoring: "qti-standard",
|
|
3743
|
+
initialResponse() {
|
|
3744
|
+
return null;
|
|
3745
|
+
}
|
|
3746
|
+
});
|
|
3747
|
+
|
|
3748
|
+
// src/interactions/slider.ts
|
|
3749
|
+
import { z as z14 } from "zod";
|
|
3750
|
+
var sliderInteractionNodeSchema = z14.object({
|
|
3751
|
+
kind: z14.literal("sliderInteraction"),
|
|
3752
|
+
responseIdentifier: z14.string().min(1),
|
|
3753
|
+
lowerBound: z14.number(),
|
|
3754
|
+
upperBound: z14.number(),
|
|
3755
|
+
step: z14.number().optional(),
|
|
3756
|
+
stepLabel: z14.boolean().optional(),
|
|
3757
|
+
orientation: z14.enum(["horizontal", "vertical"]).optional(),
|
|
3758
|
+
reverse: z14.boolean().optional()
|
|
3759
|
+
});
|
|
3760
|
+
var sliderInteraction = defineInteraction({
|
|
3761
|
+
kind: "sliderInteraction",
|
|
3762
|
+
schema: sliderInteractionNodeSchema,
|
|
352
3763
|
scoring: "qti-standard",
|
|
353
3764
|
initialResponse() {
|
|
354
3765
|
return null;
|
|
@@ -356,13 +3767,13 @@ var inlineChoiceInteraction = defineInteraction({
|
|
|
356
3767
|
});
|
|
357
3768
|
|
|
358
3769
|
// src/interactions/text-entry.ts
|
|
359
|
-
import { z as
|
|
360
|
-
var textEntryInteractionNodeSchema =
|
|
361
|
-
kind:
|
|
362
|
-
responseIdentifier:
|
|
363
|
-
expectedLength:
|
|
364
|
-
placeholderText:
|
|
365
|
-
patternMask:
|
|
3770
|
+
import { z as z15 } from "zod";
|
|
3771
|
+
var textEntryInteractionNodeSchema = z15.object({
|
|
3772
|
+
kind: z15.literal("textEntryInteraction"),
|
|
3773
|
+
responseIdentifier: z15.string().min(1),
|
|
3774
|
+
expectedLength: z15.number().int().optional(),
|
|
3775
|
+
placeholderText: z15.string().optional(),
|
|
3776
|
+
patternMask: z15.string().optional()
|
|
366
3777
|
});
|
|
367
3778
|
var textEntryInteraction = defineInteraction({
|
|
368
3779
|
kind: "textEntryInteraction",
|
|
@@ -373,31 +3784,964 @@ var textEntryInteraction = defineInteraction({
|
|
|
373
3784
|
}
|
|
374
3785
|
});
|
|
375
3786
|
|
|
3787
|
+
// src/interactions/upload.ts
|
|
3788
|
+
import { z as z16 } from "zod";
|
|
3789
|
+
var uploadInteractionNodeSchema = z16.object({
|
|
3790
|
+
kind: z16.literal("uploadInteraction"),
|
|
3791
|
+
responseIdentifier: z16.string().min(1),
|
|
3792
|
+
type: z16.string().optional()
|
|
3793
|
+
});
|
|
3794
|
+
var uploadInteraction = defineInteraction({
|
|
3795
|
+
kind: "uploadInteraction",
|
|
3796
|
+
schema: uploadInteractionNodeSchema,
|
|
3797
|
+
scoring: "qti-standard",
|
|
3798
|
+
initialResponse() {
|
|
3799
|
+
return null;
|
|
3800
|
+
}
|
|
3801
|
+
});
|
|
3802
|
+
|
|
376
3803
|
// src/interactions/index.ts
|
|
377
3804
|
var qtiCoreInteractions = [
|
|
3805
|
+
associateInteraction,
|
|
378
3806
|
choiceInteraction,
|
|
3807
|
+
drawingInteraction,
|
|
3808
|
+
endAttemptInteraction,
|
|
3809
|
+
extendedTextInteraction,
|
|
3810
|
+
gapMatchInteraction,
|
|
3811
|
+
graphicAssociateInteraction,
|
|
3812
|
+
graphicGapMatchInteraction,
|
|
3813
|
+
graphicOrderInteraction,
|
|
3814
|
+
hotspotInteraction,
|
|
3815
|
+
hottextInteraction,
|
|
3816
|
+
inlineChoiceInteraction,
|
|
3817
|
+
matchInteraction,
|
|
3818
|
+
mediaInteraction,
|
|
3819
|
+
orderInteraction,
|
|
3820
|
+
positionObjectStage,
|
|
3821
|
+
selectPointInteraction,
|
|
3822
|
+
sliderInteraction,
|
|
379
3823
|
textEntryInteraction,
|
|
380
|
-
|
|
3824
|
+
uploadInteraction
|
|
381
3825
|
];
|
|
3826
|
+
// src/reference-skin/associate.ts
|
|
3827
|
+
import { createElement as createElement3, useState as useState2 } from "react";
|
|
3828
|
+
|
|
3829
|
+
// src/reference-skin/content.ts
|
|
3830
|
+
function textOf(nodes) {
|
|
3831
|
+
if (!nodes) {
|
|
3832
|
+
return "";
|
|
3833
|
+
}
|
|
3834
|
+
let text = "";
|
|
3835
|
+
for (const node of nodes) {
|
|
3836
|
+
const value = node.value;
|
|
3837
|
+
if (typeof value === "string") {
|
|
3838
|
+
text += value;
|
|
3839
|
+
}
|
|
3840
|
+
const children = node.children;
|
|
3841
|
+
if (children) {
|
|
3842
|
+
text += textOf(children);
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
return text;
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
// src/reference-skin/associate.ts
|
|
3849
|
+
function AssociateReferenceSkin(props) {
|
|
3850
|
+
const node = props.node;
|
|
3851
|
+
const choices = node.simpleAssociableChoices ?? [];
|
|
3852
|
+
const pairs = Array.isArray(props.value) ? props.value : [];
|
|
3853
|
+
const [first, setFirst] = useState2("");
|
|
3854
|
+
const [second, setSecond] = useState2("");
|
|
3855
|
+
function labelFor(identifier) {
|
|
3856
|
+
const choice = choices.find((candidate) => candidate.identifier === identifier);
|
|
3857
|
+
return choice ? textOf(choice.content) || choice.identifier : identifier;
|
|
3858
|
+
}
|
|
3859
|
+
function addPair() {
|
|
3860
|
+
if (first === "" || second === "" || first === second) {
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
const pair = `${first} ${second}`;
|
|
3864
|
+
if (!pairs.includes(pair) && !pairs.includes(`${second} ${first}`)) {
|
|
3865
|
+
props.setValue([...pairs, pair]);
|
|
3866
|
+
}
|
|
3867
|
+
setFirst("");
|
|
3868
|
+
setSecond("");
|
|
3869
|
+
}
|
|
3870
|
+
function picker(value, setValue, label) {
|
|
3871
|
+
return createElement3("select", {
|
|
3872
|
+
value,
|
|
3873
|
+
disabled: props.disabled,
|
|
3874
|
+
"aria-label": label,
|
|
3875
|
+
onChange: (event) => setValue(event.target.value)
|
|
3876
|
+
}, createElement3("option", { key: "", value: "" }, ""), choices.map((choice) => createElement3("option", { key: choice.identifier, value: choice.identifier }, labelFor(choice.identifier))));
|
|
3877
|
+
}
|
|
3878
|
+
return createElement3("div", { "data-qti-interaction": "associateInteraction", "data-status": props.status }, node.prompt ? createElement3("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, picker(first, setFirst, "First member"), picker(second, setSecond, "Second member"), createElement3("button", { type: "button", disabled: props.disabled, onClick: addPair }, "Add pair"), createElement3("ul", null, pairs.map((pair) => {
|
|
3879
|
+
const [a, b] = pair.split(/\s+/u);
|
|
3880
|
+
return createElement3("li", { key: pair, "data-qti-pair": pair }, `${labelFor(a ?? "")} ↔ ${labelFor(b ?? "")} `, createElement3("button", {
|
|
3881
|
+
type: "button",
|
|
3882
|
+
disabled: props.disabled,
|
|
3883
|
+
"aria-label": `Remove pair ${pair}`,
|
|
3884
|
+
onClick: () => props.setValue(pairs.filter((entry) => entry !== pair))
|
|
3885
|
+
}, "×"));
|
|
3886
|
+
})));
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
// src/reference-skin/choice.ts
|
|
3890
|
+
import { createElement as createElement4 } from "react";
|
|
3891
|
+
function ChoiceReferenceSkin(props) {
|
|
3892
|
+
const node = props.node;
|
|
3893
|
+
const choices = node.simpleChoices ?? [];
|
|
3894
|
+
const isRadio = choices.length > 0 && props.getOptionProps(choices[0].identifier).role === "radio";
|
|
3895
|
+
return createElement4("div", {
|
|
3896
|
+
role: isRadio ? "radiogroup" : "group",
|
|
3897
|
+
"data-qti-interaction": "choiceInteraction",
|
|
3898
|
+
"data-status": props.status
|
|
3899
|
+
}, node.prompt ? createElement4("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, choices.map((choice) => {
|
|
3900
|
+
const optionProps = props.getOptionProps(choice.identifier);
|
|
3901
|
+
return createElement4("button", { key: choice.identifier, type: "button", disabled: props.disabled, ...optionProps }, props.renderContent(choice.content) ?? choice.identifier);
|
|
3902
|
+
}));
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
// src/reference-skin/drawing.ts
|
|
3906
|
+
import {
|
|
3907
|
+
createElement as createElement5,
|
|
3908
|
+
useCallback,
|
|
3909
|
+
useEffect as useEffect2,
|
|
3910
|
+
useRef as useRef2
|
|
3911
|
+
} from "react";
|
|
3912
|
+
var strokeStyle = "#c2410c";
|
|
3913
|
+
var strokeWidth = 3;
|
|
3914
|
+
function DrawingReferenceSkin(props) {
|
|
3915
|
+
const node = props.node;
|
|
3916
|
+
const width = node.object.width ?? 400;
|
|
3917
|
+
const height = node.object.height ?? 300;
|
|
3918
|
+
const canvasRef = useRef2(null);
|
|
3919
|
+
const drawingRef = useRef2(false);
|
|
3920
|
+
const propsRef = useRef2(props);
|
|
3921
|
+
propsRef.current = props;
|
|
3922
|
+
const stageData = node.object.data;
|
|
3923
|
+
const paintBackground = useCallback((canvas) => {
|
|
3924
|
+
const context = canvas.getContext("2d");
|
|
3925
|
+
if (!context) {
|
|
3926
|
+
return;
|
|
3927
|
+
}
|
|
3928
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
3929
|
+
const image = new Image;
|
|
3930
|
+
image.onload = () => {
|
|
3931
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
3932
|
+
};
|
|
3933
|
+
image.src = propsRef.current.resolveAsset(stageData);
|
|
3934
|
+
}, [stageData]);
|
|
3935
|
+
useEffect2(() => {
|
|
3936
|
+
if (canvasRef.current) {
|
|
3937
|
+
paintBackground(canvasRef.current);
|
|
3938
|
+
}
|
|
3939
|
+
}, [paintBackground]);
|
|
3940
|
+
const pointerPosition = (event) => {
|
|
3941
|
+
const canvas = event.currentTarget;
|
|
3942
|
+
const rect = canvas.getBoundingClientRect();
|
|
3943
|
+
return {
|
|
3944
|
+
x: (event.clientX - rect.left) / rect.width * canvas.width,
|
|
3945
|
+
y: (event.clientY - rect.top) / rect.height * canvas.height
|
|
3946
|
+
};
|
|
3947
|
+
};
|
|
3948
|
+
const handlePointerDown = (event) => {
|
|
3949
|
+
if (props.disabled) {
|
|
3950
|
+
return;
|
|
3951
|
+
}
|
|
3952
|
+
const context = event.currentTarget.getContext("2d");
|
|
3953
|
+
if (!context) {
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
3956
|
+
drawingRef.current = true;
|
|
3957
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
3958
|
+
const { x, y } = pointerPosition(event);
|
|
3959
|
+
context.strokeStyle = strokeStyle;
|
|
3960
|
+
context.lineWidth = strokeWidth;
|
|
3961
|
+
context.lineCap = "round";
|
|
3962
|
+
context.beginPath();
|
|
3963
|
+
context.moveTo(x, y);
|
|
3964
|
+
};
|
|
3965
|
+
const handlePointerMove = (event) => {
|
|
3966
|
+
if (!drawingRef.current) {
|
|
3967
|
+
return;
|
|
3968
|
+
}
|
|
3969
|
+
const context = event.currentTarget.getContext("2d");
|
|
3970
|
+
if (!context) {
|
|
3971
|
+
return;
|
|
3972
|
+
}
|
|
3973
|
+
const { x, y } = pointerPosition(event);
|
|
3974
|
+
context.lineTo(x, y);
|
|
3975
|
+
context.stroke();
|
|
3976
|
+
};
|
|
3977
|
+
const handlePointerUp = (event) => {
|
|
3978
|
+
if (!drawingRef.current) {
|
|
3979
|
+
return;
|
|
3980
|
+
}
|
|
3981
|
+
drawingRef.current = false;
|
|
3982
|
+
props.setValue(event.currentTarget.toDataURL("image/png"));
|
|
3983
|
+
};
|
|
3984
|
+
const handleClear = () => {
|
|
3985
|
+
if (props.disabled) {
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
props.setValue(null);
|
|
3989
|
+
if (canvasRef.current) {
|
|
3990
|
+
paintBackground(canvasRef.current);
|
|
3991
|
+
}
|
|
3992
|
+
};
|
|
3993
|
+
return createElement5("div", { "data-qti-interaction": "drawingInteraction", "data-status": props.status }, node.prompt ? createElement5("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement5("canvas", {
|
|
3994
|
+
ref: canvasRef,
|
|
3995
|
+
width,
|
|
3996
|
+
height,
|
|
3997
|
+
role: "img",
|
|
3998
|
+
"aria-label": "Drawing surface",
|
|
3999
|
+
"data-qti-drawing-stage": "",
|
|
4000
|
+
style: { touchAction: "none", border: "1px solid #d1d5db", maxWidth: "100%" },
|
|
4001
|
+
onPointerDown: handlePointerDown,
|
|
4002
|
+
onPointerMove: handlePointerMove,
|
|
4003
|
+
onPointerUp: handlePointerUp
|
|
4004
|
+
}), createElement5("button", { type: "button", onClick: handleClear, disabled: props.disabled, "aria-disabled": props.disabled }, "Clear"));
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
// src/reference-skin/end-attempt.ts
|
|
4008
|
+
import { createElement as createElement6 } from "react";
|
|
4009
|
+
function EndAttemptReferenceSkin(props) {
|
|
4010
|
+
const node = props.node;
|
|
4011
|
+
return createElement6("button", {
|
|
4012
|
+
type: "button",
|
|
4013
|
+
disabled: props.disabled,
|
|
4014
|
+
"data-qti-interaction": "endAttemptInteraction",
|
|
4015
|
+
onClick: () => props.endAttempt()
|
|
4016
|
+
}, node.title ?? "End attempt");
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
// src/reference-skin/extended-text.ts
|
|
4020
|
+
import { createElement as createElement7 } from "react";
|
|
4021
|
+
function ExtendedTextReferenceSkin(props) {
|
|
4022
|
+
const node = props.node;
|
|
4023
|
+
const value = typeof props.value === "string" ? props.value : "";
|
|
4024
|
+
return createElement7("div", { "data-qti-interaction": "extendedTextInteraction", "data-status": props.status }, node.prompt ? createElement7("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement7("textarea", {
|
|
4025
|
+
value,
|
|
4026
|
+
rows: node.expectedLines ?? 4,
|
|
4027
|
+
placeholder: node.placeholderText,
|
|
4028
|
+
disabled: props.disabled,
|
|
4029
|
+
"aria-disabled": props.disabled,
|
|
4030
|
+
onChange: (event) => {
|
|
4031
|
+
props.setValue(event.target.value === "" ? null : event.target.value);
|
|
4032
|
+
}
|
|
4033
|
+
}));
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
// src/reference-skin/gap-match.ts
|
|
4037
|
+
import { createElement as createElement8 } from "react";
|
|
4038
|
+
function GapMatchReferenceSkin(props) {
|
|
4039
|
+
const node = props.node;
|
|
4040
|
+
const gapTexts = node.gapTexts ?? [];
|
|
4041
|
+
const pairs = Array.isArray(props.value) ? props.value : [];
|
|
4042
|
+
function fillGap(gapIdentifier, gapTextIdentifier) {
|
|
4043
|
+
const kept = pairs.filter((pair) => pair.split(/\s+/u)[1] !== gapIdentifier);
|
|
4044
|
+
props.setValue(gapTextIdentifier === "" ? kept : [...kept, `${gapTextIdentifier} ${gapIdentifier}`]);
|
|
4045
|
+
}
|
|
4046
|
+
return createElement8("div", { "data-qti-interaction": "gapMatchInteraction", "data-status": props.status }, node.prompt ? createElement8("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, props.renderContent(node.content, {
|
|
4047
|
+
gap: (child, key) => {
|
|
4048
|
+
const gapIdentifier = child.identifier ?? "";
|
|
4049
|
+
const filledBy = pairs.find((pair) => pair.split(/\s+/u)[1] === gapIdentifier)?.split(/\s+/u)[0] ?? "";
|
|
4050
|
+
return createElement8("select", {
|
|
4051
|
+
key,
|
|
4052
|
+
value: filledBy,
|
|
4053
|
+
disabled: props.disabled,
|
|
4054
|
+
"aria-label": `Gap ${gapIdentifier}`,
|
|
4055
|
+
"data-qti-gap": gapIdentifier,
|
|
4056
|
+
onChange: (event) => fillGap(gapIdentifier, event.target.value)
|
|
4057
|
+
}, createElement8("option", { key: "", value: "" }, ""), gapTexts.map((gapText) => createElement8("option", { key: gapText.identifier, value: gapText.identifier }, textOf(gapText.content) || gapText.identifier)));
|
|
4058
|
+
}
|
|
4059
|
+
}));
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
// src/reference-skin/graphic-associate.ts
|
|
4063
|
+
import { Fragment as Fragment2, createElement as createElement10, useState as useState3 } from "react";
|
|
4064
|
+
|
|
4065
|
+
// src/reference-skin/graphic-base.ts
|
|
4066
|
+
import { createElement as createElement9 } from "react";
|
|
4067
|
+
function shapeCenter(shape, coords) {
|
|
4068
|
+
switch (shape) {
|
|
4069
|
+
case "circle":
|
|
4070
|
+
case "ellipse":
|
|
4071
|
+
return { x: coords[0] ?? 0, y: coords[1] ?? 0 };
|
|
4072
|
+
case "rect":
|
|
4073
|
+
return { x: ((coords[0] ?? 0) + (coords[2] ?? 0)) / 2, y: ((coords[1] ?? 0) + (coords[3] ?? 0)) / 2 };
|
|
4074
|
+
case "poly": {
|
|
4075
|
+
let x = 0;
|
|
4076
|
+
let y = 0;
|
|
4077
|
+
const pointCount = Math.floor(coords.length / 2);
|
|
4078
|
+
for (let i = 0;i < pointCount * 2; i += 2) {
|
|
4079
|
+
x += coords[i] ?? 0;
|
|
4080
|
+
y += coords[i + 1] ?? 0;
|
|
4081
|
+
}
|
|
4082
|
+
return pointCount === 0 ? { x: 0, y: 0 } : { x: x / pointCount, y: y / pointCount };
|
|
4083
|
+
}
|
|
4084
|
+
default:
|
|
4085
|
+
return { x: 0, y: 0 };
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
function shapeElement(shape, coords, key, props) {
|
|
4089
|
+
const base = { key, fill: "transparent", stroke: "currentColor", strokeWidth: 1, ...props };
|
|
4090
|
+
switch (shape) {
|
|
4091
|
+
case "circle":
|
|
4092
|
+
return createElement9("circle", { ...base, cx: coords[0], cy: coords[1], r: coords[2] });
|
|
4093
|
+
case "rect":
|
|
4094
|
+
return createElement9("rect", {
|
|
4095
|
+
...base,
|
|
4096
|
+
x: coords[0],
|
|
4097
|
+
y: coords[1],
|
|
4098
|
+
width: (coords[2] ?? 0) - (coords[0] ?? 0),
|
|
4099
|
+
height: (coords[3] ?? 0) - (coords[1] ?? 0)
|
|
4100
|
+
});
|
|
4101
|
+
case "ellipse":
|
|
4102
|
+
return createElement9("ellipse", { ...base, cx: coords[0], cy: coords[1], rx: coords[2], ry: coords[3] });
|
|
4103
|
+
case "poly": {
|
|
4104
|
+
const points = [];
|
|
4105
|
+
for (let i = 0;i + 1 < coords.length; i += 2) {
|
|
4106
|
+
points.push(`${coords[i]},${coords[i + 1]}`);
|
|
4107
|
+
}
|
|
4108
|
+
return createElement9("polygon", { ...base, points: points.join(" ") });
|
|
4109
|
+
}
|
|
4110
|
+
default:
|
|
4111
|
+
return createElement9("rect", { ...base, x: 0, y: 0, width: "100%", height: "100%" });
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
function GraphicStage(props) {
|
|
4115
|
+
const width = props.object.width ?? 0;
|
|
4116
|
+
const height = props.object.height ?? 0;
|
|
4117
|
+
return createElement9("div", { "data-qti-interaction": props.interaction, "data-status": props.status }, props.prompt, createElement9("div", { style: { position: "relative", display: "inline-block", lineHeight: 0 } }, createElement9("img", {
|
|
4118
|
+
src: props.resolveAsset(props.object.data),
|
|
4119
|
+
width: width || undefined,
|
|
4120
|
+
height: height || undefined,
|
|
4121
|
+
alt: ""
|
|
4122
|
+
}), createElement9("svg", {
|
|
4123
|
+
viewBox: `0 0 ${width || 100} ${height || 100}`,
|
|
4124
|
+
width: width || undefined,
|
|
4125
|
+
height: height || undefined,
|
|
4126
|
+
style: { position: "absolute", inset: 0 },
|
|
4127
|
+
onClick: props.onStageClick ? (event) => {
|
|
4128
|
+
props.onStageClick?.({
|
|
4129
|
+
x: Math.round(event.nativeEvent.offsetX),
|
|
4130
|
+
y: Math.round(event.nativeEvent.offsetY)
|
|
4131
|
+
});
|
|
4132
|
+
} : undefined
|
|
4133
|
+
}, props.overlay)), props.after);
|
|
4134
|
+
}
|
|
4135
|
+
|
|
4136
|
+
// src/reference-skin/graphic-associate.ts
|
|
4137
|
+
function GraphicAssociateReferenceSkin(props) {
|
|
4138
|
+
const node = props.node;
|
|
4139
|
+
const pairs = Array.isArray(props.value) ? [...props.value] : [];
|
|
4140
|
+
const [pending, setPending] = useState3(null);
|
|
4141
|
+
const hotspots = node.associableHotspots ?? [];
|
|
4142
|
+
const centersById = new Map(hotspots.map((hotspot) => [hotspot.identifier, shapeCenter(hotspot.shape, hotspot.coords)]));
|
|
4143
|
+
if (!node.object) {
|
|
4144
|
+
return null;
|
|
4145
|
+
}
|
|
4146
|
+
function clickHotspot(identifier) {
|
|
4147
|
+
if (props.disabled) {
|
|
4148
|
+
return;
|
|
4149
|
+
}
|
|
4150
|
+
if (pending === null) {
|
|
4151
|
+
setPending(identifier);
|
|
4152
|
+
return;
|
|
4153
|
+
}
|
|
4154
|
+
if (pending === identifier) {
|
|
4155
|
+
setPending(null);
|
|
4156
|
+
return;
|
|
4157
|
+
}
|
|
4158
|
+
const pair = `${pending} ${identifier}`;
|
|
4159
|
+
const reversed = `${identifier} ${pending}`;
|
|
4160
|
+
if (!pairs.includes(pair) && !pairs.includes(reversed)) {
|
|
4161
|
+
props.setValue([...pairs, pair]);
|
|
4162
|
+
}
|
|
4163
|
+
setPending(null);
|
|
4164
|
+
}
|
|
4165
|
+
return createElement10(GraphicStage, {
|
|
4166
|
+
object: node.object,
|
|
4167
|
+
resolveAsset: props.resolveAsset,
|
|
4168
|
+
interaction: "graphicAssociateInteraction",
|
|
4169
|
+
status: props.status,
|
|
4170
|
+
prompt: node.prompt ? createElement10("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
|
|
4171
|
+
overlay: createElement10(Fragment2, null, pairs.map((pair) => {
|
|
4172
|
+
const [a, b] = pair.split(/\s+/u);
|
|
4173
|
+
const from = centersById.get(a ?? "");
|
|
4174
|
+
const to = centersById.get(b ?? "");
|
|
4175
|
+
if (!from || !to) {
|
|
4176
|
+
return null;
|
|
4177
|
+
}
|
|
4178
|
+
return createElement10("line", {
|
|
4179
|
+
key: pair,
|
|
4180
|
+
x1: from.x,
|
|
4181
|
+
y1: from.y,
|
|
4182
|
+
x2: to.x,
|
|
4183
|
+
y2: to.y,
|
|
4184
|
+
stroke: "currentColor",
|
|
4185
|
+
strokeWidth: 2,
|
|
4186
|
+
"data-qti-pair": pair,
|
|
4187
|
+
style: { pointerEvents: "none" }
|
|
4188
|
+
});
|
|
4189
|
+
}), hotspots.map((hotspot) => shapeElement(hotspot.shape, hotspot.coords, hotspot.identifier, {
|
|
4190
|
+
role: "button",
|
|
4191
|
+
tabIndex: 0,
|
|
4192
|
+
"aria-label": hotspot.identifier,
|
|
4193
|
+
"aria-pressed": pending === hotspot.identifier,
|
|
4194
|
+
"data-status": pending === hotspot.identifier ? "selected" : "idle",
|
|
4195
|
+
onClick: () => clickHotspot(hotspot.identifier),
|
|
4196
|
+
style: { cursor: props.disabled ? "default" : "pointer" }
|
|
4197
|
+
}))),
|
|
4198
|
+
after: createElement10("ul", null, pairs.map((pair) => createElement10("li", { key: pair }, pair.replace(/\s+/u, " ↔ "), createElement10("button", {
|
|
4199
|
+
type: "button",
|
|
4200
|
+
disabled: props.disabled,
|
|
4201
|
+
"aria-label": `Remove association ${pair}`,
|
|
4202
|
+
onClick: () => props.setValue(pairs.filter((entry) => entry !== pair))
|
|
4203
|
+
}, "×"))))
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
// src/reference-skin/graphic-gap-match.ts
|
|
4208
|
+
import { Fragment as Fragment3, createElement as createElement11, useState as useState4 } from "react";
|
|
4209
|
+
function GraphicGapMatchReferenceSkin(props) {
|
|
4210
|
+
const node = props.node;
|
|
4211
|
+
const pairs = Array.isArray(props.value) ? [...props.value] : [];
|
|
4212
|
+
const [selectedGapImg, setSelectedGapImg] = useState4(null);
|
|
4213
|
+
const gapImgs = node.gapImgs ?? [];
|
|
4214
|
+
const gapImgsById = new Map(gapImgs.map((gapImg) => [gapImg.identifier, gapImg]));
|
|
4215
|
+
if (!node.object) {
|
|
4216
|
+
return null;
|
|
4217
|
+
}
|
|
4218
|
+
function placedIn(hotspotIdentifier) {
|
|
4219
|
+
const pair = pairs.find((entry) => entry.split(/\s+/u)[1] === hotspotIdentifier);
|
|
4220
|
+
return pair?.split(/\s+/u)[0] ?? null;
|
|
4221
|
+
}
|
|
4222
|
+
function clickHotspot(hotspotIdentifier) {
|
|
4223
|
+
if (props.disabled) {
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
const kept = pairs.filter((entry) => entry.split(/\s+/u)[1] !== hotspotIdentifier);
|
|
4227
|
+
if (selectedGapImg === null) {
|
|
4228
|
+
if (kept.length !== pairs.length) {
|
|
4229
|
+
props.setValue(kept.length === 0 ? null : kept);
|
|
4230
|
+
}
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
props.setValue([...kept, `${selectedGapImg} ${hotspotIdentifier}`]);
|
|
4234
|
+
setSelectedGapImg(null);
|
|
4235
|
+
}
|
|
4236
|
+
return createElement11(GraphicStage, {
|
|
4237
|
+
object: node.object,
|
|
4238
|
+
resolveAsset: props.resolveAsset,
|
|
4239
|
+
interaction: "graphicGapMatchInteraction",
|
|
4240
|
+
status: props.status,
|
|
4241
|
+
prompt: createElement11(Fragment3, null, node.prompt ? createElement11("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement11("div", { role: "group", "aria-label": "Gap images" }, gapImgs.map((gapImg) => createElement11("button", {
|
|
4242
|
+
key: gapImg.identifier,
|
|
4243
|
+
type: "button",
|
|
4244
|
+
disabled: props.disabled,
|
|
4245
|
+
"aria-pressed": selectedGapImg === gapImg.identifier,
|
|
4246
|
+
"data-status": selectedGapImg === gapImg.identifier ? "selected" : "idle",
|
|
4247
|
+
"data-qti-gap-img": gapImg.identifier,
|
|
4248
|
+
onClick: () => setSelectedGapImg(selectedGapImg === gapImg.identifier ? null : gapImg.identifier)
|
|
4249
|
+
}, gapImg.object ? createElement11("img", {
|
|
4250
|
+
src: props.resolveAsset(gapImg.object.data),
|
|
4251
|
+
width: gapImg.object.width,
|
|
4252
|
+
height: gapImg.object.height,
|
|
4253
|
+
alt: gapImg.identifier
|
|
4254
|
+
}) : gapImg.label ?? gapImg.identifier)))),
|
|
4255
|
+
overlay: (node.associableHotspots ?? []).map((hotspot) => {
|
|
4256
|
+
const placed = placedIn(hotspot.identifier);
|
|
4257
|
+
const placedEntry = placed === null ? undefined : gapImgsById.get(placed);
|
|
4258
|
+
const placedObject = placedEntry?.object;
|
|
4259
|
+
const center = shapeCenter(hotspot.shape, hotspot.coords);
|
|
4260
|
+
return createElement11(Fragment3, { key: hotspot.identifier }, shapeElement(hotspot.shape, hotspot.coords, `${hotspot.identifier}-shape`, {
|
|
4261
|
+
role: "button",
|
|
4262
|
+
tabIndex: 0,
|
|
4263
|
+
"aria-label": `${hotspot.identifier}${placed === null ? "" : `, contains ${placed}`}`,
|
|
4264
|
+
"data-status": placed === null ? "idle" : "selected",
|
|
4265
|
+
onClick: () => clickHotspot(hotspot.identifier),
|
|
4266
|
+
style: { cursor: props.disabled ? "default" : "pointer" }
|
|
4267
|
+
}), placedObject ? createElement11("image", {
|
|
4268
|
+
href: props.resolveAsset(placedObject.data),
|
|
4269
|
+
x: center.x - (placedObject.width ?? 0) / 2,
|
|
4270
|
+
y: center.y - (placedObject.height ?? 0) / 2,
|
|
4271
|
+
width: placedObject.width,
|
|
4272
|
+
height: placedObject.height,
|
|
4273
|
+
style: { pointerEvents: "none" }
|
|
4274
|
+
}) : placedEntry ? createElement11("text", {
|
|
4275
|
+
x: center.x,
|
|
4276
|
+
y: center.y,
|
|
4277
|
+
textAnchor: "middle",
|
|
4278
|
+
dominantBaseline: "middle",
|
|
4279
|
+
style: { pointerEvents: "none" }
|
|
4280
|
+
}, placedEntry.label ?? placedEntry.identifier) : null);
|
|
4281
|
+
})
|
|
4282
|
+
});
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
// src/reference-skin/graphic-order.ts
|
|
4286
|
+
import { Fragment as Fragment4, createElement as createElement12 } from "react";
|
|
4287
|
+
function GraphicOrderReferenceSkin(props) {
|
|
4288
|
+
const node = props.node;
|
|
4289
|
+
const order = Array.isArray(props.value) ? [...props.value] : [];
|
|
4290
|
+
if (!node.object) {
|
|
4291
|
+
return null;
|
|
4292
|
+
}
|
|
4293
|
+
function toggle(identifier) {
|
|
4294
|
+
if (props.disabled) {
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
const next = order.includes(identifier) ? order.filter((entry) => entry !== identifier) : [...order, identifier];
|
|
4298
|
+
props.setValue(next.length === 0 ? null : next);
|
|
4299
|
+
}
|
|
4300
|
+
return createElement12(GraphicStage, {
|
|
4301
|
+
object: node.object,
|
|
4302
|
+
resolveAsset: props.resolveAsset,
|
|
4303
|
+
interaction: "graphicOrderInteraction",
|
|
4304
|
+
status: props.status,
|
|
4305
|
+
prompt: node.prompt ? createElement12("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
|
|
4306
|
+
overlay: (node.hotspotChoices ?? []).map((hotspot) => {
|
|
4307
|
+
const position = order.indexOf(hotspot.identifier);
|
|
4308
|
+
const center = shapeCenter(hotspot.shape, hotspot.coords);
|
|
4309
|
+
return createElement12(Fragment4, { key: hotspot.identifier }, shapeElement(hotspot.shape, hotspot.coords, `${hotspot.identifier}-shape`, {
|
|
4310
|
+
role: "button",
|
|
4311
|
+
tabIndex: 0,
|
|
4312
|
+
"aria-label": `${hotspot.identifier}${position === -1 ? "" : `, position ${position + 1}`}`,
|
|
4313
|
+
"data-status": position === -1 ? "idle" : "selected",
|
|
4314
|
+
onClick: () => toggle(hotspot.identifier),
|
|
4315
|
+
style: { cursor: props.disabled ? "default" : "pointer" }
|
|
4316
|
+
}), position === -1 ? null : createElement12("text", {
|
|
4317
|
+
x: center.x,
|
|
4318
|
+
y: center.y,
|
|
4319
|
+
textAnchor: "middle",
|
|
4320
|
+
dominantBaseline: "central",
|
|
4321
|
+
"data-qti-order-badge": hotspot.identifier,
|
|
4322
|
+
style: { pointerEvents: "none" }
|
|
4323
|
+
}, String(position + 1)));
|
|
4324
|
+
})
|
|
4325
|
+
});
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
// src/reference-skin/hotspot.ts
|
|
4329
|
+
import { createElement as createElement13 } from "react";
|
|
4330
|
+
function HotspotReferenceSkin(props) {
|
|
4331
|
+
const node = props.node;
|
|
4332
|
+
if (!node.object) {
|
|
4333
|
+
return null;
|
|
4334
|
+
}
|
|
4335
|
+
return createElement13(GraphicStage, {
|
|
4336
|
+
object: node.object,
|
|
4337
|
+
resolveAsset: props.resolveAsset,
|
|
4338
|
+
interaction: "hotspotInteraction",
|
|
4339
|
+
status: props.status,
|
|
4340
|
+
prompt: node.prompt ? createElement13("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
|
|
4341
|
+
overlay: (node.hotspotChoices ?? []).map((hotspot) => {
|
|
4342
|
+
const optionProps = props.getOptionProps(hotspot.identifier);
|
|
4343
|
+
return shapeElement(hotspot.shape, hotspot.coords, hotspot.identifier, {
|
|
4344
|
+
...optionProps,
|
|
4345
|
+
"aria-label": hotspot.identifier,
|
|
4346
|
+
style: { cursor: props.disabled ? "default" : "pointer" }
|
|
4347
|
+
});
|
|
4348
|
+
})
|
|
4349
|
+
});
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
// src/reference-skin/hottext.ts
|
|
4353
|
+
import { createElement as createElement14 } from "react";
|
|
4354
|
+
function HottextReferenceSkin(props) {
|
|
4355
|
+
const node = props.node;
|
|
4356
|
+
return createElement14("div", { "data-qti-interaction": "hottextInteraction", "data-status": props.status }, node.prompt ? createElement14("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, props.renderContent(node.content, {
|
|
4357
|
+
hottext: (child, key) => {
|
|
4358
|
+
const view = child;
|
|
4359
|
+
const identifier = view.identifier ?? "";
|
|
4360
|
+
const optionProps = props.getOptionProps(identifier);
|
|
4361
|
+
return createElement14("button", { key, type: "button", disabled: props.disabled, "data-qti-hottext": identifier, ...optionProps }, props.renderContent(view.content) ?? identifier);
|
|
4362
|
+
}
|
|
4363
|
+
}));
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
// src/reference-skin/inline-choice.ts
|
|
4367
|
+
import { createElement as createElement15 } from "react";
|
|
4368
|
+
function InlineChoiceReferenceSkin(props) {
|
|
4369
|
+
const node = props.node;
|
|
4370
|
+
const choices = node.inlineChoices ?? [];
|
|
4371
|
+
const value = typeof props.value === "string" ? props.value : "";
|
|
4372
|
+
return createElement15("select", {
|
|
4373
|
+
value,
|
|
4374
|
+
disabled: props.disabled,
|
|
4375
|
+
"aria-disabled": props.disabled,
|
|
4376
|
+
"data-qti-interaction": "inlineChoiceInteraction",
|
|
4377
|
+
"data-status": props.status,
|
|
4378
|
+
onChange: (event) => {
|
|
4379
|
+
props.setValue(event.target.value === "" ? null : event.target.value);
|
|
4380
|
+
}
|
|
4381
|
+
}, createElement15("option", { key: "", value: "" }, ""), choices.map((choice) => createElement15("option", { key: choice.identifier, value: choice.identifier }, textOf(choice.content))));
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
// src/reference-skin/match.ts
|
|
4385
|
+
import { createElement as createElement16 } from "react";
|
|
4386
|
+
function MatchReferenceSkin(props) {
|
|
4387
|
+
const node = props.node;
|
|
4388
|
+
const rows = node.simpleMatchSets?.[0]?.simpleAssociableChoices ?? [];
|
|
4389
|
+
const columns = node.simpleMatchSets?.[1]?.simpleAssociableChoices ?? [];
|
|
4390
|
+
const pairs = Array.isArray(props.value) ? props.value : [];
|
|
4391
|
+
function togglePair(pair) {
|
|
4392
|
+
props.setValue(pairs.includes(pair) ? pairs.filter((entry) => entry !== pair) : [...pairs, pair]);
|
|
4393
|
+
}
|
|
4394
|
+
return createElement16("div", { "data-qti-interaction": "matchInteraction", "data-status": props.status }, node.prompt ? createElement16("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement16("table", null, createElement16("thead", null, createElement16("tr", null, createElement16("td", null), columns.map((column) => createElement16("th", { key: column.identifier, scope: "col" }, textOf(column.content) || column.identifier)))), createElement16("tbody", null, rows.map((row) => createElement16("tr", { key: row.identifier }, createElement16("th", { scope: "row" }, textOf(row.content) || row.identifier), columns.map((column) => {
|
|
4395
|
+
const pair = `${row.identifier} ${column.identifier}`;
|
|
4396
|
+
return createElement16("td", { key: column.identifier }, createElement16("input", {
|
|
4397
|
+
type: "checkbox",
|
|
4398
|
+
checked: pairs.includes(pair),
|
|
4399
|
+
disabled: props.disabled,
|
|
4400
|
+
"aria-label": `${textOf(row.content) || row.identifier} — ${textOf(column.content) || column.identifier}`,
|
|
4401
|
+
"data-qti-pair": pair,
|
|
4402
|
+
onChange: () => togglePair(pair)
|
|
4403
|
+
}));
|
|
4404
|
+
}))))));
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
// src/reference-skin/media.ts
|
|
4408
|
+
import { createElement as createElement17 } from "react";
|
|
4409
|
+
function findMediaElement(nodes) {
|
|
4410
|
+
for (const node of nodes ?? []) {
|
|
4411
|
+
if (node.kind === "xml") {
|
|
4412
|
+
const xmlNode = node;
|
|
4413
|
+
if (xmlNode.name === "audio" || xmlNode.name === "video") {
|
|
4414
|
+
return xmlNode;
|
|
4415
|
+
}
|
|
4416
|
+
const nested = findMediaElement(xmlNode.children);
|
|
4417
|
+
if (nested) {
|
|
4418
|
+
return nested;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
return null;
|
|
4423
|
+
}
|
|
4424
|
+
function MediaReferenceSkin(props) {
|
|
4425
|
+
const node = props.node;
|
|
4426
|
+
const media = findMediaElement(node.content);
|
|
4427
|
+
const plays = typeof props.value === "string" ? Number(props.value) || 0 : 0;
|
|
4428
|
+
const playsExhausted = node.maxPlays !== undefined && node.maxPlays > 0 && plays >= node.maxPlays;
|
|
4429
|
+
if (!media) {
|
|
4430
|
+
return createElement17("div", { "data-qti-interaction": "mediaInteraction", "data-status": props.status }, "No media element.");
|
|
4431
|
+
}
|
|
4432
|
+
const src = typeof media.attributes?.["src"] === "string" ? props.resolveAsset(media.attributes["src"]) : undefined;
|
|
4433
|
+
return createElement17("div", { "data-qti-interaction": "mediaInteraction", "data-status": props.status, "data-qti-plays": plays }, node.prompt ? createElement17("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement17(media.name, {
|
|
4434
|
+
src,
|
|
4435
|
+
controls: true,
|
|
4436
|
+
loop: node.loop ?? false,
|
|
4437
|
+
onPlay: (event) => {
|
|
4438
|
+
if (props.disabled || playsExhausted) {
|
|
4439
|
+
event.currentTarget.pause();
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
4442
|
+
props.setValue(String(plays + 1));
|
|
4443
|
+
}
|
|
4444
|
+
}), playsExhausted ? createElement17("p", { role: "status" }, "No plays remaining.") : null);
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
// src/reference-skin/order.ts
|
|
4448
|
+
import { createElement as createElement18 } from "react";
|
|
4449
|
+
function OrderReferenceSkin(props) {
|
|
4450
|
+
const node = props.node;
|
|
4451
|
+
const choices = node.simpleChoices ?? [];
|
|
4452
|
+
const declared = choices.map((choice) => choice.identifier);
|
|
4453
|
+
const order = Array.isArray(props.value) ? [...props.value] : declared;
|
|
4454
|
+
const choicesById = new Map(choices.map((choice) => [choice.identifier, choice]));
|
|
4455
|
+
function move(index, delta) {
|
|
4456
|
+
const target = index + delta;
|
|
4457
|
+
if (target < 0 || target >= order.length) {
|
|
4458
|
+
return;
|
|
4459
|
+
}
|
|
4460
|
+
const next = [...order];
|
|
4461
|
+
const moved = next[index];
|
|
4462
|
+
next[index] = next[target];
|
|
4463
|
+
next[target] = moved;
|
|
4464
|
+
props.setValue(next);
|
|
4465
|
+
}
|
|
4466
|
+
return createElement18("div", { "data-qti-interaction": "orderInteraction", "data-status": props.status }, node.prompt ? createElement18("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement18("ol", null, order.map((identifier, index) => createElement18("li", { key: identifier }, props.renderContent(choicesById.get(identifier)?.content) ?? identifier, createElement18("button", {
|
|
4467
|
+
type: "button",
|
|
4468
|
+
"aria-label": `Move ${identifier} up`,
|
|
4469
|
+
disabled: props.disabled || index === 0,
|
|
4470
|
+
onClick: () => move(index, -1)
|
|
4471
|
+
}, "↑"), createElement18("button", {
|
|
4472
|
+
type: "button",
|
|
4473
|
+
"aria-label": `Move ${identifier} down`,
|
|
4474
|
+
disabled: props.disabled || index === order.length - 1,
|
|
4475
|
+
onClick: () => move(index, 1)
|
|
4476
|
+
}, "↓")))));
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
// src/reference-skin/position-object.ts
|
|
4480
|
+
import { createElement as createElement19 } from "react";
|
|
4481
|
+
function PositionObjectReferenceSkin(props) {
|
|
4482
|
+
const node = props.node;
|
|
4483
|
+
const maxChoices = node.maxChoices ?? 1;
|
|
4484
|
+
const points = props.value === null ? [] : typeof props.value === "string" ? [props.value] : Array.isArray(props.value) ? [...props.value] : [];
|
|
4485
|
+
if (!node.stageObject || !node.object) {
|
|
4486
|
+
return null;
|
|
4487
|
+
}
|
|
4488
|
+
const movable = node.object;
|
|
4489
|
+
function stageClick(point) {
|
|
4490
|
+
if (props.disabled) {
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
const formatted = formatPoint(point);
|
|
4494
|
+
if (maxChoices === 1) {
|
|
4495
|
+
props.setValue(formatted);
|
|
4496
|
+
return;
|
|
4497
|
+
}
|
|
4498
|
+
if (points.length < maxChoices) {
|
|
4499
|
+
props.setValue([...points, formatted]);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
return createElement19(GraphicStage, {
|
|
4503
|
+
object: node.stageObject,
|
|
4504
|
+
resolveAsset: props.resolveAsset,
|
|
4505
|
+
interaction: "positionObjectStage",
|
|
4506
|
+
status: props.status,
|
|
4507
|
+
onStageClick: stageClick,
|
|
4508
|
+
prompt: node.prompt ? createElement19("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
|
|
4509
|
+
overlay: points.map((value, index) => {
|
|
4510
|
+
const point = parsePoint(value);
|
|
4511
|
+
if (!point) {
|
|
4512
|
+
return null;
|
|
4513
|
+
}
|
|
4514
|
+
return createElement19("image", {
|
|
4515
|
+
key: `${value}-${index}`,
|
|
4516
|
+
href: props.resolveAsset(movable.data),
|
|
4517
|
+
x: point.x - (movable.width ?? 0) / 2,
|
|
4518
|
+
y: point.y - (movable.height ?? 0) / 2,
|
|
4519
|
+
width: movable.width,
|
|
4520
|
+
height: movable.height,
|
|
4521
|
+
"data-qti-point": value,
|
|
4522
|
+
style: { pointerEvents: "none" }
|
|
4523
|
+
});
|
|
4524
|
+
})
|
|
4525
|
+
});
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
// src/reference-skin/select-point.ts
|
|
4529
|
+
import { Fragment as Fragment5, createElement as createElement20 } from "react";
|
|
4530
|
+
function SelectPointReferenceSkin(props) {
|
|
4531
|
+
const node = props.node;
|
|
4532
|
+
const maxChoices = node.maxChoices ?? 1;
|
|
4533
|
+
const points = props.value === null ? [] : typeof props.value === "string" ? [props.value] : Array.isArray(props.value) ? [...props.value] : [];
|
|
4534
|
+
if (!node.object) {
|
|
4535
|
+
return null;
|
|
4536
|
+
}
|
|
4537
|
+
function stageClick(point) {
|
|
4538
|
+
if (props.disabled) {
|
|
4539
|
+
return;
|
|
4540
|
+
}
|
|
4541
|
+
const formatted = formatPoint(point);
|
|
4542
|
+
if (maxChoices === 1) {
|
|
4543
|
+
props.setValue(formatted);
|
|
4544
|
+
return;
|
|
4545
|
+
}
|
|
4546
|
+
if (points.length < maxChoices) {
|
|
4547
|
+
props.setValue([...points, formatted]);
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
return createElement20(GraphicStage, {
|
|
4551
|
+
object: node.object,
|
|
4552
|
+
resolveAsset: props.resolveAsset,
|
|
4553
|
+
interaction: "selectPointInteraction",
|
|
4554
|
+
status: props.status,
|
|
4555
|
+
onStageClick: stageClick,
|
|
4556
|
+
prompt: node.prompt ? createElement20("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
|
|
4557
|
+
overlay: points.map((value, index) => {
|
|
4558
|
+
const [x, y] = value.split(/\s+/u).map(Number);
|
|
4559
|
+
return createElement20(Fragment5, { key: `${value}-${index}` }, createElement20("circle", {
|
|
4560
|
+
cx: x,
|
|
4561
|
+
cy: y,
|
|
4562
|
+
r: 5,
|
|
4563
|
+
fill: "currentColor",
|
|
4564
|
+
"data-qti-point": value,
|
|
4565
|
+
style: { pointerEvents: "none" }
|
|
4566
|
+
}));
|
|
4567
|
+
}),
|
|
4568
|
+
after: createElement20("button", {
|
|
4569
|
+
type: "button",
|
|
4570
|
+
disabled: props.disabled || points.length === 0,
|
|
4571
|
+
onClick: () => props.setValue(null)
|
|
4572
|
+
}, "Clear points")
|
|
4573
|
+
});
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
// src/reference-skin/slider.ts
|
|
4577
|
+
import { createElement as createElement21 } from "react";
|
|
4578
|
+
function SliderReferenceSkin(props) {
|
|
4579
|
+
const node = props.node;
|
|
4580
|
+
const lower = node.lowerBound ?? 0;
|
|
4581
|
+
const value = typeof props.value === "string" && props.value !== "" ? props.value : String(lower);
|
|
4582
|
+
return createElement21("div", { "data-qti-interaction": "sliderInteraction", "data-status": props.status }, node.prompt ? createElement21("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement21("input", {
|
|
4583
|
+
type: "range",
|
|
4584
|
+
min: lower,
|
|
4585
|
+
max: node.upperBound ?? 100,
|
|
4586
|
+
step: node.step ?? 1,
|
|
4587
|
+
value,
|
|
4588
|
+
disabled: props.disabled,
|
|
4589
|
+
"aria-disabled": props.disabled,
|
|
4590
|
+
onChange: (event) => {
|
|
4591
|
+
props.setValue(event.target.value);
|
|
4592
|
+
}
|
|
4593
|
+
}), createElement21("output", null, props.value === null ? "—" : value));
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4596
|
+
// src/reference-skin/text-entry.ts
|
|
4597
|
+
import { createElement as createElement22 } from "react";
|
|
4598
|
+
function TextEntryReferenceSkin(props) {
|
|
4599
|
+
const node = props.node;
|
|
4600
|
+
const value = typeof props.value === "string" ? props.value : "";
|
|
4601
|
+
return createElement22("input", {
|
|
4602
|
+
type: "text",
|
|
4603
|
+
value,
|
|
4604
|
+
placeholder: node.placeholderText,
|
|
4605
|
+
size: node.expectedLength,
|
|
4606
|
+
disabled: props.disabled,
|
|
4607
|
+
"aria-disabled": props.disabled,
|
|
4608
|
+
"data-qti-interaction": "textEntryInteraction",
|
|
4609
|
+
"data-status": props.status,
|
|
4610
|
+
onChange: (event) => {
|
|
4611
|
+
props.setValue(event.target.value === "" ? null : event.target.value);
|
|
4612
|
+
}
|
|
4613
|
+
});
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
// src/reference-skin/upload.ts
|
|
4617
|
+
import { createElement as createElement23 } from "react";
|
|
4618
|
+
function UploadReferenceSkin(props) {
|
|
4619
|
+
const node = props.node;
|
|
4620
|
+
return createElement23("div", { "data-qti-interaction": "uploadInteraction", "data-status": props.status }, node.prompt ? createElement23("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null, createElement23("input", {
|
|
4621
|
+
type: "file",
|
|
4622
|
+
accept: node.type,
|
|
4623
|
+
disabled: props.disabled,
|
|
4624
|
+
"aria-disabled": props.disabled,
|
|
4625
|
+
onChange: (event) => {
|
|
4626
|
+
const file = event.target.files?.[0];
|
|
4627
|
+
if (!file) {
|
|
4628
|
+
props.setValue(null);
|
|
4629
|
+
return;
|
|
4630
|
+
}
|
|
4631
|
+
const reader = new FileReader;
|
|
4632
|
+
reader.onload = () => {
|
|
4633
|
+
props.setValue(typeof reader.result === "string" ? reader.result : null);
|
|
4634
|
+
};
|
|
4635
|
+
reader.readAsDataURL(file);
|
|
4636
|
+
}
|
|
4637
|
+
}));
|
|
4638
|
+
}
|
|
4639
|
+
|
|
4640
|
+
// src/reference-skin/index.ts
|
|
4641
|
+
var referenceSkin = {
|
|
4642
|
+
associateInteraction: AssociateReferenceSkin,
|
|
4643
|
+
choiceInteraction: ChoiceReferenceSkin,
|
|
4644
|
+
drawingInteraction: DrawingReferenceSkin,
|
|
4645
|
+
endAttemptInteraction: EndAttemptReferenceSkin,
|
|
4646
|
+
extendedTextInteraction: ExtendedTextReferenceSkin,
|
|
4647
|
+
gapMatchInteraction: GapMatchReferenceSkin,
|
|
4648
|
+
graphicAssociateInteraction: GraphicAssociateReferenceSkin,
|
|
4649
|
+
graphicGapMatchInteraction: GraphicGapMatchReferenceSkin,
|
|
4650
|
+
graphicOrderInteraction: GraphicOrderReferenceSkin,
|
|
4651
|
+
hotspotInteraction: HotspotReferenceSkin,
|
|
4652
|
+
hottextInteraction: HottextReferenceSkin,
|
|
4653
|
+
inlineChoiceInteraction: InlineChoiceReferenceSkin,
|
|
4654
|
+
matchInteraction: MatchReferenceSkin,
|
|
4655
|
+
mediaInteraction: MediaReferenceSkin,
|
|
4656
|
+
orderInteraction: OrderReferenceSkin,
|
|
4657
|
+
positionObjectStage: PositionObjectReferenceSkin,
|
|
4658
|
+
selectPointInteraction: SelectPointReferenceSkin,
|
|
4659
|
+
sliderInteraction: SliderReferenceSkin,
|
|
4660
|
+
textEntryInteraction: TextEntryReferenceSkin,
|
|
4661
|
+
uploadInteraction: UploadReferenceSkin
|
|
4662
|
+
};
|
|
382
4663
|
|
|
383
4664
|
// src/index.ts
|
|
384
4665
|
var qtiReactPackageName = "@conform-ed/qti-react";
|
|
385
4666
|
export {
|
|
4667
|
+
valueToPciResponse,
|
|
386
4668
|
v0InteractionKinds,
|
|
387
4669
|
v0ContentModel,
|
|
4670
|
+
uploadInteraction,
|
|
4671
|
+
textOf,
|
|
388
4672
|
textEntryInteraction,
|
|
4673
|
+
sliderInteraction,
|
|
4674
|
+
serializePciMarkup,
|
|
4675
|
+
selectPointInteraction,
|
|
389
4676
|
scoreResponse,
|
|
390
4677
|
sanitizeAttributes,
|
|
4678
|
+
resolveTemplate,
|
|
4679
|
+
referenceSkin,
|
|
391
4680
|
qtiReactPackageName,
|
|
392
4681
|
qtiCoreInteractions,
|
|
4682
|
+
positionObjectStage,
|
|
4683
|
+
portableCustomInteraction,
|
|
4684
|
+
pointInShape,
|
|
4685
|
+
pciResponseToValue,
|
|
4686
|
+
parsePoint,
|
|
4687
|
+
parseCoords,
|
|
4688
|
+
orderInteraction,
|
|
4689
|
+
mulberry32,
|
|
4690
|
+
mountPci,
|
|
4691
|
+
mediaInteraction,
|
|
4692
|
+
matchInteraction,
|
|
393
4693
|
matchCorrect,
|
|
4694
|
+
mapResponsePoint,
|
|
394
4695
|
mapResponse,
|
|
395
4696
|
isInteractionKind,
|
|
396
4697
|
isAllowedFlowElement,
|
|
397
4698
|
inlineChoiceInteraction,
|
|
4699
|
+
hottextInteraction,
|
|
4700
|
+
hotspotInteraction,
|
|
4701
|
+
graphicOrderInteraction,
|
|
4702
|
+
graphicGapMatchInteraction,
|
|
4703
|
+
graphicAssociateInteraction,
|
|
4704
|
+
gapMatchInteraction,
|
|
4705
|
+
formatPoint,
|
|
398
4706
|
foldString,
|
|
4707
|
+
extendedTextInteraction,
|
|
4708
|
+
executeTemplateProcessing,
|
|
4709
|
+
executeResponseProcessing,
|
|
4710
|
+
endAttemptInteraction,
|
|
4711
|
+
drawingInteraction,
|
|
399
4712
|
defineInteraction,
|
|
4713
|
+
createTestSessionStore,
|
|
4714
|
+
createTestController,
|
|
400
4715
|
createQtiRuntime,
|
|
4716
|
+
createPciSkin,
|
|
4717
|
+
createPciModuleRegistry,
|
|
401
4718
|
createAttemptStore,
|
|
402
|
-
|
|
4719
|
+
collectTemplateIssues,
|
|
4720
|
+
collectRpIssues,
|
|
4721
|
+
choiceInteraction,
|
|
4722
|
+
associateInteraction,
|
|
4723
|
+
assessmentTestViewFromNormalized,
|
|
4724
|
+
assessmentItemViewFromNormalized,
|
|
4725
|
+
applyCorrectResponseOverrides,
|
|
4726
|
+
UploadReferenceSkin,
|
|
4727
|
+
TextEntryReferenceSkin,
|
|
4728
|
+
SliderReferenceSkin,
|
|
4729
|
+
SelectPointReferenceSkin,
|
|
4730
|
+
PositionObjectReferenceSkin,
|
|
4731
|
+
OrderReferenceSkin,
|
|
4732
|
+
MediaReferenceSkin,
|
|
4733
|
+
MatchReferenceSkin,
|
|
4734
|
+
InlineChoiceReferenceSkin,
|
|
4735
|
+
HottextReferenceSkin,
|
|
4736
|
+
HotspotReferenceSkin,
|
|
4737
|
+
GraphicStage,
|
|
4738
|
+
GraphicOrderReferenceSkin,
|
|
4739
|
+
GraphicGapMatchReferenceSkin,
|
|
4740
|
+
GraphicAssociateReferenceSkin,
|
|
4741
|
+
GapMatchReferenceSkin,
|
|
4742
|
+
ExtendedTextReferenceSkin,
|
|
4743
|
+
EndAttemptReferenceSkin,
|
|
4744
|
+
DrawingReferenceSkin,
|
|
4745
|
+
ChoiceReferenceSkin,
|
|
4746
|
+
AssociateReferenceSkin
|
|
403
4747
|
};
|