@conform-ed/qti-react 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -4
- package/dist/index.js +2492 -408
- package/dist/normalized-item.d.ts +7 -5
- package/dist/pnp.d.ts +115 -0
- package/dist/response-validity.d.ts +28 -0
- package/dist/rp/evaluate.d.ts +6 -1
- package/dist/rp/index.d.ts +2 -2
- package/dist/rp/interpreter.d.ts +7 -1
- package/dist/rp/lookup-table.d.ts +17 -0
- package/dist/rp/template-processing.d.ts +5 -0
- package/dist/rp/types.d.ts +71 -7
- package/dist/runtime.d.ts +95 -0
- package/dist/store.d.ts +47 -0
- package/dist/test/controller.d.ts +22 -0
- package/dist/test/index.d.ts +2 -1
- package/dist/test/results.d.ts +102 -0
- package/dist/test/session-store.d.ts +32 -0
- package/dist/test/types.d.ts +173 -5
- package/dist/types.d.ts +5 -0
- package/package.json +5 -1
- package/src/content-model.ts +44 -4
- package/src/index.ts +43 -1
- package/src/normalized-item.ts +106 -4
- package/src/pci/mount.ts +11 -3
- package/src/pnp.ts +333 -0
- package/src/reference-skin/choice.ts +3 -0
- package/src/response-validity.ts +163 -0
- package/src/rp/evaluate.ts +280 -32
- package/src/rp/index.ts +5 -0
- package/src/rp/interpreter.ts +81 -1
- package/src/rp/lookup-table.ts +46 -0
- package/src/rp/template-processing.ts +41 -0
- package/src/rp/types.ts +75 -7
- package/src/runtime.ts +397 -20
- package/src/store.ts +146 -8
- package/src/test/controller.ts +856 -82
- package/src/test/index.ts +23 -0
- package/src/test/results.ts +378 -0
- package/src/test/session-store.ts +109 -1
- package/src/test/types.ts +172 -5
- package/src/types.ts +1 -0
- package/src/xspattern.d.ts +11 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response validity (ItemSessionControl validate-responses): "An invalid response is
|
|
3
|
+
* defined to be a response which does not satisfy the constraints imposed by the
|
|
4
|
+
* interaction with which it is associated." The constraint vocabulary is what the
|
|
5
|
+
* view model carries on interaction nodes — min/max-choices, min/max-associations,
|
|
6
|
+
* min-strings, pattern-mask, min-plays. Only authored attributes are validated:
|
|
7
|
+
* rendering defaults (e.g. a radio group's single choice) are interaction behavior,
|
|
8
|
+
* not submission constraints.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { compile as compileXsdPattern } from "xspattern";
|
|
12
|
+
|
|
13
|
+
import { isInteractionKind, v0ContentModel } from "./content-model";
|
|
14
|
+
import type { BodyNode } from "./runtime";
|
|
15
|
+
import { isResponseRecord } from "./types";
|
|
16
|
+
import type { ResponseValue } from "./types";
|
|
17
|
+
|
|
18
|
+
export type ResponseConstraintKind =
|
|
19
|
+
| "minChoices"
|
|
20
|
+
| "maxChoices"
|
|
21
|
+
| "minAssociations"
|
|
22
|
+
| "maxAssociations"
|
|
23
|
+
| "minStrings"
|
|
24
|
+
| "patternMask"
|
|
25
|
+
| "minPlays";
|
|
26
|
+
|
|
27
|
+
export interface InteractionConstraint {
|
|
28
|
+
readonly responseIdentifier: string;
|
|
29
|
+
readonly kind: ResponseConstraintKind;
|
|
30
|
+
/** The declared bound: a count for the min/max constraints, the XSD regex for patternMask. */
|
|
31
|
+
readonly bound: number | string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A constraint the current response fails — the reason a submission is invalid. */
|
|
35
|
+
export type ResponseViolation = InteractionConstraint;
|
|
36
|
+
|
|
37
|
+
const countConstraintKinds = ["minChoices", "maxChoices", "minAssociations", "maxAssociations", "minStrings"] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Walk the item body and collect the constraint attributes its interactions carry.
|
|
41
|
+
* Zero bounds impose nothing: "If max-choices is 0 then there is no restriction";
|
|
42
|
+
* "If min-choices is 0 then the candidate is not required to select any choices."
|
|
43
|
+
*/
|
|
44
|
+
export function collectInteractionConstraints(content: readonly BodyNode[] | undefined): InteractionConstraint[] {
|
|
45
|
+
const constraints: InteractionConstraint[] = [];
|
|
46
|
+
|
|
47
|
+
function walk(node: BodyNode): void {
|
|
48
|
+
const record = node as unknown as Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
if (isInteractionKind(v0ContentModel, node.kind) && typeof record["responseIdentifier"] === "string") {
|
|
51
|
+
const responseIdentifier = record["responseIdentifier"];
|
|
52
|
+
|
|
53
|
+
for (const kind of [...countConstraintKinds, "minPlays"] as const) {
|
|
54
|
+
const bound = record[kind];
|
|
55
|
+
|
|
56
|
+
if (typeof bound === "number" && bound > 0) {
|
|
57
|
+
constraints.push({ responseIdentifier, kind, bound });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const patternMask = record["patternMask"];
|
|
62
|
+
|
|
63
|
+
if (typeof patternMask === "string" && patternMask !== "") {
|
|
64
|
+
constraints.push({ responseIdentifier, kind: "patternMask", bound: patternMask });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return; // interactions do not nest
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const key of ["content", "children"] as const) {
|
|
71
|
+
const nested = record[key];
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(nested)) {
|
|
74
|
+
for (const child of nested as readonly BodyNode[]) {
|
|
75
|
+
walk(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const node of content ?? []) {
|
|
82
|
+
walk(node);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return constraints;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Selected members of a response: choices picked, associations made, strings entered. */
|
|
89
|
+
function memberCount(value: ResponseValue): number {
|
|
90
|
+
if (value === null || value === undefined || value === "") {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
return value.filter((member) => member !== null && member !== "").length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The non-empty strings a pattern mask applies to (each container member must match). */
|
|
102
|
+
function stringMembers(value: ResponseValue): string[] {
|
|
103
|
+
if (typeof value === "string") {
|
|
104
|
+
return value === "" ? [] : [value];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
return value.filter((member): member is string => typeof member === "string" && member !== "");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function violates(constraint: InteractionConstraint, value: ResponseValue): boolean {
|
|
115
|
+
switch (constraint.kind) {
|
|
116
|
+
case "minChoices":
|
|
117
|
+
case "minAssociations":
|
|
118
|
+
case "minStrings":
|
|
119
|
+
return memberCount(value) < Number(constraint.bound);
|
|
120
|
+
case "maxChoices":
|
|
121
|
+
case "maxAssociations":
|
|
122
|
+
return memberCount(value) > Number(constraint.bound);
|
|
123
|
+
case "minPlays": {
|
|
124
|
+
// "Failure to play the media object the minimum number of times constitutes an
|
|
125
|
+
// invalid response." The media response variable counts the plays.
|
|
126
|
+
const plays = typeof value === "number" ? value : 0;
|
|
127
|
+
|
|
128
|
+
return plays < Number(constraint.bound);
|
|
129
|
+
}
|
|
130
|
+
case "patternMask": {
|
|
131
|
+
// "the pattern-mask specifies a regular expression that the candidate's
|
|
132
|
+
// response must match in order to be considered valid" — XSD regex dialect.
|
|
133
|
+
// An unanswered interaction is governed by the min* constraints, not the
|
|
134
|
+
// pattern, and an uncompilable pattern never blocks the candidate.
|
|
135
|
+
const members = stringMembers(value);
|
|
136
|
+
|
|
137
|
+
if (members.length === 0) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const matches = compileXsdPattern(String(constraint.bound), { language: "xsd" });
|
|
143
|
+
|
|
144
|
+
return !members.every((member) => matches(member));
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The constraints the current responses fail; empty means the responses are valid. */
|
|
153
|
+
export function collectResponseViolations(
|
|
154
|
+
constraints: readonly InteractionConstraint[],
|
|
155
|
+
responses: Readonly<Record<string, ResponseValue>>,
|
|
156
|
+
): ResponseViolation[] {
|
|
157
|
+
return constraints.filter((constraint) => {
|
|
158
|
+
const value = responses[constraint.responseIdentifier] ?? null;
|
|
159
|
+
|
|
160
|
+
// Record responses (PCI-style composites) carry no countable selection.
|
|
161
|
+
return !isResponseRecord(value) && violates(constraint, value);
|
|
162
|
+
});
|
|
163
|
+
}
|
package/src/rp/evaluate.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* response processing must stay deterministic and replayable.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { compile as compileXsdPattern } from "xspattern";
|
|
10
|
+
|
|
9
11
|
import { parseCoords, parsePoint, pointInShape } from "../graphic";
|
|
10
12
|
import { mapResponse, mapResponsePoint } from "../response-processing";
|
|
11
13
|
import type { ResponseDeclarationView, ResponseValue } from "../types";
|
|
@@ -32,8 +34,13 @@ export interface EvalEnv {
|
|
|
32
34
|
readonly random?: (() => number) | undefined;
|
|
33
35
|
/** `testVariables` aggregation; present only in test-level outcome processing. */
|
|
34
36
|
readonly testVariables?: (expression: RpExpressionView) => MaybeRpValue;
|
|
35
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The test-level subset aggregates (`number*`, `outcomeMinimum`/`outcomeMaximum`);
|
|
39
|
+
* present only in test-level outcome processing.
|
|
40
|
+
*/
|
|
36
41
|
readonly testAggregate?: (expression: RpExpressionView) => MaybeRpValue;
|
|
42
|
+
/** Declared default of any item variable, for the `default` expression (§2.11.1.3). */
|
|
43
|
+
readonly variableDefault?: (identifier: string) => MaybeRpValue;
|
|
37
44
|
/** Registered vendor operators by class; unregistered classes stay unsupported. */
|
|
38
45
|
readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>> | undefined;
|
|
39
46
|
}
|
|
@@ -41,10 +48,16 @@ export interface EvalEnv {
|
|
|
41
48
|
/** Expression kinds legal everywhere (deterministic). */
|
|
42
49
|
export const deterministicExpressionKinds: ReadonlySet<string> = new Set([
|
|
43
50
|
"and",
|
|
51
|
+
"anyN",
|
|
44
52
|
"baseValue",
|
|
53
|
+
"containerSize",
|
|
54
|
+
"contains",
|
|
45
55
|
"correct",
|
|
56
|
+
"default",
|
|
46
57
|
"delete",
|
|
47
58
|
"divide",
|
|
59
|
+
"durationGte",
|
|
60
|
+
"durationLt",
|
|
48
61
|
"equal",
|
|
49
62
|
"equalRounded",
|
|
50
63
|
"fieldValue",
|
|
@@ -70,8 +83,11 @@ export const deterministicExpressionKinds: ReadonlySet<string> = new Set([
|
|
|
70
83
|
"min",
|
|
71
84
|
"multiple",
|
|
72
85
|
"not",
|
|
86
|
+
"null",
|
|
73
87
|
"or",
|
|
74
88
|
"ordered",
|
|
89
|
+
"patternMatch",
|
|
90
|
+
"power",
|
|
75
91
|
"product",
|
|
76
92
|
"repeat",
|
|
77
93
|
"round",
|
|
@@ -99,6 +115,36 @@ export class RpUnsupportedError extends Error {
|
|
|
99
115
|
|
|
100
116
|
const mathConstants: Readonly<Record<string, number>> = { pi: Math.PI, e: Math.E };
|
|
101
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Compiled XSD-dialect pattern matchers (`patternMatch` uses the regular expression
|
|
120
|
+
* language of Appendix F of XML Schema, not ECMAScript). null caches a pattern that
|
|
121
|
+
* failed to compile — invalid patterns are refused, never guessed.
|
|
122
|
+
*/
|
|
123
|
+
const xsdPatternMatchers = new Map<string, ((value: string) => boolean) | null>();
|
|
124
|
+
|
|
125
|
+
function xsdPatternMatcher(pattern: string): ((value: string) => boolean) | null {
|
|
126
|
+
const cached = xsdPatternMatchers.get(pattern);
|
|
127
|
+
|
|
128
|
+
if (cached !== undefined) {
|
|
129
|
+
return cached;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let matcher: ((value: string) => boolean) | null;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
matcher = compileXsdPattern(pattern);
|
|
136
|
+
} catch {
|
|
137
|
+
matcher = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
xsdPatternMatchers.set(pattern, matcher);
|
|
141
|
+
|
|
142
|
+
return matcher;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The brace-enclosed variable-reference form of string attributes (§7.13). */
|
|
146
|
+
const encVariableStringPattern = /^\{[^{}]+\}$/u;
|
|
147
|
+
|
|
102
148
|
/** The named functions of `mathOperator`; undefined means the name is unknown. */
|
|
103
149
|
function applyMathOperator(name: string, x: number, y: number): number | undefined {
|
|
104
150
|
switch (name) {
|
|
@@ -163,6 +209,28 @@ function applyMathOperator(name: string, x: number, y: number): number | undefin
|
|
|
163
209
|
}
|
|
164
210
|
}
|
|
165
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A numeric attribute that is either a literal or a variable reference (the spec's
|
|
214
|
+
* IntOrIdentifier / FloatOrVariableRef): "If n is an identifier, it is the value of n
|
|
215
|
+
* at runtime that is used" (QTI 3 info model §2.11.3.6). The brace-enclosed
|
|
216
|
+
* EncVariableString form (§7.13) is accepted alongside bare identifiers. An
|
|
217
|
+
* unresolvable or NULL-valued reference yields null — the operator then results in
|
|
218
|
+
* NULL, never a refusal.
|
|
219
|
+
*/
|
|
220
|
+
function resolveNumericAttribute(raw: number | string | undefined, env: EvalEnv): number | null | undefined {
|
|
221
|
+
if (raw === undefined) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (typeof raw === "number") {
|
|
226
|
+
return raw;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const identifier = raw.startsWith("{") && raw.endsWith("}") ? raw.slice(1, -1) : raw;
|
|
230
|
+
|
|
231
|
+
return singleNumber(env.lookupVariable(identifier));
|
|
232
|
+
}
|
|
233
|
+
|
|
166
234
|
function roundToFigures(value: number, mode: "decimalPlaces" | "significantFigures", figures: number): number | null {
|
|
167
235
|
if (mode === "decimalPlaces") {
|
|
168
236
|
const scale = 10 ** figures;
|
|
@@ -270,12 +338,21 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
270
338
|
case "or": {
|
|
271
339
|
const members = (expression.expressions ?? []).map((child) => singleBoolean(evaluate(child)));
|
|
272
340
|
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
341
|
+
// Three-valued logic (§2.11.3.10/.15): a decisive operand wins outright; an
|
|
342
|
+
// undecided one ("NULL and all others are true/false") makes the result NULL.
|
|
343
|
+
if (expression.kind === "and") {
|
|
344
|
+
if (members.some((member) => member === false)) {
|
|
345
|
+
return booleanValue(false);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return members.some((member) => member === null) ? null : booleanValue(true);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (members.some((member) => member === true)) {
|
|
352
|
+
return booleanValue(true);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return members.some((member) => member === null) ? null : booleanValue(false);
|
|
279
356
|
}
|
|
280
357
|
|
|
281
358
|
case "sum":
|
|
@@ -344,12 +421,12 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
344
421
|
return booleanValue(a === b);
|
|
345
422
|
}
|
|
346
423
|
|
|
347
|
-
const t0 = expression.tolerance?.[0];
|
|
348
|
-
const
|
|
424
|
+
const t0 = resolveNumericAttribute(expression.tolerance?.[0], env);
|
|
425
|
+
const t1raw = expression.tolerance?.[1];
|
|
426
|
+
const t1 = t1raw === undefined ? t0 : resolveNumericAttribute(t1raw, env);
|
|
349
427
|
|
|
350
428
|
if (typeof t0 !== "number" || typeof t1 !== "number") {
|
|
351
|
-
|
|
352
|
-
throw new RpUnsupportedError("equal");
|
|
429
|
+
return null; // missing or unresolvable tolerance → NULL
|
|
353
430
|
}
|
|
354
431
|
|
|
355
432
|
const lower = mode === "absolute" ? a - t0 : a * (1 - t0 / 100);
|
|
@@ -376,8 +453,10 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
376
453
|
}
|
|
377
454
|
|
|
378
455
|
case "index": {
|
|
379
|
-
|
|
380
|
-
|
|
456
|
+
const n = resolveNumericAttribute(expression.n, env);
|
|
457
|
+
|
|
458
|
+
if (typeof n !== "number") {
|
|
459
|
+
return null; // missing or unresolvable n → NULL
|
|
381
460
|
}
|
|
382
461
|
|
|
383
462
|
const operand = expression.expressions?.[0];
|
|
@@ -387,7 +466,7 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
387
466
|
return null;
|
|
388
467
|
}
|
|
389
468
|
|
|
390
|
-
const member = container.values[
|
|
469
|
+
const member = container.values[n - 1];
|
|
391
470
|
|
|
392
471
|
if (member === undefined) {
|
|
393
472
|
return null; // out of range is null, per spec
|
|
@@ -503,8 +582,10 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
503
582
|
}
|
|
504
583
|
|
|
505
584
|
case "roundTo": {
|
|
506
|
-
|
|
507
|
-
|
|
585
|
+
const figures = resolveNumericAttribute(expression.figures, env);
|
|
586
|
+
|
|
587
|
+
if (typeof figures !== "number") {
|
|
588
|
+
return null; // missing or unresolvable figures → NULL
|
|
508
589
|
}
|
|
509
590
|
|
|
510
591
|
const operand = expression.expressions?.[0];
|
|
@@ -514,14 +595,16 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
514
595
|
return null;
|
|
515
596
|
}
|
|
516
597
|
|
|
517
|
-
const rounded = roundToFigures(value, expression.roundingMode ?? "significantFigures",
|
|
598
|
+
const rounded = roundToFigures(value, expression.roundingMode ?? "significantFigures", figures);
|
|
518
599
|
|
|
519
600
|
return rounded === null ? null : floatValue(rounded);
|
|
520
601
|
}
|
|
521
602
|
|
|
522
603
|
case "equalRounded": {
|
|
523
|
-
|
|
524
|
-
|
|
604
|
+
const figures = resolveNumericAttribute(expression.figures, env);
|
|
605
|
+
|
|
606
|
+
if (typeof figures !== "number") {
|
|
607
|
+
return null; // missing or unresolvable figures → NULL
|
|
525
608
|
}
|
|
526
609
|
|
|
527
610
|
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
@@ -531,8 +614,8 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
531
614
|
}
|
|
532
615
|
|
|
533
616
|
const mode = expression.roundingMode ?? "significantFigures";
|
|
534
|
-
const roundedA = roundToFigures(a, mode,
|
|
535
|
-
const roundedB = roundToFigures(b, mode,
|
|
617
|
+
const roundedA = roundToFigures(a, mode, figures);
|
|
618
|
+
const roundedB = roundToFigures(b, mode, figures);
|
|
536
619
|
|
|
537
620
|
return roundedA === null || roundedB === null ? null : booleanValue(roundedA === roundedB);
|
|
538
621
|
}
|
|
@@ -608,18 +691,18 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
608
691
|
}
|
|
609
692
|
|
|
610
693
|
case "repeat": {
|
|
611
|
-
|
|
612
|
-
throw new RpUnsupportedError("repeat"); // template-variable count
|
|
613
|
-
}
|
|
694
|
+
const numberRepeats = resolveNumericAttribute(expression.numberRepeats, env);
|
|
614
695
|
|
|
615
|
-
|
|
696
|
+
// "If qti-number-repeats refers to a variable whose value is less than 1, the
|
|
697
|
+
// value of the whole expression is NULL" (§2.11.3.42); unresolvable refs too.
|
|
698
|
+
if (typeof numberRepeats !== "number" || numberRepeats < 1) {
|
|
616
699
|
return null;
|
|
617
700
|
}
|
|
618
701
|
|
|
619
702
|
const members: RpValue["values"][number][] = [];
|
|
620
703
|
let baseType: string | undefined;
|
|
621
704
|
|
|
622
|
-
for (let pass = 0; pass <
|
|
705
|
+
for (let pass = 0; pass < numberRepeats; pass += 1) {
|
|
623
706
|
for (const child of expression.expressions ?? []) {
|
|
624
707
|
const value = evaluate(child);
|
|
625
708
|
|
|
@@ -635,6 +718,160 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
635
718
|
return members.length === 0 ? null : rpValue("ordered", members, baseType);
|
|
636
719
|
}
|
|
637
720
|
|
|
721
|
+
case "null":
|
|
722
|
+
return null;
|
|
723
|
+
|
|
724
|
+
case "durationGte":
|
|
725
|
+
case "durationLt": {
|
|
726
|
+
// Durations are compared as elapsed seconds; "longer (or equal, within the
|
|
727
|
+
// limits imposed by truncation …)" (§2.11.3.20/.21). NULL operands propagate.
|
|
728
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
729
|
+
|
|
730
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return booleanValue(expression.kind === "durationGte" ? a >= b : a < b);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
case "default":
|
|
738
|
+
// "Returns the associated qti-default-value or NULL if no default value was
|
|
739
|
+
// declared" (§2.11.1.3).
|
|
740
|
+
return env.variableDefault?.(expression.identifier ?? "") ?? null;
|
|
741
|
+
|
|
742
|
+
case "patternMatch": {
|
|
743
|
+
const operand = expression.expressions?.[0];
|
|
744
|
+
const value = operand === undefined ? null : evaluate(operand);
|
|
745
|
+
const member = value?.values[0];
|
|
746
|
+
|
|
747
|
+
if (value === null || typeof member !== "string") {
|
|
748
|
+
return null; // "If the sub-expression is NULL then the operator results in NULL" (§2.11.3.41)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (expression.pattern === undefined) {
|
|
752
|
+
throw new RpUnsupportedError("patternMatch");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// EncVariableString (§7.13): a brace-enclosed reference resolves the pattern
|
|
756
|
+
// from a variable at runtime; anything else is a literal XSD pattern.
|
|
757
|
+
const pattern = encVariableStringPattern.test(expression.pattern)
|
|
758
|
+
? env.lookupVariable(expression.pattern.slice(1, -1))?.values[0]
|
|
759
|
+
: expression.pattern;
|
|
760
|
+
|
|
761
|
+
if (typeof pattern !== "string") {
|
|
762
|
+
return null; // unresolvable pattern reference → NULL
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const matcher = xsdPatternMatcher(pattern);
|
|
766
|
+
|
|
767
|
+
if (matcher === null) {
|
|
768
|
+
throw new RpUnsupportedError("patternMatch"); // invalid pattern: refuse, never guess
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return booleanValue(matcher(member));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
case "power": {
|
|
775
|
+
const [a, b] = (expression.expressions ?? []).map((child) => singleNumber(evaluate(child)));
|
|
776
|
+
|
|
777
|
+
if (a === undefined || b === undefined || a === null || b === null) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const result = a ** b;
|
|
782
|
+
|
|
783
|
+
// "If the resulting value is outside the value set defined by float (not
|
|
784
|
+
// including positive and negative infinity) then the operator shall result in
|
|
785
|
+
// NULL" (§2.11.3.30).
|
|
786
|
+
return Number.isFinite(result) ? floatValue(result) : null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
case "containerSize": {
|
|
790
|
+
const operand = expression.expressions?.[0];
|
|
791
|
+
const container = operand === undefined ? null : evaluate(operand);
|
|
792
|
+
|
|
793
|
+
// "If the sub-expression is NULL the result is 0" (§2.11.3.32) — the spec's
|
|
794
|
+
// exception to NULL propagation; an empty container is NULL in this model.
|
|
795
|
+
return {
|
|
796
|
+
cardinality: "single",
|
|
797
|
+
baseType: "integer",
|
|
798
|
+
values: [container === null ? 0 : container.values.length],
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
case "contains": {
|
|
803
|
+
const [firstExpression, secondExpression] = expression.expressions ?? [];
|
|
804
|
+
|
|
805
|
+
if (firstExpression === undefined || secondExpression === undefined) {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const first = evaluate(firstExpression);
|
|
810
|
+
const second = evaluate(secondExpression);
|
|
811
|
+
|
|
812
|
+
if (first === null || second === null || first.cardinality === "record" || second.cardinality === "record") {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const baseType = first.baseType ?? second.baseType;
|
|
817
|
+
|
|
818
|
+
if (first.cardinality === "ordered") {
|
|
819
|
+
// "For ordered containers the second sub-expression must be a strict
|
|
820
|
+
// sub-sequence within the first" (§2.11.3.17): a contiguous in-order run.
|
|
821
|
+
const found = first.values.some(
|
|
822
|
+
(_, start) =>
|
|
823
|
+
start + second.values.length <= first.values.length &&
|
|
824
|
+
second.values.every((member, offset) =>
|
|
825
|
+
scalarsEqual(first.values[start + offset]!, member, baseType, env.normalization),
|
|
826
|
+
),
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
return booleanValue(found);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Unordered: multiset semantics — "[A,B,C] does not contain [B,B] but
|
|
833
|
+
// [A,B,B,C] does" (§2.11.3.17).
|
|
834
|
+
const remaining = [...first.values];
|
|
835
|
+
|
|
836
|
+
for (const member of second.values) {
|
|
837
|
+
const at = remaining.findIndex((candidate) => scalarsEqual(candidate, member, baseType, env.normalization));
|
|
838
|
+
|
|
839
|
+
if (at === -1) {
|
|
840
|
+
return booleanValue(false);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
remaining.splice(at, 1);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return booleanValue(true);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
case "anyN": {
|
|
850
|
+
const min = resolveNumericAttribute(expression.min, env);
|
|
851
|
+
const max = resolveNumericAttribute(expression.max, env);
|
|
852
|
+
|
|
853
|
+
if (typeof min !== "number" || typeof max !== "number") {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const members = (expression.expressions ?? []).map((child) => singleBoolean(evaluate(child)));
|
|
858
|
+
const trueCount = members.filter((member) => member === true).length;
|
|
859
|
+
const nullCount = members.filter((member) => member === null).length;
|
|
860
|
+
|
|
861
|
+
// The actual count of true lies in [trueCount, trueCount + nullCount]; the
|
|
862
|
+
// result is decided only when that whole interval is inside or outside
|
|
863
|
+
// [min, max] — this reproduces the spec's worked examples (§2.11.3, anyN).
|
|
864
|
+
if (trueCount >= min && trueCount + nullCount <= max) {
|
|
865
|
+
return booleanValue(true);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (trueCount + nullCount < min || trueCount > max) {
|
|
869
|
+
return booleanValue(false);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
638
875
|
case "stringMatch":
|
|
639
876
|
case "substring": {
|
|
640
877
|
const [a, b] = (expression.expressions ?? []).map((child) => {
|
|
@@ -735,9 +972,9 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
735
972
|
throw new RpUnsupportedError(expression.kind);
|
|
736
973
|
}
|
|
737
974
|
|
|
738
|
-
const min = expression.min ?? 0;
|
|
739
|
-
const max = expression.max ?? min;
|
|
740
|
-
const step = expression.step ?? 1;
|
|
975
|
+
const min = resolveNumericAttribute(expression.min, env) ?? 0;
|
|
976
|
+
const max = resolveNumericAttribute(expression.max, env) ?? min;
|
|
977
|
+
const step = resolveNumericAttribute(expression.step, env) ?? 1;
|
|
741
978
|
const count = Math.max(1, Math.floor((max - min) / step) + 1);
|
|
742
979
|
|
|
743
980
|
return { cardinality: "single", baseType: "integer", values: [min + Math.floor(env.random() * count) * step] };
|
|
@@ -748,8 +985,8 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
748
985
|
throw new RpUnsupportedError(expression.kind);
|
|
749
986
|
}
|
|
750
987
|
|
|
751
|
-
const min = expression.min ?? 0;
|
|
752
|
-
const max = expression.max ?? min;
|
|
988
|
+
const min = resolveNumericAttribute(expression.min, env) ?? 0;
|
|
989
|
+
const max = resolveNumericAttribute(expression.max, env) ?? min;
|
|
753
990
|
|
|
754
991
|
return floatValue(min + env.random() * (max - min));
|
|
755
992
|
}
|
|
@@ -776,7 +1013,9 @@ export function evaluateExpression(expression: RpExpressionView, env: EvalEnv):
|
|
|
776
1013
|
case "numberIncorrect":
|
|
777
1014
|
case "numberPresented":
|
|
778
1015
|
case "numberResponded":
|
|
779
|
-
case "numberSelected":
|
|
1016
|
+
case "numberSelected":
|
|
1017
|
+
case "outcomeMinimum":
|
|
1018
|
+
case "outcomeMaximum": {
|
|
780
1019
|
if (!env.testAggregate) {
|
|
781
1020
|
throw new RpUnsupportedError(expression.kind);
|
|
782
1021
|
}
|
|
@@ -819,6 +1058,15 @@ export function collectExpressionIssues(
|
|
|
819
1058
|
}
|
|
820
1059
|
} else if (!allowedKinds.has(expression.kind)) {
|
|
821
1060
|
report(expression.kind);
|
|
1061
|
+
} else if (
|
|
1062
|
+
expression.kind === "patternMatch" &&
|
|
1063
|
+
expression.pattern !== undefined &&
|
|
1064
|
+
!encVariableStringPattern.test(expression.pattern) &&
|
|
1065
|
+
xsdPatternMatcher(expression.pattern) === null
|
|
1066
|
+
) {
|
|
1067
|
+
// A literal pattern that does not compile can never evaluate: surface it at
|
|
1068
|
+
// gate time instead of as a runtime abort.
|
|
1069
|
+
report(expression.kind);
|
|
822
1070
|
}
|
|
823
1071
|
|
|
824
1072
|
for (const child of expression.expressions ?? []) {
|
package/src/rp/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { collectRpIssues, executeResponseProcessing } from "./interpreter";
|
|
2
2
|
export {
|
|
3
3
|
applyCorrectResponseOverrides,
|
|
4
|
+
applyTemplateDefaultOverrides,
|
|
4
5
|
collectTemplateIssues,
|
|
5
6
|
executeTemplateProcessing,
|
|
6
7
|
mulberry32,
|
|
@@ -13,6 +14,10 @@ export {
|
|
|
13
14
|
export { resolveTemplate } from "./templates";
|
|
14
15
|
export type {
|
|
15
16
|
CustomOperatorImplementation,
|
|
17
|
+
InterpolationTableEntryView,
|
|
18
|
+
InterpolationTableView,
|
|
19
|
+
MatchTableEntryView,
|
|
20
|
+
MatchTableView,
|
|
16
21
|
MaybeRpValue,
|
|
17
22
|
OutcomeDeclarationView,
|
|
18
23
|
OutcomeValue,
|