@conform-ed/qti-react 0.0.15 → 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 +2486 -405
- 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/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
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,
|
package/src/rp/interpreter.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
evaluateExpression,
|
|
15
15
|
type EvalEnv,
|
|
16
16
|
} from "./evaluate";
|
|
17
|
+
import { hasLookupTable, lookupTableValue } from "./lookup-table";
|
|
17
18
|
import { resolveTemplate } from "./templates";
|
|
18
19
|
import type {
|
|
19
20
|
OutcomeDeclarationView,
|
|
@@ -35,7 +36,13 @@ import {
|
|
|
35
36
|
type MaybeRpValue,
|
|
36
37
|
} from "./values";
|
|
37
38
|
|
|
38
|
-
const supportedRuleKinds = new Set([
|
|
39
|
+
const supportedRuleKinds = new Set([
|
|
40
|
+
"responseCondition",
|
|
41
|
+
"setOutcomeValue",
|
|
42
|
+
"lookupOutcomeValue",
|
|
43
|
+
"responseProcessingFragment",
|
|
44
|
+
"exitResponse",
|
|
45
|
+
]);
|
|
39
46
|
|
|
40
47
|
/**
|
|
41
48
|
* RP additionally supports the random operators: the attempt store always provides a
|
|
@@ -83,6 +90,13 @@ export function executeResponseProcessing(
|
|
|
83
90
|
function initialOutcomes(): Map<string, MaybeRpValue> {
|
|
84
91
|
const outcomes = defaultOutcomes(context.outcomeDeclarations);
|
|
85
92
|
|
|
93
|
+
// The built-in completionStatus is "declared implicitly" and starts at
|
|
94
|
+
// "not_attempted" (§2.2.2.3); seeding it into the map lets setOutcomeValue
|
|
95
|
+
// change it. Explicit declarations (legacy content) keep the declared path.
|
|
96
|
+
if (!outcomes.has("completionStatus")) {
|
|
97
|
+
outcomes.set("completionStatus", rpValue("single", [context.completionStatus ?? "not_attempted"], "identifier"));
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
// Adaptive carry-over: prior outcome values (from earlier attempts in the same
|
|
87
101
|
// item session) replace the declared defaults.
|
|
88
102
|
for (const [identifier, prior] of Object.entries(context.priorOutcomes ?? {})) {
|
|
@@ -110,6 +124,16 @@ export function executeResponseProcessing(
|
|
|
110
124
|
|
|
111
125
|
const env: EvalEnv = {
|
|
112
126
|
lookupVariable: (identifier) => {
|
|
127
|
+
// Built-in session variables (reserved identifiers; items must not declare
|
|
128
|
+
// them): duration in seconds, numAttempts including the current attempt.
|
|
129
|
+
if (identifier === "duration") {
|
|
130
|
+
return context.duration === undefined ? null : rpValue("single", [context.duration], "duration");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (identifier === "numAttempts") {
|
|
134
|
+
return context.numAttempts === undefined ? null : rpValue("single", [context.numAttempts], "integer");
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
const declaration = declarationsById.get(identifier);
|
|
114
138
|
|
|
115
139
|
if (declaration) {
|
|
@@ -130,6 +154,22 @@ export function executeResponseProcessing(
|
|
|
130
154
|
},
|
|
131
155
|
responseDeclaration: (identifier) => declarationsById.get(identifier),
|
|
132
156
|
responseValue: (identifier) => context.responses[identifier] ?? null,
|
|
157
|
+
variableDefault: (identifier) => {
|
|
158
|
+
const declaration =
|
|
159
|
+
declarationsById.get(identifier) ??
|
|
160
|
+
context.outcomeDeclarations.find((entry) => entry.identifier === identifier) ??
|
|
161
|
+
templateDeclarationsById.get(identifier);
|
|
162
|
+
|
|
163
|
+
if (!declaration?.defaultValue) {
|
|
164
|
+
return null; // "NULL if no default value was declared" (§2.11.1.3)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return rpValue(
|
|
168
|
+
declaration.cardinality,
|
|
169
|
+
declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
|
|
170
|
+
declaration.baseType,
|
|
171
|
+
);
|
|
172
|
+
},
|
|
133
173
|
normalization: context.normalization,
|
|
134
174
|
random: context.random,
|
|
135
175
|
customOperators: context.customOperators,
|
|
@@ -162,6 +202,28 @@ export function executeResponseProcessing(
|
|
|
162
202
|
continue;
|
|
163
203
|
}
|
|
164
204
|
|
|
205
|
+
// "A simple group of responseRules" (§5.118): executes inline, in order, over
|
|
206
|
+
// the same outcome state; exitResponse propagates through the recursion.
|
|
207
|
+
if (rule.kind === "responseProcessingFragment") {
|
|
208
|
+
executeRules(rule.rules ?? []);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (rule.kind === "lookupOutcomeValue") {
|
|
213
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
214
|
+
const declaration = context.outcomeDeclarations.find((entry) => entry.identifier === rule.identifier);
|
|
215
|
+
|
|
216
|
+
if (!hasLookupTable(declaration)) {
|
|
217
|
+
// §5.87 presumes "the lookupTable associated with the outcome's
|
|
218
|
+
// declaration" — no table, no spec-defined value: refuse, never guess.
|
|
219
|
+
throw new RpUnsupportedError("lookupOutcomeValue");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
outcomes.set(rule.identifier, lookupTableValue(declaration, evaluateExpression(rule.expression, env)));
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
165
227
|
// responseCondition
|
|
166
228
|
if (rule.responseIf && branchTaken(rule.responseIf)) {
|
|
167
229
|
continue;
|
|
@@ -195,6 +257,12 @@ export function executeResponseProcessing(
|
|
|
195
257
|
export interface RpIssueOptions {
|
|
196
258
|
/** `customOperator` classes the consumer has registered implementations for. */
|
|
197
259
|
readonly customOperatorClasses?: ReadonlySet<string>;
|
|
260
|
+
/**
|
|
261
|
+
* The item's outcome declarations; when supplied, a `lookupOutcomeValue` rule whose
|
|
262
|
+
* declaration carries no lookupTable is reported statically (gate parity with the
|
|
263
|
+
* runtime refusal).
|
|
264
|
+
*/
|
|
265
|
+
readonly outcomeDeclarations?: readonly OutcomeDeclarationView[];
|
|
198
266
|
}
|
|
199
267
|
|
|
200
268
|
/** Static coverage walk for `canDeliver`: reports constructs the interpreter lacks without executing. */
|
|
@@ -223,10 +291,22 @@ export function collectRpIssues(
|
|
|
223
291
|
continue;
|
|
224
292
|
}
|
|
225
293
|
|
|
294
|
+
if (rule.kind === "lookupOutcomeValue" && options?.outcomeDeclarations !== undefined) {
|
|
295
|
+
const declaration = options.outcomeDeclarations.find((entry) => entry.identifier === rule.identifier);
|
|
296
|
+
|
|
297
|
+
if (!hasLookupTable(declaration)) {
|
|
298
|
+
report("lookupOutcomeValue", "Outcome declaration has no matchTable/interpolationTable.");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
226
302
|
if (rule.expression) {
|
|
227
303
|
collectExpressionIssues(rule.expression, rpExpressionKinds, report, options?.customOperatorClasses);
|
|
228
304
|
}
|
|
229
305
|
|
|
306
|
+
if (rule.rules) {
|
|
307
|
+
walkRules(rule.rules); // responseProcessingFragment nesting (§5.118)
|
|
308
|
+
}
|
|
309
|
+
|
|
230
310
|
for (const branch of [rule.responseIf, ...(rule.responseElseIfs ?? [])]) {
|
|
231
311
|
if (branch) {
|
|
232
312
|
collectExpressionIssues(branch.expression, rpExpressionKinds, report, options?.customOperatorClasses);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lookupTable evaluation for the `lookupOutcomeValue` rule (§5.87): "sets the value
|
|
3
|
+
* of an outcome variable to the value obtained by looking up the value of the
|
|
4
|
+
* associated expression in the lookupTable associated with the outcome's
|
|
5
|
+
* declaration." Shared by the item interpreter and the test controller — the test's
|
|
6
|
+
* own outcome declarations use the same view type.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { OutcomeDeclarationView } from "./types";
|
|
10
|
+
import { coerceScalar, rpValue, singleNumber, type MaybeRpValue } from "./values";
|
|
11
|
+
|
|
12
|
+
export function hasLookupTable(declaration: OutcomeDeclarationView | undefined): declaration is OutcomeDeclarationView {
|
|
13
|
+
return declaration?.matchTable !== undefined || declaration?.interpolationTable !== undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Look `source` up in the declaration's lookupTable. A NULL or non-numeric source is
|
|
18
|
+
* read as "no matching table entry is found" (§5.90.1) — an interpretive call the
|
|
19
|
+
* spec leaves open — so it takes the defaultValue path, never a refusal. "If
|
|
20
|
+
* omitted, the NULL value is used." (§5.90.1/§5.78.1)
|
|
21
|
+
*/
|
|
22
|
+
export function lookupTableValue(declaration: OutcomeDeclarationView, source: MaybeRpValue): MaybeRpValue {
|
|
23
|
+
const value = singleNumber(source);
|
|
24
|
+
const table = declaration.matchTable ?? declaration.interpolationTable;
|
|
25
|
+
const target = value === null ? undefined : matchedTarget(declaration, value);
|
|
26
|
+
const result = target ?? table?.defaultValue;
|
|
27
|
+
|
|
28
|
+
if (result === undefined) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return rpValue("single", [coerceScalar(result, declaration.baseType)], declaration.baseType);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchedTarget(declaration: OutcomeDeclarationView, value: number) {
|
|
36
|
+
if (declaration.matchTable) {
|
|
37
|
+
// "the first qti-match-table-entry with an exact match to the source" (§5.90)
|
|
38
|
+
return declaration.matchTable.matchTableEntries.find((entry) => entry.sourceValue === value)?.targetValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// "the first interpolationTableEntry with a sourceValue that is less than or equal
|
|
42
|
+
// to (subject to includeBoundary) the source value" (§5.78); document order decides.
|
|
43
|
+
return declaration.interpolationTable?.interpolationTableEntries.find((entry) =>
|
|
44
|
+
(entry.includeBoundary ?? true) ? entry.sourceValue <= value : entry.sourceValue < value,
|
|
45
|
+
)?.targetValue;
|
|
46
|
+
}
|