@conform-ed/qti-react 0.0.12 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4566 -212
- package/package.json +3 -1
- 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/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 -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/upload.ts +19 -0
- package/src/normalized-item.ts +561 -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 +135 -0
- package/src/pci/registry.ts +240 -0
- package/src/pci/response.ts +138 -0
- package/src/pci/skin.ts +87 -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 +150 -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 +75 -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 +828 -0
- package/src/rp/index.ts +30 -0
- package/src/rp/interpreter.ts +251 -0
- package/src/rp/template-processing.ts +295 -0
- package/src/rp/templates.ts +190 -0
- package/src/rp/types.ts +161 -0
- package/src/rp/values.ts +198 -0
- package/src/runtime.ts +474 -28
- package/src/store.ts +155 -5
- package/src/test/controller.ts +806 -0
- package/src/test/index.ts +25 -0
- package/src/test/session-store.ts +244 -0
- package/src/test/types.ts +203 -0
- package/src/types.ts +27 -1
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared QTI expression evaluator (ADR-0004), used by both response processing and
|
|
3
|
+
* template processing. The environment supplies variable lookup, declarations, and —
|
|
4
|
+
* only where the spec allows nondeterminism (template processing) — a seeded PRNG.
|
|
5
|
+
* Random operators without a PRNG in the environment are unsupported constructs:
|
|
6
|
+
* response processing must stay deterministic and replayable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseCoords, parsePoint, pointInShape } from "../graphic";
|
|
10
|
+
import { mapResponse, mapResponsePoint } from "../response-processing";
|
|
11
|
+
import type { ResponseDeclarationView, ResponseValue } from "../types";
|
|
12
|
+
|
|
13
|
+
import type { CustomOperatorImplementation, ResponseNormalization, RpExpressionView } from "./types";
|
|
14
|
+
import {
|
|
15
|
+
booleanValue,
|
|
16
|
+
coerceScalar,
|
|
17
|
+
floatValue,
|
|
18
|
+
scalarsEqual,
|
|
19
|
+
singleBoolean,
|
|
20
|
+
singleNumber,
|
|
21
|
+
valuesMatch,
|
|
22
|
+
type MaybeRpValue,
|
|
23
|
+
type RpValue,
|
|
24
|
+
} from "./values";
|
|
25
|
+
|
|
26
|
+
export interface EvalEnv {
|
|
27
|
+
readonly lookupVariable: (identifier: string) => MaybeRpValue;
|
|
28
|
+
readonly responseDeclaration: (identifier: string) => ResponseDeclarationView | undefined;
|
|
29
|
+
readonly responseValue: (identifier: string) => ResponseValue;
|
|
30
|
+
readonly normalization?: ResponseNormalization;
|
|
31
|
+
/** Seeded PRNG in [0, 1); present only in template processing. */
|
|
32
|
+
readonly random?: () => number;
|
|
33
|
+
/** `testVariables` aggregation; present only in test-level outcome processing. */
|
|
34
|
+
readonly testVariables?: (expression: RpExpressionView) => MaybeRpValue;
|
|
35
|
+
/** The `number*` item-session aggregates; present only in test-level outcome processing. */
|
|
36
|
+
readonly testAggregate?: (expression: RpExpressionView) => MaybeRpValue;
|
|
37
|
+
/** Registered vendor operators by class; unregistered classes stay unsupported. */
|
|
38
|
+
readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Expression kinds legal everywhere (deterministic). */
|
|
42
|
+
export const deterministicExpressionKinds = new Set([
|
|
43
|
+
"and",
|
|
44
|
+
"baseValue",
|
|
45
|
+
"correct",
|
|
46
|
+
"delete",
|
|
47
|
+
"divide",
|
|
48
|
+
"equal",
|
|
49
|
+
"equalRounded",
|
|
50
|
+
"fieldValue",
|
|
51
|
+
"gcd",
|
|
52
|
+
"gt",
|
|
53
|
+
"gte",
|
|
54
|
+
"index",
|
|
55
|
+
"inside",
|
|
56
|
+
"lcm",
|
|
57
|
+
"integerDivide",
|
|
58
|
+
"integerModulus",
|
|
59
|
+
"integerToFloat",
|
|
60
|
+
"isNull",
|
|
61
|
+
"lt",
|
|
62
|
+
"lte",
|
|
63
|
+
"mapResponse",
|
|
64
|
+
"mapResponsePoint",
|
|
65
|
+
"match",
|
|
66
|
+
"mathConstant",
|
|
67
|
+
"mathOperator",
|
|
68
|
+
"max",
|
|
69
|
+
"member",
|
|
70
|
+
"min",
|
|
71
|
+
"multiple",
|
|
72
|
+
"not",
|
|
73
|
+
"or",
|
|
74
|
+
"ordered",
|
|
75
|
+
"product",
|
|
76
|
+
"repeat",
|
|
77
|
+
"round",
|
|
78
|
+
"roundTo",
|
|
79
|
+
"statsOperator",
|
|
80
|
+
"stringMatch",
|
|
81
|
+
"substring",
|
|
82
|
+
"subtract",
|
|
83
|
+
"sum",
|
|
84
|
+
"truncate",
|
|
85
|
+
"variable",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** Expression kinds requiring the seeded PRNG (template processing only). */
|
|
89
|
+
export const randomExpressionKinds = new Set(["random", "randomFloat", "randomInteger"]);
|
|
90
|
+
|
|
91
|
+
export class RpUnsupportedError extends Error {
|
|
92
|
+
constructor(readonly kindName: string) {
|
|
93
|
+
super(`Unsupported response-processing construct: ${kindName}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const mathConstants: Readonly<Record<string, number>> = { pi: Math.PI, e: Math.E };
|
|
98
|
+
|
|
99
|
+
/** The named functions of `mathOperator`; undefined means the name is unknown. */
|
|
100
|
+
function applyMathOperator(name: string, x: number, y: number): number | undefined {
|
|
101
|
+
switch (name) {
|
|
102
|
+
case "sin":
|
|
103
|
+
return Math.sin(x);
|
|
104
|
+
case "cos":
|
|
105
|
+
return Math.cos(x);
|
|
106
|
+
case "tan":
|
|
107
|
+
return Math.tan(x);
|
|
108
|
+
case "sec":
|
|
109
|
+
return 1 / Math.cos(x);
|
|
110
|
+
case "csc":
|
|
111
|
+
return 1 / Math.sin(x);
|
|
112
|
+
case "cot":
|
|
113
|
+
return Math.cos(x) / Math.sin(x);
|
|
114
|
+
case "asin":
|
|
115
|
+
return Math.asin(x);
|
|
116
|
+
case "acos":
|
|
117
|
+
return Math.acos(x);
|
|
118
|
+
case "atan":
|
|
119
|
+
return Math.atan(x);
|
|
120
|
+
case "atan2":
|
|
121
|
+
return Math.atan2(x, y);
|
|
122
|
+
case "asec":
|
|
123
|
+
return Math.acos(1 / x);
|
|
124
|
+
case "acsc":
|
|
125
|
+
return Math.asin(1 / x);
|
|
126
|
+
case "acot":
|
|
127
|
+
return Math.atan(1 / x);
|
|
128
|
+
case "sinh":
|
|
129
|
+
return Math.sinh(x);
|
|
130
|
+
case "cosh":
|
|
131
|
+
return Math.cosh(x);
|
|
132
|
+
case "tanh":
|
|
133
|
+
return Math.tanh(x);
|
|
134
|
+
case "sech":
|
|
135
|
+
return 1 / Math.cosh(x);
|
|
136
|
+
case "csch":
|
|
137
|
+
return 1 / Math.sinh(x);
|
|
138
|
+
case "coth":
|
|
139
|
+
return Math.cosh(x) / Math.sinh(x);
|
|
140
|
+
case "log":
|
|
141
|
+
return Math.log10(x);
|
|
142
|
+
case "ln":
|
|
143
|
+
return Math.log(x);
|
|
144
|
+
case "exp":
|
|
145
|
+
return Math.exp(x);
|
|
146
|
+
case "abs":
|
|
147
|
+
return Math.abs(x);
|
|
148
|
+
case "signum":
|
|
149
|
+
return Math.sign(x);
|
|
150
|
+
case "floor":
|
|
151
|
+
return Math.floor(x);
|
|
152
|
+
case "ceil":
|
|
153
|
+
return Math.ceil(x);
|
|
154
|
+
case "toDegrees":
|
|
155
|
+
return (x * 180) / Math.PI;
|
|
156
|
+
case "toRadians":
|
|
157
|
+
return (x * Math.PI) / 180;
|
|
158
|
+
default:
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function roundToFigures(value: number, mode: "decimalPlaces" | "significantFigures", figures: number): number | null {
|
|
164
|
+
if (mode === "decimalPlaces") {
|
|
165
|
+
const scale = 10 ** figures;
|
|
166
|
+
return Math.round(value * scale) / scale;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (figures < 1) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value === 0) {
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const magnitude = Math.floor(Math.log10(Math.abs(value)));
|
|
178
|
+
const scale = 10 ** (figures - 1 - magnitude);
|
|
179
|
+
|
|
180
|
+
return Math.round(value * scale) / scale;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function evaluateExpression(expression: RpExpressionView, env: EvalEnv): MaybeRpValue {
|
|
184
|
+
function evaluate(child: RpExpressionView): MaybeRpValue {
|
|
185
|
+
return evaluateExpression(child, env);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
switch (expression.kind) {
|
|
189
|
+
case "baseValue": {
|
|
190
|
+
const baseType = expression.baseType;
|
|
191
|
+
const value = expression.value;
|
|
192
|
+
|
|
193
|
+
return value === undefined ? null : { cardinality: "single", baseType, values: [coerceScalar(value, baseType)] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "variable":
|
|
197
|
+
return env.lookupVariable(expression.identifier ?? "");
|
|
198
|
+
|
|
199
|
+
case "correct": {
|
|
200
|
+
const declaration = env.responseDeclaration(expression.identifier ?? "");
|
|
201
|
+
|
|
202
|
+
if (!declaration?.correctResponse) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
cardinality: declaration.cardinality,
|
|
208
|
+
baseType: declaration.baseType,
|
|
209
|
+
values: declaration.correctResponse.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "mapResponse": {
|
|
214
|
+
const identifier = expression.identifier ?? "";
|
|
215
|
+
const declaration = env.responseDeclaration(identifier);
|
|
216
|
+
|
|
217
|
+
if (!declaration) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return floatValue(mapResponse(declaration, env.responseValue(identifier), env.normalization));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "mapResponsePoint": {
|
|
225
|
+
const identifier = expression.identifier ?? "";
|
|
226
|
+
const declaration = env.responseDeclaration(identifier);
|
|
227
|
+
|
|
228
|
+
if (!declaration) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return floatValue(mapResponsePoint(declaration, env.responseValue(identifier)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case "match": {
|
|
236
|
+
const [a, b] = (expression.expressions ?? []).map(evaluate);
|
|
237
|
+
|
|
238
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return booleanValue(valuesMatch(a, b, env.normalization));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
case "isNull": {
|
|
246
|
+
const operand = expression.expressions?.[0];
|
|
247
|
+
|
|
248
|
+
return booleanValue(operand === undefined || evaluate(operand) === null);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "not": {
|
|
252
|
+
const operand = expression.expressions?.[0];
|
|
253
|
+
const value = operand === undefined ? null : singleBoolean(evaluate(operand));
|
|
254
|
+
|
|
255
|
+
return value === null ? null : booleanValue(!value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case "fieldValue": {
|
|
259
|
+
const operand = expression.expressions?.[0];
|
|
260
|
+
const value = operand === undefined ? null : evaluate(operand);
|
|
261
|
+
const field = value?.fields?.find((entry) => entry.name === expression.fieldIdentifier);
|
|
262
|
+
|
|
263
|
+
return field === undefined
|
|
264
|
+
? null
|
|
265
|
+
: { cardinality: "single", ...(field.baseType ? { baseType: field.baseType } : {}), values: [field.value] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case "and":
|
|
269
|
+
case "or": {
|
|
270
|
+
const members = (expression.expressions ?? []).map((child) => singleBoolean(evaluate(child)));
|
|
271
|
+
|
|
272
|
+
// NULL operands are treated as false; sufficient for the supported coverage.
|
|
273
|
+
return booleanValue(
|
|
274
|
+
expression.kind === "and"
|
|
275
|
+
? members.every((member) => member === true)
|
|
276
|
+
: members.some((member) => member === true),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case "sum":
|
|
281
|
+
case "product": {
|
|
282
|
+
let result = expression.kind === "sum" ? 0 : 1;
|
|
283
|
+
|
|
284
|
+
for (const child of expression.expressions ?? []) {
|
|
285
|
+
const value = evaluate(child);
|
|
286
|
+
|
|
287
|
+
if (value === null) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const member of value.values) {
|
|
292
|
+
if (typeof member !== "number") {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
result = expression.kind === "sum" ? result + member : result * member;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return floatValue(result);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "subtract":
|
|
304
|
+
case "divide": {
|
|
305
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
306
|
+
|
|
307
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (expression.kind === "divide") {
|
|
312
|
+
return b === 0 ? null : floatValue(a / b);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return floatValue(a - b);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case "gt":
|
|
319
|
+
case "gte":
|
|
320
|
+
case "lt":
|
|
321
|
+
case "lte": {
|
|
322
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
323
|
+
|
|
324
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const comparisons = { gt: a > b, gte: a >= b, lt: a < b, lte: a <= b } as const;
|
|
329
|
+
|
|
330
|
+
return booleanValue(comparisons[expression.kind]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case "equal": {
|
|
334
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
335
|
+
|
|
336
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const mode = expression.toleranceMode ?? "exact";
|
|
341
|
+
|
|
342
|
+
if (mode === "exact") {
|
|
343
|
+
return booleanValue(a === b);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const t0 = expression.tolerance?.[0];
|
|
347
|
+
const t1 = expression.tolerance?.[1] ?? t0;
|
|
348
|
+
|
|
349
|
+
if (typeof t0 !== "number" || typeof t1 !== "number") {
|
|
350
|
+
// Template-variable tolerances (and missing ones) are out of the staged scope.
|
|
351
|
+
throw new RpUnsupportedError("equal");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const lower = mode === "absolute" ? a - t0 : a * (1 - t0 / 100);
|
|
355
|
+
const upper = mode === "absolute" ? a + t1 : a * (1 + t1 / 100);
|
|
356
|
+
const aboveLower = (expression.includeLowerBound ?? true) ? b >= lower : b > lower;
|
|
357
|
+
const belowUpper = (expression.includeUpperBound ?? true) ? b <= upper : b < upper;
|
|
358
|
+
|
|
359
|
+
return booleanValue(aboveLower && belowUpper);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
case "round":
|
|
363
|
+
case "truncate": {
|
|
364
|
+
const operand = expression.expressions?.[0];
|
|
365
|
+
const value = operand === undefined ? null : singleNumber(evaluate(operand));
|
|
366
|
+
|
|
367
|
+
if (value === null) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// QTI rounds half toward positive infinity, which is Math.round's behavior.
|
|
372
|
+
const rounded = expression.kind === "round" ? Math.round(value) : Math.trunc(value);
|
|
373
|
+
|
|
374
|
+
return { cardinality: "single", baseType: "integer", values: [rounded] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "index": {
|
|
378
|
+
if (typeof expression.n !== "number") {
|
|
379
|
+
throw new RpUnsupportedError("index"); // template-variable n is out of scope
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const operand = expression.expressions?.[0];
|
|
383
|
+
const container = operand === undefined ? null : evaluate(operand);
|
|
384
|
+
|
|
385
|
+
if (container === null) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const member = container.values[expression.n - 1];
|
|
390
|
+
|
|
391
|
+
if (member === undefined) {
|
|
392
|
+
return null; // out of range is null, per spec
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { cardinality: "single", baseType: container.baseType, values: [member] };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
case "mathConstant": {
|
|
399
|
+
const constant = expression.name === undefined ? undefined : mathConstants[expression.name];
|
|
400
|
+
|
|
401
|
+
if (constant === undefined) {
|
|
402
|
+
throw new RpUnsupportedError("mathConstant");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return floatValue(constant);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
case "mathOperator": {
|
|
409
|
+
const [x, y] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
410
|
+
|
|
411
|
+
if (x === undefined || x === null || (expression.name === "atan2" && (y === undefined || y === null))) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const result = expression.name === undefined ? undefined : applyMathOperator(expression.name, x, y ?? NaN);
|
|
416
|
+
|
|
417
|
+
if (result === undefined) {
|
|
418
|
+
throw new RpUnsupportedError("mathOperator");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return Number.isFinite(result) ? floatValue(result) : null; // domain errors are NULL
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case "integerDivide":
|
|
425
|
+
case "integerModulus": {
|
|
426
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
427
|
+
|
|
428
|
+
if (a === undefined || b === undefined || a === null || b === null || b === 0) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const result = expression.kind === "integerDivide" ? Math.trunc(a / b) : a % b;
|
|
433
|
+
|
|
434
|
+
return { cardinality: "single", baseType: "integer", values: [result] };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
case "integerToFloat": {
|
|
438
|
+
const operand = expression.expressions?.[0];
|
|
439
|
+
const value = operand === undefined ? null : singleNumber(evaluate(operand));
|
|
440
|
+
|
|
441
|
+
return value === null ? null : floatValue(value);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
case "min":
|
|
445
|
+
case "max": {
|
|
446
|
+
const members: number[] = [];
|
|
447
|
+
|
|
448
|
+
for (const child of expression.expressions ?? []) {
|
|
449
|
+
const value = evaluate(child);
|
|
450
|
+
|
|
451
|
+
if (value === null) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const member of value.values) {
|
|
456
|
+
if (typeof member !== "number") {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
members.push(member);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (members.length === 0) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return floatValue(expression.kind === "min" ? Math.min(...members) : Math.max(...members));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
case "gcd":
|
|
472
|
+
case "lcm": {
|
|
473
|
+
const members: number[] = [];
|
|
474
|
+
|
|
475
|
+
for (const child of expression.expressions ?? []) {
|
|
476
|
+
const value = evaluate(child);
|
|
477
|
+
|
|
478
|
+
if (value === null) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const member of value.values) {
|
|
483
|
+
if (typeof member !== "number" || !Number.isInteger(member)) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
members.push(Math.abs(member));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (members.length === 0) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const gcdOf = (a: number, b: number): number => (b === 0 ? a : gcdOf(b, a % b));
|
|
496
|
+
const result =
|
|
497
|
+
expression.kind === "gcd"
|
|
498
|
+
? members.reduce(gcdOf)
|
|
499
|
+
: members.reduce((a, b) => (a === 0 || b === 0 ? 0 : (a / gcdOf(a, b)) * b));
|
|
500
|
+
|
|
501
|
+
return { cardinality: "single", baseType: "integer", values: [result] };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
case "roundTo": {
|
|
505
|
+
if (typeof expression.figures !== "number") {
|
|
506
|
+
throw new RpUnsupportedError("roundTo"); // template-variable figures
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const operand = expression.expressions?.[0];
|
|
510
|
+
const value = operand === undefined ? null : singleNumber(evaluate(operand));
|
|
511
|
+
|
|
512
|
+
if (value === null) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const rounded = roundToFigures(value, expression.roundingMode ?? "significantFigures", expression.figures);
|
|
517
|
+
|
|
518
|
+
return rounded === null ? null : floatValue(rounded);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
case "equalRounded": {
|
|
522
|
+
if (typeof expression.figures !== "number") {
|
|
523
|
+
throw new RpUnsupportedError("equalRounded");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
527
|
+
|
|
528
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const mode = expression.roundingMode ?? "significantFigures";
|
|
533
|
+
const roundedA = roundToFigures(a, mode, expression.figures);
|
|
534
|
+
const roundedB = roundToFigures(b, mode, expression.figures);
|
|
535
|
+
|
|
536
|
+
return roundedA === null || roundedB === null ? null : booleanValue(roundedA === roundedB);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
case "statsOperator": {
|
|
540
|
+
const operand = expression.expressions?.[0];
|
|
541
|
+
const container = operand === undefined ? null : evaluate(operand);
|
|
542
|
+
|
|
543
|
+
if (container === null) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const members: number[] = [];
|
|
548
|
+
|
|
549
|
+
for (const member of container.values) {
|
|
550
|
+
if (typeof member !== "number") {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
members.push(member);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const count = members.length;
|
|
558
|
+
|
|
559
|
+
if (count === 0) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const mean = members.reduce((sum, member) => sum + member, 0) / count;
|
|
564
|
+
const sumSquares = members.reduce((sum, member) => sum + (member - mean) ** 2, 0);
|
|
565
|
+
|
|
566
|
+
switch (expression.name) {
|
|
567
|
+
case "mean":
|
|
568
|
+
return floatValue(mean);
|
|
569
|
+
case "popVariance":
|
|
570
|
+
return floatValue(sumSquares / count);
|
|
571
|
+
case "popSD":
|
|
572
|
+
return floatValue(Math.sqrt(sumSquares / count));
|
|
573
|
+
case "sampleVariance":
|
|
574
|
+
return count < 2 ? null : floatValue(sumSquares / (count - 1));
|
|
575
|
+
case "sampleSD":
|
|
576
|
+
return count < 2 ? null : floatValue(Math.sqrt(sumSquares / (count - 1)));
|
|
577
|
+
default:
|
|
578
|
+
throw new RpUnsupportedError("statsOperator");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case "delete": {
|
|
583
|
+
const [valueExpression, containerExpression] = expression.expressions ?? [];
|
|
584
|
+
|
|
585
|
+
if (valueExpression === undefined || containerExpression === undefined) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const value = evaluate(valueExpression);
|
|
590
|
+
const container = evaluate(containerExpression);
|
|
591
|
+
|
|
592
|
+
if (value === null || container === null) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const scalar = value.values[0];
|
|
597
|
+
|
|
598
|
+
if (scalar === undefined) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const baseType = container.baseType ?? value.baseType;
|
|
603
|
+
const remaining = container.values.filter((member) => !scalarsEqual(member, scalar, baseType, env.normalization));
|
|
604
|
+
|
|
605
|
+
// An empty container is NULL, per the QTI value model.
|
|
606
|
+
return remaining.length === 0
|
|
607
|
+
? null
|
|
608
|
+
: { cardinality: container.cardinality, baseType: container.baseType, values: remaining };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
case "repeat": {
|
|
612
|
+
if (typeof expression.numberRepeats !== "number") {
|
|
613
|
+
throw new RpUnsupportedError("repeat"); // template-variable count
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (expression.numberRepeats < 1) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const members: RpValue["values"][number][] = [];
|
|
621
|
+
let baseType: string | undefined;
|
|
622
|
+
|
|
623
|
+
for (let pass = 0; pass < expression.numberRepeats; pass += 1) {
|
|
624
|
+
for (const child of expression.expressions ?? []) {
|
|
625
|
+
const value = evaluate(child);
|
|
626
|
+
|
|
627
|
+
if (value === null) {
|
|
628
|
+
continue; // NULL sub-expressions are ignored, per spec
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
baseType ??= value.baseType;
|
|
632
|
+
members.push(...value.values);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return members.length === 0 ? null : { cardinality: "ordered", baseType, values: members };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
case "stringMatch":
|
|
640
|
+
case "substring": {
|
|
641
|
+
const [a, b] = (expression.expressions ?? []).map((child) => {
|
|
642
|
+
const value = evaluate(child);
|
|
643
|
+
const member = value?.values[0];
|
|
644
|
+
|
|
645
|
+
return typeof member === "string" ? member : null;
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const normalize = (input: string): string => {
|
|
653
|
+
const normalized = env.normalization?.(input) ?? input;
|
|
654
|
+
|
|
655
|
+
return expression.caseSensitive === false ? normalized.toLowerCase() : normalized;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const [left, right] = [normalize(a), normalize(b)];
|
|
659
|
+
const contains = expression.kind === "substring" || expression.substring === true;
|
|
660
|
+
|
|
661
|
+
return booleanValue(contains ? right.includes(left) : left === right);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
case "inside": {
|
|
665
|
+
if (typeof expression.shape !== "string" || typeof expression.coords !== "string") {
|
|
666
|
+
throw new RpUnsupportedError("inside");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const operand = expression.expressions?.[0];
|
|
670
|
+
const value = operand === undefined ? null : evaluate(operand);
|
|
671
|
+
const member = value?.values[0];
|
|
672
|
+
|
|
673
|
+
if (value === null || typeof member !== "string") {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const point = parsePoint(member);
|
|
678
|
+
|
|
679
|
+
if (point === null) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return booleanValue(pointInShape(expression.shape, parseCoords(expression.coords), point));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
case "member": {
|
|
687
|
+
const [needleExpression, containerExpression] = expression.expressions ?? [];
|
|
688
|
+
|
|
689
|
+
if (needleExpression === undefined || containerExpression === undefined) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const needle = evaluate(needleExpression);
|
|
694
|
+
const container = evaluate(containerExpression);
|
|
695
|
+
|
|
696
|
+
if (needle === null || container === null) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const scalar = needle.values[0];
|
|
701
|
+
|
|
702
|
+
if (scalar === undefined) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const baseType = container.baseType ?? needle.baseType;
|
|
707
|
+
|
|
708
|
+
return booleanValue(container.values.some((member) => scalarsEqual(member, scalar, baseType, env.normalization)));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
case "multiple":
|
|
712
|
+
case "ordered": {
|
|
713
|
+
const members: RpValue["values"][number][] = [];
|
|
714
|
+
let baseType: string | undefined;
|
|
715
|
+
|
|
716
|
+
for (const child of expression.expressions ?? []) {
|
|
717
|
+
const value = evaluate(child);
|
|
718
|
+
|
|
719
|
+
if (value === null) {
|
|
720
|
+
continue; // spec: NULL sub-expressions are ignored by container constructors
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
baseType ??= value.baseType;
|
|
724
|
+
members.push(...value.values);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (members.length === 0) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { cardinality: expression.kind, baseType, values: members };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
case "randomInteger": {
|
|
735
|
+
if (!env.random) {
|
|
736
|
+
throw new RpUnsupportedError(expression.kind);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const min = expression.min ?? 0;
|
|
740
|
+
const max = expression.max ?? min;
|
|
741
|
+
const step = expression.step ?? 1;
|
|
742
|
+
const count = Math.max(1, Math.floor((max - min) / step) + 1);
|
|
743
|
+
|
|
744
|
+
return { cardinality: "single", baseType: "integer", values: [min + Math.floor(env.random() * count) * step] };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
case "randomFloat": {
|
|
748
|
+
if (!env.random) {
|
|
749
|
+
throw new RpUnsupportedError(expression.kind);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const min = expression.min ?? 0;
|
|
753
|
+
const max = expression.max ?? min;
|
|
754
|
+
|
|
755
|
+
return floatValue(min + env.random() * (max - min));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
case "testVariables": {
|
|
759
|
+
if (!env.testVariables) {
|
|
760
|
+
throw new RpUnsupportedError(expression.kind);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return env.testVariables(expression);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
case "customOperator": {
|
|
767
|
+
const implementation = env.customOperators?.[expression.class ?? ""];
|
|
768
|
+
|
|
769
|
+
if (!implementation) {
|
|
770
|
+
throw new RpUnsupportedError(expression.kind);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return implementation((expression.expressions ?? []).map(evaluate), expression);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
case "numberCorrect":
|
|
777
|
+
case "numberIncorrect":
|
|
778
|
+
case "numberPresented":
|
|
779
|
+
case "numberResponded":
|
|
780
|
+
case "numberSelected": {
|
|
781
|
+
if (!env.testAggregate) {
|
|
782
|
+
throw new RpUnsupportedError(expression.kind);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return env.testAggregate(expression);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
case "random": {
|
|
789
|
+
if (!env.random) {
|
|
790
|
+
throw new RpUnsupportedError(expression.kind);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const container = expression.expressions?.[0] === undefined ? null : evaluate(expression.expressions[0]);
|
|
794
|
+
|
|
795
|
+
if (container === null || container.values.length === 0) {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const pick = container.values[Math.floor(env.random() * container.values.length)]!;
|
|
800
|
+
|
|
801
|
+
return { cardinality: "single", baseType: container.baseType, values: [pick] };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
default:
|
|
805
|
+
throw new RpUnsupportedError(expression.kind);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Walk an expression tree, reporting kinds outside the allowed set. */
|
|
810
|
+
export function collectExpressionIssues(
|
|
811
|
+
expression: RpExpressionView,
|
|
812
|
+
allowedKinds: ReadonlySet<string>,
|
|
813
|
+
report: (name: string) => void,
|
|
814
|
+
customOperatorClasses?: ReadonlySet<string>,
|
|
815
|
+
): void {
|
|
816
|
+
if (expression.kind === "customOperator") {
|
|
817
|
+
// Supported only for registered classes — a per-class gate, not a kind gate.
|
|
818
|
+
if (!customOperatorClasses?.has(expression.class ?? "")) {
|
|
819
|
+
report(expression.kind);
|
|
820
|
+
}
|
|
821
|
+
} else if (!allowedKinds.has(expression.kind)) {
|
|
822
|
+
report(expression.kind);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
for (const child of expression.expressions ?? []) {
|
|
826
|
+
collectExpressionIssues(child, allowedKinds, report, customOperatorClasses);
|
|
827
|
+
}
|
|
828
|
+
}
|